# for annotating Options as input of itself
from __future__ import annotations
import configparser
from collections.abc import MutableMapping
import numpy as np
[docs]
class Options(MutableMapping, dict):
"""
This class is responsible for Options.
Parameters
----------
default_options_path : str
The path to default options that can be overwritten by the user.
evaluation_parameters : dict
Parameters used to evaluate the options.
user_options : dict
User defined values to overwrite default options.
Attributes
----------
unset_user_options : set
The names of the user options whose value is ``None``, which stands
for the default: they are not set, and not in ``useroptions``, but
their names are checked by
:py:meth:`validate_option_names`.
Notes
-----
The mapping entry ``options["useroptions"]`` holds the set of option
names supplied by the user with non-None values.
:py:meth:`load_options_file` leaves their values unchanged.
"""
def __init__(
self,
default_options_path: str,
evaluation_parameters: dict = None,
user_options: dict = None,
):
"""
Initialize the options using default options and specified options from
the user.
"""
super().__init__()
self.descriptions = dict()
self["useroptions"] = set()
# load options from file
self.load_options_file(default_options_path, evaluation_parameters)
# User options; a value of None stands for the default, as an empty
# value does in MATLAB BADS (setupoptions.m)
self.unset_user_options = set()
if user_options is not None:
self.unset_user_options.update(
key for key, value in user_options.items() if value is None
)
user_options = {
key: value
for key, value in user_options.items()
if value is not None
}
self.update(user_options)
self["useroptions"].update(user_options.keys())
[docs]
@classmethod
def init_from_existing_options(
cls,
default_options_path: str,
evaluation_parameters: dict = None,
other: Options = None,
):
"""
Initialize an options instance using default options and another options
instance.
Only the user-definied options from the other object will overwrite the
default options. Everything else will come from the default options.
Parameters
----------
default_options_path : str
The path to default options that can be overwritten by the user.
evaluation_parameters : dict
Parameters used to evaluate the options.
other : Options
User defined values to overwrite default options.
Returns
-------
new_options : Options
The new options object with the values merged as described above.
"""
if other is None:
user_options = None
else:
user_option_keys = other.get("useroptions")
user_options = {k: other.get(k) for k in user_option_keys}
new_options = cls(
default_options_path, evaluation_parameters, user_options
)
return new_options
[docs]
def load_options_file(
self, options_path: str, evaluation_parameters: dict = None
):
"""
Load options from an ini file and evaluate them using the specified
``evaluation_parameters``.
Note that strings starting with # in the .ini file act as description to
the option in the following line.
Parameters
----------
options_path : str
The path to an options ini file that should be loaded.
evaluation_parameters : dict, optional
Parameters used to evaluate the options.
"""
# evaluation_parameters
for key, val in evaluation_parameters.items():
global_scope = globals()
exec(f"{key} = {val}", global_scope)
options_list = _read_config_file(options_path)
for key, value, description in options_list:
if key == "useroptions":
continue
# An option that the user set keeps the user's value, and takes
# its description from the file all the same
self.descriptions[key] = description
if key not in self.get("useroptions"):
self[key] = eval(value)
[docs]
def validate_option_names(self, options_paths: list):
"""
Check that ini files specified by the list of ``options_paths`` contain
the option names from this options object at least once.
Note that this method checks not if there are option names in files that
are not in the object.
Parameters
----------
options_paths : list of str
A list of paths to ini files can contain the allowed option names.
Raises
------
ValueError
Raised when an option exists in this object but not in one of the
specified ini files.
"""
# create set of option names from all ini files
file_option_names = set()
for options_path in options_paths:
file_option_names.update(
_read_config_file(options_path)[:, 0].flatten()
)
for key in list(self.keys()) + sorted(self.unset_user_options):
if key != "useroptions" and key not in file_option_names:
raise ValueError("The option {} does not exist.".format(key))
[docs]
def validate_boolean_options(
self,
options_paths: list,
extra_names: tuple = (),
excluded_names: tuple = (),
):
"""
Check that the user's value of each boolean option is ``True`` or
``False``.
The boolean options are those whose default in the ini files
specified by ``options_paths`` is ``True`` or ``False``, and those
named in ``extra_names``, except those named in ``excluded_names``.
NumPy booleans are booleans. Any other value is refused, a
MATLAB-style string such as ``"off"``, which is truthy, included.
Parameters
----------
options_paths : list of str
A list of paths to the ini files that hold the default options.
extra_names : tuple of str, optional
The names of other options that take ``True`` or ``False``,
and whose default is ``None``.
excluded_names : tuple of str, optional
The names of options whose default is ``True`` or ``False`` and
that take other values too.
Raises
------
ValueError
Raised when the user's value of a boolean option is not a
boolean.
"""
boolean_names = set(extra_names)
for options_path in options_paths:
for key, value, _ in _read_config_file(options_path):
# the default's text, without an inline comment
if value.split("#")[0].strip() in ("True", "False"):
boolean_names.add(key)
boolean_names -= set(excluded_names)
for key in sorted(boolean_names & self["useroptions"]):
if not isinstance(self[key], (bool, np.bool_)):
# the options of extra_names default to None, which a user
# can give too
accepted = (
"True, False or None"
if key in extra_names
else "True or False"
)
raise ValueError(
f"options['{key}'] needs to be {accepted}, not "
f"{self[key]!r}."
)
def __setitem__(self, key, val):
dict.__setitem__(self, key, val)
def __getitem__(self, key):
return dict.__getitem__(self, key)
def __iter__(self):
yield from sorted(dict.__iter__(self))
def __len__(self):
return dict.__len__(self)
def __delitem__(self, key):
return dict.__delitem__(self, key)
[docs]
def eval(self, key: str, evaluation_parameters: dict):
"""
Evaluate an option using `evaluation_parameters` if it is a callable,
otherwise return the value of the option.
Parameters
----------
key : str
The name of the option.
evaluation_parameters : dict
Parameters for the options in case it is a callable. These have to
match the key arguments of the callable and are ignored if it is not
a callable.
Returns
-------
val : object
Value of the object which has been evaluated if it is a callable.
"""
if callable(self.get(key)):
return self.get(key)(**evaluation_parameters)
else:
return self.get(key)
def __str__(self):
"""
Returns the options in a format key: value (description).
Returns
-------
str
The str to describe an options object.
"""
return "".join(
[
"{}: {} ({}) \n".format(k, v, str(self.descriptions.get(k)))
for (k, v) in self.items()
]
)
def _read_config_file(options_path: str):
"""
Private helper method to read a config file and return the options as a
list of tuples (key, value, description).
Note that strings starting with # in the .ini file act as description to
the option in the following line. The comment lines are read apart from
the values, so that a description keeps its whole text, an ``=`` or a
``:`` included.
"""
conf = configparser.ConfigParser(delimiters=("=",))
# do not lower() both values as well as descriptions
conf.optionxform = str
read_paths = conf.read(options_path)
# The description of an option is the last comment line above it
descriptions = dict()
description = ""
for path in read_paths:
with open(path) as file:
for line in file:
line = line.strip()
if line.startswith("#"):
description = line.strip("# ")
elif "=" in line:
key = line.split("=", 1)[0].strip()
descriptions[key] = description
description = ""
option_list = list()
for section in conf.sections():
for key, value in conf.items(section):
option_list.append([key, value, descriptions[key]])
if len(option_list) == 0:
raise ValueError(
"The option file at {} does not contain options.".format(
options_path
)
)
return np.array(option_list)