# Copyright 2019 TerraPower, LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
System to handle basic configuration settings.
Notes
-----
The type of each setting is derived from the type of the default
value. When users set values to their settings, ARMI enforces
these types with schema validation. This also allows for more
complex schema validation for settings that are more complex
dictionaries (e.g. XS, rx coeffs).
"""
from collections import namedtuple
from typing import List, Optional, Tuple
import copy
import datetime
import voluptuous as vol
from armi import runLog
from armi.reactor.flags import Flags
# Options are used to imbue existing settings with new Options. This allows a setting
# like `neutronicsKernel` to strictly enforce options, even though the plugin that
# defines it does not know all possible options, which may be provided from other
# plugins.
Option = namedtuple("Option", ["option", "settingName"])
Default = namedtuple("Default", ["value", "settingName"])
[docs]class Setting:
"""
A particular setting.
.. impl:: The setting default is mandatory.
:id: I_ARMI_SETTINGS_DEFAULTS
:implements: R_ARMI_SETTINGS_DEFAULTS
Setting objects hold all associated information of a setting in ARMI and should
typically be accessed through the Settings methods rather than directly.
Settings require a mandatory default value.
Setting subclasses can implement custom ``load`` and ``dump`` methods that can
enable serialization (to/from dicts) of custom objects. When you set a
setting's value, the value will be unserialized into the custom object and when
you call ``dump``, it will be serialized. Just accessing the value will return
the actual object in this case.
"""
def __init__(
self,
name,
default,
description,
label=None,
options=None,
schema=None,
enforcedOptions=False,
subLabels=None,
isEnvironment=False,
oldNames: Optional[List[Tuple[str, Optional[datetime.date]]]] = None,
):
"""
Initialize a Setting object.
Parameters
----------
name : str
the setting's name
default : object
The setting's default value
description : str
The description of the setting
label : str, optional
the shorter description used for the ARMI GUI
options : list, optional
Legal values (useful in GUI drop-downs)
schema : callable, optional
A function that gets called with the configuration
VALUES that build this setting. The callable will
either raise an exception, safely modify/update,
or leave unchanged the value. If left blank,
a type check will be performed against the default.
enforcedOptions : bool, optional
Require that the value be one of the valid options.
subLabels : tuple, optional
The names of the fields in each tuple for a setting that accepts a list
of tuples. For example, if a setting is a list of (assembly name, file name)
tuples, the sublabels would be ("assembly name", "file name").
This is needed for building GUI widgets to input such data.
isEnvironment : bool, optional
Whether this should be considered an "environment" setting. These can be
used by the Case system to propagate environment options through
command-line flags.
oldNames : list of tuple, optional
List of previous names that this setting used to have, along with optional
expiration dates. These can aid in automatic migration of old inputs. When
provided, if it is appears that the expiration date has passed, old names
will result in errors, requiring to user to update their input by hand to
use more current settings.
"""
assert description, f"Setting {name} defined without description."
assert description != "None", f"Setting {name} defined without description."
self.name = name
self.description = description or name
self.label = label or name
self.options = options
self.enforcedOptions = enforcedOptions
self.subLabels = subLabels
self.isEnvironment = isEnvironment
self.oldNames: List[Tuple[str, Optional[datetime.date]]] = oldNames or []
self._default = default
self._value = copy.deepcopy(default) # break link from _default
# Retain the passed schema so that we don't accidentally stomp on it in
# addOptions(), et.al.
self._customSchema = schema
self._setSchema()
@property
def underlyingType(self):
"""Useful in categorizing settings, e.g. for GUI."""
return type(self._default)
@property
def containedType(self):
"""The subtype for lists."""
# assume schema set to [int] or [str] or something similar
try:
containedSchema = self.schema.schema[0]
if isinstance(containedSchema, vol.Coerce):
# special case for Coerce objects, which
# store their underlying type as ``.type``.
return containedSchema.type
return containedSchema
except TypeError:
# cannot infer. fall back to str
return str
def _setSchema(self):
"""Apply or auto-derive schema of the value."""
schema = self._customSchema
if schema:
self.schema = schema
elif self.options and self.enforcedOptions:
self.schema = vol.Schema(vol.In(self.options))
else:
# Coercion is needed in some GUI instances where lists are getting set as strings.
if isinstance(self.default, list) and self.default:
# Non-empty default: assume the default has the desired contained type
# Coerce all values to the first entry in the default so mixed floats and ints work.
# Note that this will not work for settings that allow mixed
# types in their lists (e.g. [0, '10R']), so those all need custom schemas.
self.schema = vol.Schema([vol.Coerce(type(self.default[0]))])
else:
self.schema = vol.Schema(vol.Coerce(type(self.default)))
@property
def default(self):
return self._default
@property
def value(self):
return self._value
@value.setter
def value(self, val):
"""
Set the value directly.
Notes
-----
Can't just decorate ``setValue`` with ``@value.setter`` because
some callers use setting.value=val and others use setting.setValue(val)
and the latter fails with ``TypeError: 'XSSettings' object is not callable``
"""
return self.setValue(val)
[docs] def setValue(self, val):
"""
Set value of a setting.
This validates it against its value schema on the way in.
Some setting values are custom serializable objects.
Rather than writing them directly to YAML using
YAML's Python object-writing features, we prefer
to use our own custom serializers on subclasses.
"""
try:
val = self.schema(val)
except vol.error.Invalid:
runLog.error(f"Error in setting {self.name}, val: {val}.")
raise
self._value = self._load(val)
[docs] def addOptions(self, options: List[Option]):
"""Extend this Setting's options with extra options."""
self.options.extend([o.option for o in options])
self._setSchema()
[docs] def addOption(self, option: Option):
"""Extend this Setting's options with an extra option."""
self.addOptions([option])
[docs] def changeDefault(self, newDefault: Default):
"""Change the default of a setting, and also the current value."""
self._default = newDefault.value
self.value = newDefault.value
@staticmethod
def _load(inputVal):
"""
Create setting value from input value.
In some custom settings, this can return a custom object
rather than just the input value.
"""
return inputVal
[docs] def dump(self):
"""
Return a serializable version of this setting's value.
Override to define custom deserializers for custom/compund settings.
"""
return self._value
def __repr__(self):
return "<{} {} value:{} default:{}>".format(
self.__class__.__name__, self.name, self.value, self.default
)
def __getstate__(self):
"""
Remove schema during pickling because it is often unpickleable.
Notes
-----
Errors are often with
``AttributeError: Can't pickle local object '_compile_scalar.<locals>.validate_instance'``
See Also
--------
armi.settings.caseSettings.Settings.__setstate__ : regenerates the schema upon load
Note that we don't do it at the individual setting level because it'd be too
O(N^2).
"""
state = copy.deepcopy(self.__dict__)
for trouble in ("schema", "_customSchema"):
if trouble in state:
del state[trouble]
return state
[docs] def revertToDefault(self):
"""
Revert a setting back to its default.
Notes
-----
Skips the property setter because default val
should already be validated.
"""
self._value = copy.deepcopy(self.default)
[docs] def isDefault(self):
"""
Returns a boolean based on whether or not the setting equals its default value.
It's possible for a setting to change and not be reported as such when it is changed back to its default.
That behavior seems acceptable.
"""
return self.value == self.default
@property
def offDefault(self):
"""Return True if the setting is not the default value for that setting."""
return not self.isDefault()
[docs] def getCustomAttributes(self):
"""Hack to work with settings writing system until old one is gone."""
return {"value": self.value}
[docs] def getDefaultAttributes(self):
"""
Additional hack, residual from when settings system could write settings definitions.
This is only needed here due to the unit tests in test_settings.
"""
return {
"value": self.value,
"type": type(self.default),
"default": self.default,
}
def __copy__(self):
setting = Setting(
str(self.name),
copy.copy(self._default),
description=None if self.description is None else str(self.description),
label=None if self.label is None else str(self.label),
options=copy.copy(self.options),
schema=copy.copy(self.schema) if hasattr(self, "schema") else None,
enforcedOptions=bool(self.enforcedOptions),
subLabels=copy.copy(self.subLabels),
isEnvironment=bool(self.isEnvironment),
oldNames=None if self.oldNames is None else list(self.oldNames),
)
setting._value = copy.deepcopy(self._value)
return setting
[docs]class FlagListSetting(Setting):
"""Subclass of :py:class:`Setting <armi.settings.Setting>` convert settings between flags and strings."""
def __init__(
self,
name,
default,
description=None,
label=None,
oldNames: Optional[List[Tuple[str, Optional[datetime.date]]]] = None,
):
Setting.__init__(
self,
name=name,
default=default,
description=description,
label=label,
options=None,
schema=self.schema,
enforcedOptions=None,
subLabels=None,
isEnvironment=False,
oldNames=oldNames,
)
[docs] @staticmethod
def schema(val) -> List[Flags]:
"""
Return a list of :py:class:`Flags <armi.reactor.flags.Flags`.
Raises
------
TypeError
When ``val`` is not a list.
ValueError
When ``val`` is not an instance of str or Flags.
"""
if not isinstance(val, list):
raise TypeError(f"Expected `{val}` to be a list.")
flagVals = []
for v in val:
if isinstance(v, str):
flagVals.append(Flags.fromString(v))
elif isinstance(v, Flags):
flagVals.append(v)
else:
raise ValueError(f"Invalid flag input `{v}` in `FlagListSetting`")
return flagVals
[docs] def dump(self) -> List[str]:
"""Return a list of strings converted from the flag values."""
return [Flags.toString(v) for v in self.value]