Source code for armi.settings.setting

# 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]