# -*- coding: utf-8 -*-
"""Control parameters for a step in a MolSSI flowchart"""
import collections.abc
from distutils.util import strtobool
import importlib
import json
import logging
from seamm_util import Q_
from seamm_util import ureg
import pprint
logger = logging.getLogger(__name__)
# All for a default root context for evaluating expressions
# and variables
root_context = None
[docs]
def set_context(context):
"""Set the default root context for evaluating variables
and expressions in parameters."""
global root_context
root_context = context
[docs]
class Parameter(collections.abc.MutableMapping):
"""A single parameter, with defaults, units, description, etc.
This is object is a dict-like mutable mapping with properties
to make it appear to be a simple object with attributes.
"""
def __init__(self, *args, **kwargs):
"""Initialize this parameter"""
logger.debug("\nParameter.__init__")
self._data = {}
self.dimensionality = None
self._widget = None
self.reset()
# Handle positional or keyword arguments
for data in args:
if isinstance(data, dict):
self.update(data)
else:
raise RuntimeError("Positional arguments must be dicts")
self.update(kwargs)
logger.debug("Finished constructing Parameter\n")
def __getitem__(self, key):
"""Allow [] access to the dictionary!"""
return self._data[key]
def __setitem__(self, key, value):
"""Allow x[key] access to the data"""
self._data[key] = value
def __delitem__(self, key):
"""Allow deletion of keys"""
del self._data[key]
def __iter__(self):
"""Allow iteration over the object"""
return iter(self._data)
def __len__(self):
"""The len() command"""
return len(self._data)
def __repr__(self):
"""The official string representation of this object"""
if self.units is None or self.units == "":
return self.value
else:
return ("{} {}").format(self.value, self.units)
def __str__(self):
if self.units is None or self.units == "":
if self.kind == "integer":
try:
value = int(self.value)
return ("{:" + self.format_string + "}").format(value)
except Exception:
return ("{}").format(self.value)
if self.kind == "float":
try:
value = float(self.value)
return ("{:" + self.format_string + "}").format(value)
except ValueError:
return ("{}").format(self.value)
if self.format_string == "":
return str(self.value)
else:
return ("{:" + self.format_string + "}").format(self.value)
else:
if self.kind == "integer":
try:
value = int(self.value)
return ("{:" + self.format_string + "} {}").format(
value, self.units
)
except ValueError:
return ("{} {}").format(self.value, self.units)
if self.kind == "float":
try:
value = float(self.value)
return ("{:" + self.format_string + "} {}").format(
value, self.units
)
except Exception:
return ("{} {}").format(self.value, self.units)
if self.format_string == "":
return "{} {}".format(self.value, self.units)
else:
return ("{:" + self.format_string + "} {}").format(
self.value, self.units
)
def __contains__(self, item):
"""Return a boolean indicating if a key exists."""
if item in self._data:
return True
return False
def __eq__(self, other):
"""Return a boolean if this object is equal to another"""
return self._data == other._data
[docs]
def copy(self):
"""Return a shallow copy of the dictionary"""
return self._data.copy()
@property
def value(self):
"""The current value of the parameter. May be a value, a
Python expression containing variables prefix with $,
standard operators or parentheses."""
if "value" not in self._data:
self._data["value"] = self._data["default"]
result = self._data["value"]
if result is None:
result = self._data["default"]
return result
@value.setter
def value(self, value):
self._data["value"] = value
@property
def default(self):
"""The current default of the parameter. May be a value, a
Python expression containing variables prefix with $,
standard operators or parenthesise, or a pint units
quantity."""
return self._data["default"]
@default.setter
def default(self, value):
self._data["default"] = value
@property
def kind(self):
"""The type of the parameter: integer, float, string,
enum or special.
This can be used to convert the value to the correct
type in e.g. get_value."""
return self._data["kind"]
@kind.setter
def kind(self, value):
if value not in ("integer", "float", "string"):
raise RuntimeError(
"The 'kind' must be 'integer', 'float', or "
"'string', not '{}'".format(value)
)
self._data["kind"] = value
@property
def units(self):
"""The units, as a string. These need to be compatible with
pint"""
if "units" not in self._data:
self._data["units"] = self._data["default_units"]
if self._data["units"] is None:
return self["default_units"]
return self._data["units"]
@units.setter
def units(self, value):
logger.debug("units: value = '{}'".format(value))
if value == "":
value = None
if value is None:
self.dimensionality = None
else:
tmp = ureg(value)
logger.debug(" tmp = '{}'".format(tmp))
if self.dimensionality is None:
self.dimensionality = tmp.dimensionality
logger.debug(" dimensionality = '{}'".format(self.dimensionality))
if tmp.dimensionality != self.dimensionality:
raise RuntimeError(
(
"Units '{}' have a different dimensionality than "
"the parameters: '{}' != '{}'"
).format(value, tmp.dimensionality, self.dimensionality)
)
self._data["units"] = value
@property
def default_units(self):
"""The default units, as a string. These need to be compatible with
pint"""
return self._data["default_units"]
@default_units.setter
def default_units(self, value):
if value == "":
value = None
if value is None:
self.dimensionality = None
else:
tmp = ureg(value)
if self.dimensionality is None:
self.dimensionality = tmp.dimensionality
if tmp.dimensionality != self.dimensionality:
raise RuntimeError(
(
"The default units '{}' have a different "
"dimensionality than the parameters: "
"'{}' != '{}'"
).format(value, tmp.dimensionality, self.dimensionality)
)
self._data["default_units"] = value
@property
def enumeration(self):
"""The possible values for an enumerated type."""
return self._data["enumeration"]
@property
def format_string(self):
"""The format string for the value"""
return self._data["format_string"]
@format_string.setter
def format_string(self, value):
self._data["format_string"] = value
@property
def description(self):
"""Short description of this parameter, preferable just a
few words"""
return self._data["description"]
@description.setter
def description(self, value):
self._data["description"] = value
@property
def help_text(self):
"""A longer description of this parameter that is suitable
for e.g. help text."""
return self._data["help_text"]
@help_text.setter
def help_text(self, value):
self._data["help_text"] = value
@property
def has_units(self):
"""Does this parameter have units associated?"""
if self.dimensionality is None:
return False
if self.dimensionality == "":
return False
return True
@property
def is_expr(self):
"""Is the current value a variable reference or
expression?"""
if isinstance(self.value, str) and len(self.value) > 0:
return self.value and self.value[0] == "$"
else:
return False
[docs]
def get(self, context=None, formatted=False, units=True):
"""Return the value evaluated in the given context"""
if self.is_expr:
if context is None:
global root_context
if root_context is None:
raise RuntimeError("No context available")
result = eval(self.value[1:], root_context)
else:
result = eval(self.value[1:], context)
else:
result = self.value
# If it is an enum, just return that.
if self.enumeration is not None and result in self.enumeration:
if self.kind == "boolean":
return bool(strtobool(result))
else:
return result
# convert to proper type
if self.kind == "integer":
result = int(result)
elif self.kind == "float":
result = float(result)
elif self.kind == "boolean":
if isinstance(result, str):
result = bool(strtobool(result))
elif not isinstance(result, bool):
result = bool(result)
elif self.kind == "list" or self.kind == "periodic table":
if not isinstance(result, list):
if isinstance(result, str) and len(result) > 0 and result[0] != "$":
result = json.loads(result)
return result
elif self.kind == "dictionary":
if not isinstance(result, dict):
result = json.loads(result)
return result
# format if requested
if formatted:
fstring = self.format_string
if fstring is not None and fstring != "":
result = f"{result:{fstring}}"
if self.units is not None and self.units != "":
result += " " + self.units
# and run into pint quantity if requested
if units and self.units is not None and self.units != "":
result = Q_(result, self.units)
return result
[docs]
def set(self, value):
"""Set the fields based on the type of value given"""
if self.kind == "special" or self.kind == "periodic table":
self.value = value
elif self.kind == "list":
self.value = value
elif isinstance(value, tuple) or isinstance(value, list):
if len(value) == 1:
self.value = value[0]
elif len(value) == 2:
self.value = value[0]
self.units = value[1]
else:
raise RuntimeError(
"Parameter.set expected a sequence of length "
"1 or 2, not '{}'".format(len(value))
)
else:
self.value = value
[docs]
def reset(self):
"""Reset to an empty state"""
self._data = {
"default": None,
"kind": None,
"widget": None,
"default_units": None,
"enumeration": None,
"format_string": None,
"group": "",
"description": None,
"help_text": None,
}
self.dimensionality = None
[docs]
def to_dict(self):
"""Convert into a string suitable for editing"""
result = dict()
# if self['kind'] == 'list':
# result['value'] = json.dumps(self.value)
# elif self['kind'] == 'dict':
# result['value'] = json.dumps(self.value)
# else:
# result['value'] = self.value
result["value"] = self.value
result["units"] = self.units
return result
[docs]
def update(self, data):
"""Update values from a dict
This assumes that the static data such as 'kind' and
'default' has been created already.
"""
logger.debug("Parameter.update....")
for key, value in data.items():
logger.debug("{:>10s} {}".format(key, value))
if key in ("value", "default"):
# if self['kind'] in ('list', 'dictionary'):
# self._data[key] = json.loads(value)
# else:
self._data[key] = value
elif key == "units":
self._data[key] = value
elif key not in self:
raise RuntimeError(
"update: dictionary not compatible with Parameters,"
" which do not have an attribute '{}'".format(key)
)
else:
self._data[key] = value
# Update the dimensionality if needed
if "units" in self._data:
self.units = self._data["units"]
if "default_units" in self._data:
self.default_units = self._data["default_units"]
[docs]
def debug_print(self):
logger.debug("\nParameter instance:\n{}".format(pprint.pformat(self._data)))
[docs]
class Parameters(collections.abc.MutableMapping):
"""A dict-like container for parameters"""
def __init__(self, defaults={}, data=None):
"""Create an instance, optionally from a dict"""
logger.debug("\nParameters.__init__")
logger.debug(pprint.pformat(defaults))
self.defaults = defaults
logger.debug("\ndefaults:\n{}".format(pprint.pformat(defaults)))
self._data = {}
self.initialize()
if logger.isEnabledFor(logging.DEBUG):
logger.debug("\nafter defaults:")
for key, value in self.items():
logger.debug(" {}: {}".format(key, pprint.pformat(value._data)))
if data:
if isinstance(data, dict):
self.update(data)
if logger.isEnabledFor(logging.DEBUG):
logger.debug("\nafter data:")
for key, value in self.items():
logger.debug(
" {}: {}".format(key, pprint.pformat(value._data))
)
else:
raise RuntimeError(
"A Parameters object can be initialized with a dict object"
)
def __getitem__(self, key):
"""Allow [] access to the dictionary!"""
return self._data[key]
def __setitem__(self, key, value):
"""Allow x[key] access to the data"""
self._data[key] = value
def __delitem__(self, key):
"""Allow deletion of keys"""
del self._data[key]
def __iter__(self):
"""Allow iteration over the object"""
return iter(self._data)
def __len__(self):
"""The len() command"""
return len(self._data)
def __repr__(self):
"""The string representation of this object"""
return repr(self._data)
def __str__(self):
"""The pretty string representation of this object"""
return pprint.pformat(self.to_dict())
def __contains__(self, item):
"""Return a boolean indicating if a key exists."""
if item in self._data:
return True
return False
def __eq__(self, other):
"""Return a boolean if this object is equal to another"""
return self._data == other._data
[docs]
def copy(self):
"""Return a shallow copy of the dictionary"""
return self._data.copy()
[docs]
def to_dict(self):
"""Return a new dictionary with the pertinent data
The Parameter class only saves the value and units,
as everything else comes form the constructor below
"""
data = {}
for key in self:
try:
data[key] = self[key].to_dict()
except: # noqa: E722
logger.critical(
("An error occurred in Parameters.to_dict " "with key '{}'").format(
key
)
)
logger.critical(("The type of the key is '{}'").format(type(self[key])))
raise
return data
[docs]
def from_dict(self, data):
"""Recreate the object from a dictionary"""
self._data = dict()
# Put back in all the constant data
self.initialize()
# and update with the new data
self.update(data)
[docs]
def initialize(self):
for key, value in self.defaults.items():
self[key] = Parameter(value)
[docs]
def update(self, data):
for key in data:
self[key].update(data[key])
[docs]
def values_to_dict(self):
"""Return a dict of the raw values of the parameters
formatted for printing"""
data = {}
for key in self:
try:
data[key] = str(self[key])
except Exception as e:
logger.warning("Cannot format '{}': {}".format(key, str(e)))
data[key] = "#err#"
return data
[docs]
def current_values_to_dict(self, context=None, formatted=False, units=True):
"""Return the current values of the parameters, resolving
any expressions, etc. in the given context or the root
context is none is given."""
data = {}
for key in self:
data[key] = self[key].get(context=context, formatted=formatted, units=units)
return data