From 9f0b552eb5fe5c15e5b8912e30f08255a56cf4a7 Mon Sep 17 00:00:00 2001 From: Mitchell Young Date: Wed, 15 Apr 2020 12:37:56 -0700 Subject: [PATCH 1/2] Remove deprecated settings implementation This removes the old settings module and renames setting2.py to setting.py. This is a breaking change for plugins that were doing something like `from armi.settings import setting2 as setting`, but trivial to refactor around. --- armi/cli/entryPoint.py | 8 - armi/physics/fuelCycle/__init__.py | 2 +- armi/physics/fuelPerformance/settings.py | 2 +- .../neutronics/crossSectionSettings.py | 2 +- .../fissionProductModelSettings.py | 2 +- armi/physics/neutronics/settings.py | 2 +- armi/physics/safety/settings.py | 2 +- armi/plugins.py | 6 +- armi/settings/__init__.py | 35 +- armi/settings/caseSettings.py | 26 +- armi/settings/fwSettings/__init__.py | 2 +- armi/settings/fwSettings/databaseSettings.py | 2 +- armi/settings/fwSettings/globalSettings.py | 2 +- armi/settings/fwSettings/reportSettings.py | 2 +- armi/settings/fwSettings/xsSettings.py | 2 +- armi/settings/setting.py | 664 +++++---------- armi/settings/setting2.py | 310 ------- armi/settings/settingsIO.py | 63 +- armi/settings/tests/test_settings.py | 803 ------------------ armi/settings/tests/test_settings2.py | 2 +- 20 files changed, 252 insertions(+), 1687 deletions(-) delete mode 100644 armi/settings/setting2.py delete mode 100644 armi/settings/tests/test_settings.py diff --git a/armi/cli/entryPoint.py b/armi/cli/entryPoint.py index 07877bc2fb..c1e9bd9616 100644 --- a/armi/cli/entryPoint.py +++ b/armi/cli/entryPoint.py @@ -20,7 +20,6 @@ import armi from armi import settings -from armi.settings import setting from armi import runLog @@ -196,13 +195,6 @@ def createOptionFromSetting( helpMessage = argparse.SUPPRESS else: helpMessage = settingsInstance.description.replace("%", "%%") - if isinstance(settingsInstance, setting.StrSetting): - if settingsInstance.enforcedOptions: - choices = settingsInstance.options - if settingsInstance.options: - helpMessage += " The standard choices are: {}".format( - ", ".join(settingsInstance.options) - ) aliases = ["--" + settingName] if additionalAlias is not None: diff --git a/armi/physics/fuelCycle/__init__.py b/armi/physics/fuelCycle/__init__.py index 9fc8d30ad6..fbfed6e9e4 100644 --- a/armi/physics/fuelCycle/__init__.py +++ b/armi/physics/fuelCycle/__init__.py @@ -39,7 +39,7 @@ from armi import operators from armi.utils import directoryChangers from armi.operators import RunTypes -from armi.settings import setting2 as setting +from armi.settings import setting from armi.operators import settingsValidation from armi.physics.fuelCycle import fuelHandlers diff --git a/armi/physics/fuelPerformance/settings.py b/armi/physics/fuelPerformance/settings.py index 9ade5bdb18..52ce7ff15c 100644 --- a/armi/physics/fuelPerformance/settings.py +++ b/armi/physics/fuelPerformance/settings.py @@ -14,7 +14,7 @@ """Settings related to fuel performance.""" -from armi.settings import setting2 as setting +from armi.settings import setting from armi.operators.settingsValidation import Query diff --git a/armi/physics/neutronics/crossSectionSettings.py b/armi/physics/neutronics/crossSectionSettings.py index 1ab3b68ca5..fc0f9c57d3 100644 --- a/armi/physics/neutronics/crossSectionSettings.py +++ b/armi/physics/neutronics/crossSectionSettings.py @@ -27,7 +27,7 @@ """ import voluptuous as vol -from armi.settings.setting2 import Setting +from armi.settings import Setting from armi.physics.neutronics.crossSectionGroupManager import ( BLOCK_COLLECTIONS, diff --git a/armi/physics/neutronics/fissionProductModel/fissionProductModelSettings.py b/armi/physics/neutronics/fissionProductModel/fissionProductModelSettings.py index d374ffde84..f3022c98c9 100644 --- a/armi/physics/neutronics/fissionProductModel/fissionProductModelSettings.py +++ b/armi/physics/neutronics/fissionProductModel/fissionProductModelSettings.py @@ -14,7 +14,7 @@ """Settings related to the fission product model.""" -from armi.settings import setting2 as setting +from armi.settings import setting CONF_FP_MODEL = "fpModel" diff --git a/armi/physics/neutronics/settings.py b/armi/physics/neutronics/settings.py index fb15600139..6a65a7fc2c 100644 --- a/armi/physics/neutronics/settings.py +++ b/armi/physics/neutronics/settings.py @@ -13,7 +13,7 @@ # limitations under the License. """Some generic neutronics-related settings.""" -from armi.settings import setting2 as setting +from armi.settings import setting CONF_NEUTRONICS_KERNEL = "neutronicsKernel" diff --git a/armi/physics/safety/settings.py b/armi/physics/safety/settings.py index 5b8df9e6bb..1fb2ad3c42 100644 --- a/armi/physics/safety/settings.py +++ b/armi/physics/safety/settings.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -from armi.settings import setting2 as setting +from armi.settings import setting CONF_BETA_COMPONENTS = "betaComponents" diff --git a/armi/plugins.py b/armi/plugins.py index 07cf5f5f49..ebbf262040 100644 --- a/armi/plugins.py +++ b/armi/plugins.py @@ -323,9 +323,9 @@ def defineSettings(): See also -------- armi.physics.neutronics.NeutronicsPlugin.defineSettings - armi.settings.setting2.Setting - armi.settings.setting2.Option - armi.settings.setting2.Default + armi.settings.setting.Setting + armi.settings.setting.Option + armi.settings.setting.Default """ return [] diff --git a/armi/settings/__init__.py b/armi/settings/__init__.py index 35c3ffffc7..ef3ed87dea 100644 --- a/armi/settings/__init__.py +++ b/armi/settings/__init__.py @@ -17,20 +17,8 @@ They are one of the key inputs to an ARMI run. They say which modules to run and which modeling approximations to apply and how many cycles to run and at what power and -availability fraction and things like that. - -Notes ------ -Originally, these were just a Python module ``settings.py`` that had Python types in it. -We transitioned to XML because it was trendy. Later, we wanted better uniformity across -our input formats so we made it do YAML, too. We then added the ability to provide new -Settings from plugins, which introduced the ``setting2`` module. As a result of this -history, there are now two implementations of the ``Setting`` class, which, while they -are not related through inheritance, do expose very similar interfaces and can largely -be used interchangeably. There are no insances of the old ``Setting`` format, but we are -leaving it in the code for now to facilitate input migrations from older versions of -ARMI. We plan to remove the old implementation, and replace it with the new -implementation in ``setting2`` very soon. +availability fraction and things like that. The ARMI Framework itself has many settings +of its own, and plugins typically register some of their own settings as well. """ import fnmatch import os @@ -46,25 +34,14 @@ from armi.settings.caseSettings import Settings from armi.utils import pathTools -from armi.settings.setting import Setting, BoolSetting -from armi.settings.setting2 import Setting as Setting2 +from armi.settings.setting import Setting NOT_ENABLED = "" # An empty setting value implies that the feature -def isBoolSetting(setting: Union[Setting, Setting2]) -> bool: - """Return whether the passed setting represents a boolean value. - - This is useful during the transition from old to new settings. The old settings used - to be "strongly" typed, wheras the new once are a little bit looser in that their - types are largely enforced by their schemas. In situations where we want to treat - bool-y settings special (e.g., when we want to make command-line toggles out of - them), this provides the appropriate logic depending on which Setting class is being - used. - """ - return isinstance(setting, BoolSetting) or ( - isinstance(setting, Setting2) and isinstance(setting.default, bool) - ) +def isBoolSetting(setting:Setting) -> bool: + """Return whether the passed setting represents a boolean value.""" + return isinstance(setting.default, bool) def recursivelyLoadSettingsFiles( diff --git a/armi/settings/caseSettings.py b/armi/settings/caseSettings.py index f7c189ceb7..cfa1bb0fab 100644 --- a/armi/settings/caseSettings.py +++ b/armi/settings/caseSettings.py @@ -35,10 +35,9 @@ from armi import runLog from armi.localization import exceptions from armi.settings import fwSettings -from armi.settings import setting from armi.settings import settingsIO from armi.utils import pathTools -from armi.settings import setting2 +from armi.settings import setting class Settings: @@ -106,7 +105,7 @@ def _loadPluginSettings(self): for pluginSettings in pm.hook.defineSettings(): for pluginSetting in pluginSettings: - if isinstance(pluginSetting, setting2.Setting): + if isinstance(pluginSetting, setting.Setting): name = pluginSetting.name if name in self.settings: raise ValueError( @@ -119,7 +118,7 @@ def _loadPluginSettings(self): self.settings[name].addOptions(optionsCache.pop(name)) if name in defaultsCache: self.settings[name].changeDefault(defaultsCache.pop(name)) - elif isinstance(pluginSetting, setting2.Option): + elif isinstance(pluginSetting, setting.Option): if pluginSetting.settingName in self.settings: # modifier loaded after setting, so just apply it (no cache needed) self.settings[pluginSetting.settingName].addOption( @@ -128,7 +127,7 @@ def _loadPluginSettings(self): else: # no setting yet, cache it and apply when it arrives optionsCache[pluginSetting.settingName].append(pluginSetting) - elif isinstance(pluginSetting, setting2.Default): + elif isinstance(pluginSetting, setting.Default): if pluginSetting.settingName in self.settings: # modifier loaded after setting, so just apply it (no cache needed) self.settings[pluginSetting.settingName].changeDefault( @@ -210,7 +209,7 @@ def __setstate__(self, state): See Also -------- - armi.settings.setting2.Setting.__getstate__ : removes schema + armi.settings.setting.Setting.__getstate__ : removes schema """ self.settings = {} self.loadAllDefaults() @@ -222,15 +221,8 @@ def __setstate__(self, state): setattr(self, key, val) # with schema restored, restore all setting values for name, settingState in state["settings"].items(): - if isinstance(settingState, setting.Setting): - # old style. fully pickleable, restore entire setting object - self.settings[name] = settingState - else: - # new style, just restore value from dict. - # sorry about this being ugly. - self.settings[ - name - ]._value = settingState.value # pylint: disable=protected-access + # pylint: disable=protected-access + self.settings[name]._value = settingState.value def keys(self): return self.settings.keys() @@ -337,8 +329,8 @@ def loadAllDefaults(self): for dirname, _dirnames, filenames in os.walk(armi.RES): for filename in filenames: if filename.lower().endswith("settings.xml"): - reader = settingsIO.SettingsDefinitionReader(self) - reader.readFromFile(os.path.join(dirname, filename)) + #KILLME + raise RuntimeError("Old settings are deprecated") for fwSetting in fwSettings.getFrameworkSettings(): self.settings[fwSetting.name] = fwSetting diff --git a/armi/settings/fwSettings/__init__.py b/armi/settings/fwSettings/__init__.py index 371c43a6a5..b5ea8d7d76 100644 --- a/armi/settings/fwSettings/__init__.py +++ b/armi/settings/fwSettings/__init__.py @@ -17,7 +17,7 @@ """ from typing import List -from armi.settings import setting2 as setting +from armi.settings import setting from . import globalSettings from . import xsSettings from . import databaseSettings diff --git a/armi/settings/fwSettings/databaseSettings.py b/armi/settings/fwSettings/databaseSettings.py index 0af535bda4..149b703b4c 100644 --- a/armi/settings/fwSettings/databaseSettings.py +++ b/armi/settings/fwSettings/databaseSettings.py @@ -14,7 +14,7 @@ """Settings related to the ARMI database.""" -from armi.settings import setting2 as setting +from armi.settings import setting CONF_DB = "db" diff --git a/armi/settings/fwSettings/globalSettings.py b/armi/settings/fwSettings/globalSettings.py index d07504e442..24a6f46fbd 100644 --- a/armi/settings/fwSettings/globalSettings.py +++ b/armi/settings/fwSettings/globalSettings.py @@ -27,7 +27,7 @@ import voluptuous as vol from armi import context -from armi.settings import setting2 as setting +from armi.settings import setting # Framework settings diff --git a/armi/settings/fwSettings/reportSettings.py b/armi/settings/fwSettings/reportSettings.py index 85e1ca1c00..1fc5e8b292 100644 --- a/armi/settings/fwSettings/reportSettings.py +++ b/armi/settings/fwSettings/reportSettings.py @@ -14,7 +14,7 @@ """Settings related to the report generation.""" -from armi.settings import setting2 as setting +from armi.settings import setting CONF_GEN_REPORTS = "genReports" diff --git a/armi/settings/fwSettings/xsSettings.py b/armi/settings/fwSettings/xsSettings.py index aef13979c3..ded3a1ba17 100644 --- a/armi/settings/fwSettings/xsSettings.py +++ b/armi/settings/fwSettings/xsSettings.py @@ -14,7 +14,7 @@ """Settings related to the cross section.""" -from armi.settings import setting2 as setting +from armi.settings import setting CONF_CLEAR_XS = "clearXS" diff --git a/armi/settings/setting.py b/armi/settings/setting.py index dfdc43c100..3c9915cf69 100644 --- a/armi/settings/setting.py +++ b/armi/settings/setting.py @@ -12,82 +12,159 @@ # See the License for the specific language governing permissions and # limitations under the License. -r""" -This defines a Setting object and its subclasses that populate the Settings object. - -This module is really only needed for its interactions with the submitter GUI. """ -import os +System to handle basic configuration settings. + +Notes +----- +Rather than having subclases for each setting type, we simply derive +the type based on the type of the default, and we enforce it with +schema validation. This also allows for more complex schema validation +for settings that are more complex dictionaries (e.g. XS, rx coeffs, etc.). + +One reason for complexity of the previous settings implementation was +good interoperability with the GUI widgets. + +We originally thought putting settings definitions in XML files would +help with future internationalization. This is not the case. +Internationalization will likely be added later with string interpolators given +the desire to internationalize, which is nicely compatible with this +code-based re-implementation. +""" + import copy -import collections -import warnings +from collections import namedtuple +from typing import List -import armi -from armi.utils import parsing +import voluptuous as vol +from armi import runLog -class Setting(object): - r"""Helper class to Settings - Holds a factory to instantiate the correct sub-type based on the input dictionary with a few expected - keywords. Setting objects hold all associated information of a setting in ARMI and should typically be accessed - through the Settings class methods rather than directly. The exception being the SettingAdapter class designed - for additional GUI related functionality +# 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"]) + +class Setting: """ - __slots__ = ["name", "description", "label", "underlyingType", "_value", "_default"] - _allSlots = {} + A particular setting. - def __init__(self, name, underlyingType, attrib): - r"""Initialization used in all subclass calls, some values are to be overwritten - as they are either uniquely made or optional values. + Setting objects hold all associated information of a setting in ARMI and should + typically be accessed through the Settings class methods rather than directly. The + exception being the SettingAdapter class designed for additional GUI related + functionality. - All set values should be run through the convertType(self.name,) function as Setting is - so closely tied to python types being printed to strings and parsed back, convertType(self.name,) - should cleanly fetch any python values. + 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=None, + label=None, + options=None, + schema=None, + enforcedOptions=False, + subLabels=None, + isEnvironment=False, + ): + """ + Initialize a Setting object. Parameters ---------- name : str the setting's name - underlyingType : type - The setting's type - attrib : dict - the storage bin with the strictly named attributes of the setting - - Attributes - ---------- - self.underlyingType : type - explicity states the type of setting, usually a python type, but potentially something like 'file' - self.value : select allowed python types - the setting value stored - self.default : always the same as value - the backup value of self.value to regress to if desired - self.description : str - the helpful description of the purpose and use of a setting, primarily used for GUI tooltip strings - self.label : str + default : object + The setting's default value + description : str, optional + 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. """ - warnings.warn( - "The old Setting class is being deprecated, and will " - "be replaced with the new implementation presently in the setting2 " - "module." - ) self.name = name - self.description = str(attrib.get("description", "")) - self.label = str(attrib.get("label", self.name)) - - self.underlyingType = underlyingType - self._value = None - self._default = None + self.description = description or name + self.label = label or name + self.options = options + self.enforcedOptions = enforcedOptions + self.subLabels = subLabels + self.isEnvironment = isEnvironment + + self._default = default + # Retain the passed schema so that we don't accidentally stomp on it in + # addOptions(), et.al. + self._customSchema = schema + self._setSchema(schema) + self._value = copy.deepcopy(default) # break link from _default - self.setValue(attrib["default"]) - self._default = copy.deepcopy(self._value) + @property + def underlyingType(self): + """Useful in categorizing settings, e.g. for GUI.""" + return type(self._default) @property - def schema(self): - return lambda val: val + 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, schema): + """Apply or auto-derive schema of the value.""" + if schema: + self.schema = schema + elif self.options and self.enforcedOptions: + self.schema = vol.Schema(vol.In(self.options)) + else: + # Coercion is needed to convert XML-read migrations (for old cases) + # as well as 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): @@ -98,431 +175,132 @@ def value(self): return self._value @value.setter - def value(self, v): - self.setValue(v) - - @classmethod - def _getSlots(cls): - r"""This method is caches the slots for all subclasses so that they can quickly be retrieved during __getstate__ - and __setstate__.""" - slots = cls._allSlots.get(cls, None) - if slots is None: - slots = [ - slot - for klass in cls.__mro__ - for slot in getattr(klass, "__slots__", []) - ] - cls._allSlots[cls] = slots - return slots - - def __getstate__(self): - """Get the state of the setting; required when __slots__ are defined""" - return [ - getattr(self, slot) for slot in self.__class__._getSlots() - ] # pylint: disable=protected-access - - def __setstate__(self, state): - """Set the state of the setting; required when __slots__ are defined""" - for slot, value in zip( - self.__class__._getSlots(), state - ): # pylint: disable=protected-access - setattr(self, slot, value) - - def setValue(self, v): - raise NotImplementedError - - def __repr__(self): - return "<{} {} value:{} default:{}>".format( - self.__class__.__name__, self.name, self.value, self.default - ) - - def revertToDefault(self): + def value(self, val): """ - Revert a setting back to its default. + Set the value directly. Notes ----- - Skips the property setter because default val - should already be validated. - """ - self._value = copy.deepcopy(self.default) - - def isDefault(self): + 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`` """ - Returns a boolean based on whether or not the setting equals its default value + return self.setValue(val) - 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. + def setValue(self, val): """ - 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() - - def getDefaultAttributes(self): - """Returns values associated with the default initialization write out of settings + Set value of a setting. - Excludes the stored name of the 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. """ - return collections.OrderedDict( - [ - ("type", self.underlyingType), - ("default", self.default), - ("description", self.description), - ("label", self.label), - ] + try: + val = self.schema(val) + except vol.error.MultipleInvalid: + runLog.error(f"Error in setting {self.name}, val: {val}.") + raise + + self._value = self._load(val) + + 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(self._customSchema) + + def addOption(self, option: Option): + """Extend this Setting's options with an extra option.""" + self.addOptions( + [option,] ) - def getCustomAttributes(self): - """Returns values associated with a more terse write out of settings + def changeDefault(self, newDefault: Default): + """Change the default of a setting, and also the current value.""" + self._default = newDefault.value + self.value = newDefault.value + def _load(self, inputVal): """ - return {"value": self.value} + Create setting value from input value. - @staticmethod - def factory(key, attrib): + In some custom settings, this can return a custom object + rather than just the input value. """ - The initialization method for the subclasses of Setting. - """ - try: - return SUBSETTING_MAP[attrib["type"]](key, attrib) - except KeyError: - raise TypeError( - "Cannot create a setting for {0} around {1} " - "as no subsetting exists to manage its declared type.".format( - key, attrib - ) - ) - - -class BoolSetting(Setting): - """Setting surrounding a python boolean - - No new attributes have been added - - """ - - __slots__ = [] - - def __init__(self, name, attrib): - Setting.__init__(self, name, bool, attrib) - - def setValue(self, v): - """Protection against setting the value to an invalid/unexpected type + return inputVal + def dump(self): """ - try: - tenative_value = parsing.parseValue(v, bool, True) - except ValueError: - raise ValueError( - "Cannot set {0} value to {1} as it is not a valid value for {2} " - "and cannot be converted to one".format( - self.name, v, self.__class__.__name__ - ) - ) - - self._value = tenative_value - - -class _NumericSetting(Setting): - """Between Setting and the numeric subclasses, used for ints and floats - - Attributes - ---------- - self.units : str - OPTIONAL - a descriptor of the setting, not used internally - self.min : int or float, as specified by the subclass initializing this - OPTIONAL - used to enforce values higher than itself - self.max : int or float, as specified by the sublcass initializing this - OPTIONAL - used to enforce values lower than itself + Return a serializable version of this setting's value. - """ - - __slots__ = ["units", "min", "max"] + Override to define custom deserializers for custom/compund settings. + """ + return self._value - def __init__(self, name, underlyingType, attrib): - self.units = str(attrib.get("units", "")) - self.min = parsing.parseValue( - attrib.get("min", None), underlyingType, True, False - ) - self.max = parsing.parseValue( - attrib.get("max", None), underlyingType, True, False + def __repr__(self): + return "<{} {} value:{} default:{}>".format( + self.__class__.__name__, self.name, self.value, self.default ) - Setting.__init__(self, name, underlyingType, attrib) - - def getDefaultAttributes(self): - """Adds in the new attributes to the default attribute grab of the base + def __getstate__(self): """ - attrib = Setting.getDefaultAttributes(self) - attrib["units"] = self.units - attrib["min"] = self.min - attrib["max"] = self.max - return attrib - - def setValue(self, v): - """Protection against setting the value to an invalid/unexpected type + Remove schema during pickling because it is often unpickleable. + Notes + ----- + Errors are often with + ``AttributeError: Can't pickle local object '_compile_scalar..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). """ - try: - tenative_value = parsing.parseValue(v, self.underlyingType, True) - except ValueError: - raise ValueError( - "Cannot set {0} value to {1} as it is not a valid value for {2} " - "and cannot be converted to one".format( - self.name, v, self.__class__.__name__ - ) - ) - - if self.min and tenative_value < self.min: - raise ValueError( - "Cannot set {0} value to {1} as it does not exceed the set minimum of {2}".format( - self.name, tenative_value, self.min - ) - ) - elif self.max and tenative_value > self.max: - raise ValueError( - "Cannot set {0} value to {1} as it exceeds the set maximum of {2}".format( - self.name, tenative_value, self.max - ) - ) - - self._value = tenative_value - - -class IntSetting(_NumericSetting): - """Setting surrounding a python integer""" - - __slots__ = [] - - def __init__(self, name, attrib): - _NumericSetting.__init__(self, name, int, attrib) - - -class FloatSetting(_NumericSetting): - """Setting surrounding a python float""" - - __slots__ = [] - - def __init__(self, name, attrib): - _NumericSetting.__init__(self, name, float, attrib) - - -class StrSetting(Setting): - """Setting surrounding a python string - - Attributes - ---------- - self.options : list - OPTIONAL - a list of strings that self.value is allowed to be set as - self.enforcedOptions : bool - OPTIONAL - toggles whether or not we care about self.options - - """ - - __slots__ = ["options", "enforcedOptions"] - - def __init__(self, name, attrib): - self.options = [ - item for item in parsing.parseValue(attrib.get("options", None), list, True) - ] - self.enforcedOptions = parsing.parseValue( - attrib.get("enforcedOptions", None), bool, True - ) - if self.enforcedOptions and not self.options: - raise AttributeError( - "Cannot use enforcedOptions in ({}) {} without supplying options.".format( - self.__class__.__name__, self.name - ) - ) - Setting.__init__(self, name, str, attrib) - - def setValue(self, v): - """Protection against setting the value to an invalid/unexpected type + state = copy.deepcopy(self.__dict__) + for trouble in ("schema", "_customSchema"): + if trouble in state: + del state[trouble] + return state + def revertToDefault(self): """ - if ( - v is None - ): # done for consistency with the rest of the methods using parsing module - tenative_value = None - else: - tenative_value = str(v) - - if self.options and self.enforcedOptions and tenative_value not in self.options: - raise ValueError( - "Cannot set {0} value to {1} as it isn't in the allowed options " - "{2}".format(self.name, tenative_value, self.options) - ) - - self._value = tenative_value - - def getDefaultAttributes(self): - """Adds in the new attributes to the default attribute grab of the base + Revert a setting back to its default. + Notes + ----- + Skips the property setter because default val + should already be validated. """ - attrib = Setting.getDefaultAttributes(self) - attrib["options"] = self.options - attrib["enforcedOptions"] = self.enforcedOptions - return attrib - - -class PathSetting(StrSetting): - """Setting surrounding a python string file path - - Allows for paths relative to various dynamic ARMI environment variables""" - - __slots__ = ["relativeTo", "mustExist"] - - _REMAPS = { - "RES": armi.context.RES, - "ROOT": armi.context.ROOT, - "DOC": armi.context.DOC, - "FAST_PATH": armi.context.FAST_PATH, - } - - def __init__(self, name, attrib): - self.relativeTo = attrib.get("relativeTo", None) - self.mustExist = parsing.parseValue(attrib.get("mustExist", None), bool, True) - StrSetting.__init__(self, name, attrib) - - def setValue(self, v): - """Protection against setting the value to an invalid/unexpected type + self._value = copy.deepcopy(self.default) + def isDefault(self): """ - if v is not None: - if self.relativeTo is not None: - # Use relative path if the provided path does not exist - if not os.path.exists(v): - v = os.path.join(self._REMAPS[self.relativeTo], v) - if self.mustExist and not os.path.exists(v): - raise ValueError( - "Cannot set {0} value to {1} as it doesn't exist".format( - self.name, v - ) - ) - StrSetting.setValue(self, v) - - def getDefaultAttributes(self): - """Adds in the new attributes to the default attribute grab of the base + 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. """ - attrib = Setting.getDefaultAttributes(self) - attrib["relativeTo"] = self.relativeTo - attrib["mustExist"] = self.mustExist - return attrib - - -class ListSetting(Setting): - """Setting surrounding a python list - - Attributes - ---------- - self.containedType : any python type - OPTIONAL - used to ensure all items in the list conform to this specified type, - if omitted the list is free to hold anything - - """ - - __slots__ = ["containedType", "options", "enforcedOptions"] - - def __init__(self, name, attrib): - self.containedType = parsing.parseType(attrib.get("containedType", None), True) - self.options = [ - item for item in parsing.parseValue(attrib.get("options", None), list, True) - ] - self.enforcedOptions = parsing.parseValue( - attrib.get("enforcedOptions", None), bool, True - ) - - if self.enforcedOptions and not self.options: - raise AttributeError( - "Cannot use enforcedOptions in ({}) {} without supplying options.".format( - self.__class__.__name__, self.name - ) - ) - - if self.containedType and self.containedType == type(None): - raise RuntimeError( - "Do not use NoneType for containedType in ListSetting. " - "That does seem helpful and it will cause pickling issues." - ) - Setting.__init__(self, name, list, attrib) - self._default = tuple( - self.default or [] - ) # convert mutable list to tuple so no one changes it after def. - - @property - def value(self): - return list(self._value) + return self.value == self.default @property - def default(self): - return list(self._default or []) - - def setValue(self, v): - """Protection against setting the value to an invalid/unexpected type + def offDefault(self): + """Return True if the setting is not the default value for that setting.""" + return not self.isDefault() - """ - try: - tentative_value = parsing.parseValue(v, list, True) - except ValueError: - raise ValueError( - "Cannot set {0} value to {1} as it is not a valid value for {2} " - "and cannot be converted to one".format( - self.name, v, self.__class__.__name__ - ) - ) - - if self.containedType and tentative_value: - ct = self.containedType - try: - if ct == str: - tentative_value = [str(i) for i in tentative_value] - else: - tentative_value = [ - parsing.parseValue(i, ct, False) for i in tentative_value - ] - except ValueError: - raise ValueError( - "Cannot set {0} value to {1} as it contains items not of the correct type {2}".format( - self.name, tentative_value, self.containedType - ) - ) - - if ( - self.options - and self.enforcedOptions - and any([value not in self.options for value in tentative_value]) - ): - raise ValueError( - "Cannot set {0} value to {1} as it isn't in the allowed options {2}".format( - self.name, tentative_value, self.options - ) - ) - - self._value = tuple(tentative_value or []) + def getCustomAttributes(self): + """Hack to work with settings writing system until old one is gone.""" + return {"value": self.value} def getDefaultAttributes(self): - """Adds in the new attributes to the default attribute grab of the base - """ - attrib = Setting.getDefaultAttributes(self) - attrib["containedType"] = self.containedType - attrib["options"] = self.options - attrib["enforcedOptions"] = self.enforcedOptions - return attrib - - -# help direct python types to the respective setting type -SUBSETTING_MAP = { - "bool": BoolSetting, - "int": IntSetting, - "long": IntSetting, - "float": FloatSetting, - "str": StrSetting, - "list": ListSetting, - "path": PathSetting, -} + 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, + } diff --git a/armi/settings/setting2.py b/armi/settings/setting2.py deleted file mode 100644 index c57d8d38bf..0000000000 --- a/armi/settings/setting2.py +++ /dev/null @@ -1,310 +0,0 @@ -# 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 ------ -This is a re-implementation of setting.py that requires setting objects -be instantiated in code via plugin hooks, or by the framework, rather -than by XML files. - -Rather than having subclases for each setting type, we simply derive -the type based on the type of the default, and we enforce it with -schema validation. This also allows for more complex schema validation -for settings that are more complex dictionaries (e.g. XS, rx coeffs, etc.). - -One reason for complexity of the previous settings implementation was -good interoperability with the GUI widgets. - -We originally thought putting settings definitions in XML files would -help with future internationalization. This is not the case. -Internationalization will likely be added later with string interpolators given -the desire to internationalize, which is nicely compatible with this -code-based re-implementation. -""" - -import copy -from collections import namedtuple -from typing import List - -import voluptuous as vol - -from armi import runLog - - -# 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"]) - - -class Setting: - """ - A particular setting. - - Setting objects hold all associated information of a setting in ARMI and should - typically be accessed through the Settings class methods rather than directly. The - exception being the SettingAdapter class designed for additional GUI related - functionality. - - 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=None, - label=None, - options=None, - schema=None, - enforcedOptions=False, - subLabels=None, - isEnvironment=False, - ): - """ - Initialize a Setting object. - - Parameters - ---------- - name : str - the setting's name - default : object - The setting's default value - description : str, optional - 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. - - """ - 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._default = default - # Retain the passed schema so that we don't accidentally stomp on it in - # addOptions(), et.al. - self._customSchema = schema - self._setSchema(schema) - self._value = copy.deepcopy(default) # break link from _default - - @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, schema): - """Apply or auto-derive schema of the value.""" - if schema: - self.schema = schema - elif self.options and self.enforcedOptions: - self.schema = vol.Schema(vol.In(self.options)) - else: - # Coercion is needed to convert XML-read migrations (for old cases) - # as well as 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) - - 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.MultipleInvalid: - runLog.error(f"Error in setting {self.name}, val: {val}.") - raise - - self._value = self._load(val) - - 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(self._customSchema) - - def addOption(self, option: Option): - """Extend this Setting's options with an extra option.""" - self.addOptions( - [option,] - ) - - def changeDefault(self, newDefault: Default): - """Change the default of a setting, and also the current value.""" - self._default = newDefault.value - self.value = newDefault.value - - def _load(self, 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 - - 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..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 - - 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) - - 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() - - def getCustomAttributes(self): - """Hack to work with settings writing system until old one is gone.""" - return {"value": self.value} - - 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, - } diff --git a/armi/settings/settingsIO.py b/armi/settings/settingsIO.py index 6deff9b1b0..21b6113d08 100644 --- a/armi/settings/settingsIO.py +++ b/armi/settings/settingsIO.py @@ -32,7 +32,6 @@ from armi.physics.thermalHydraulics import const from armi.localization import exceptions from armi.settings import setting -from armi.settings import setting2 from armi.settings import settingsRules from armi.reactor import geometry @@ -56,7 +55,6 @@ class _SettingsReader(object): See Also -------- SettingsReader - SettingsDefinitionReader """ class SettingsInputFormat(enum.Enum): @@ -310,60 +308,6 @@ def applyTypeConversions(settingObj, value): return value -class SettingsDefinitionReader(_SettingsReader): - """A specialized _SettingsReader which creates new setting instances.""" - - def __init__(self, cs): - _SettingsReader.__init__(self, cs, Roots.DEFINITION) - self._occupied_names = set(cs.settings.keys()) - - def _interpretSetting(self, settingElement): - settingName = settingElement.tag - attributes = settingElement.attrib - - if "type" not in attributes or "default" not in attributes: - raise exceptions.InvalidSettingDefinition(settingName, attributes) - - default = attributes["default"] # always a string at this point - settingValues = self.applyConversions(settingName, default) - # check for a new error conditions - for correctedName, correctedDefault in settingValues.items(): - if correctedName != settingName: - raise exceptions.SettingException( - "Settings definition file {} contained setting named {},\n" - "but it was changed to {}.".format( - self.inputPath, settingName, correctedName - ) - ) - if correctedDefault != default: - # problem here when default is like, an string empty list '[]' - # and it gets corrected to a real list. So hack: - if default != "[]": - raise exceptions.SettingException( - "Settings definition file {} contained setting named {}, " - "but the value was changed from {} to {}. Change default " - "something that does not get auto-corrected.".format( - self.inputPath, - settingName, - repr(default), - repr(correctedDefault), - ) - ) - if settingName.lower() in self._occupied_names: - raise exceptions.SettingNameCollision( - 'Duplicate definition for the setting "{}" found in {}.'.format( - settingName, self.inputPath - ) - ) - - # everything is good, time to create an actual setting object - self.cs.settings[settingName] = setting.Setting.factory(settingName, attributes) - self._occupied_names.add(settingName.lower()) - - def _resolveProblems(self): - self._checkInvalidSettings() - - class SettingsWriter(object): """Writes settings out to files. @@ -406,9 +350,6 @@ def writeXml(self, stream): tree = ET.ElementTree(root) for settingObj, settingDatum in settingData.items(): - if isinstance(settingObj, setting2.Setting): - # do not write new-style settings to old-style XML. It fails on read. - continue settingNode = ET.SubElement(root, settingObj.name) for attribName, attribValue in settingDatum.items(): settingNode.set(attribName, str(attribValue)) @@ -441,9 +382,7 @@ def _preprocessYaml(self, settingData): for settingObj, settingDatum in settingData.items(): if "value" in settingDatum and len(settingDatum) == 1: # ok to flatten - val = settingDatum["value"] - if isinstance(settingObj, setting2.Setting): - val = settingObj.dump() + val = settingObj.dump() cleanedData[settingObj.name] = val else: cleanedData[settingObj.name] = settingDatum diff --git a/armi/settings/tests/test_settings.py b/armi/settings/tests/test_settings.py deleted file mode 100644 index 1c5204fe76..0000000000 --- a/armi/settings/tests/test_settings.py +++ /dev/null @@ -1,803 +0,0 @@ -# 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. - -import pickle -import os -import unittest - -import six - -import armi -from armi import settings -from armi.cli import entryPoint -from armi.localization import exceptions -from armi.settings import setting -from armi.settings import settingsIO -from armi.settings import settingsRules -from armi.tests import TEST_ROOT, ARMI_RUN_PATH - - -class MockEntryPoint(entryPoint.EntryPoint): - name = "dummy" - - -class TestSettings(unittest.TestCase): - """Test the functionality of Settings.py""" - - @classmethod - def setUpClass(cls): - cls.tS = settings.Settings() - - def setUp(self): - self.tS.settings["fruitBasket"] = setting.ListSetting( - "fruitBasket", {"type": "list", "default": ["apple", "banana", "orange"]} - ) - - self.resetPoint = self.tS.keys() - - def tearDown(self): - for i in self.tS.keys(): - if i not in self.resetPoint: - if i in self.tS.settings: - del self.tS.settings[i] - - def test_canPickleCS(self): - self.tS["xsKernel"] = "MC2v3" - result = pickle.loads(pickle.dumps(self.tS)) - self.assertEqual("MC2v3", result["xsKernel"]) - - def test_userInputXML(self): - """Asserts that the state of the settings object is altered by loading an input of XML as a - means of user specifications. - - :ref:`REQbdb2e558-70b5-4ddb-aff9-d7a7b6d0e360` - """ - mock = settings.Settings() - mock.settings["newSetting"] = setting.FloatSetting( - "newSetting", - { - "description": "Hola", - "label": "hello", - "type": "float", - "default": "2.0", - }, - ) - # make sure everything is as expected - self.assertEqual(2.0, mock["newSetting"]) - - # read "user input xml" - xml = six.StringIO('') - reader = settingsIO.SettingsReader(mock) - reader.readFromStream(xml, fmt=reader.SettingsInputFormat.XML) - self.assertEqual(17.0, mock["newSetting"]) - - def test_setSetting(self): - self.tS["fruitBasket"] = ["apple", "banana", "orange"] - self.assertEqual(self.tS["fruitBasket"], ["apple", "banana", "orange"]) - - def test_getSetting(self): - """Test the retrieval of existing and nonexisting settings - - """ - if "nonExistentSetting" in self.tS.settings: - del self.tS["nonExistentSetting"] - with self.assertRaises(exceptions.NonexistentSetting): - self.tS["nonExistentSetting"] - - self.tS.settings["existingSetting"] = setting.BoolSetting( - "existingSetting", {"type": "bool", "default": "False"} - ) - - self.tS["existingSetting"] = True - self.assertIsNotNone(self.tS["existingSetting"]) - - def checkSettingsMatch(self, set1, set2, defaults=False): - """Asserts that all settings existing in two separate instances match""" - for key in set1.settings.keys(): - wSetting = set1.settings[key] - rSetting = set2.settings[key] - if defaults: - self.assertEqual( - wSetting.getDefaultAttributes(), rSetting.getDefaultAttributes() - ) - else: - self.assertEqual( - wSetting.getCustomAttributes(), rSetting.getCustomAttributes() - ) - - @unittest.skip( - "settings are no longer pickleable, but MPI does not seem to use pickling..." - ) - def test_pickling(self): - """Tests the settings can be pickled - - """ - pickled_string = pickle.dumps(self.tS) - loaded_pickle = pickle.loads(pickled_string) - - self.checkSettingsMatch(self.tS, loaded_pickle, defaults=True) - - def test_duplicate(self): - """Tests the duplication function - - """ - origSettings = settings.Settings() - dupSettings = origSettings.duplicate() - self.checkSettingsMatch(origSettings, dupSettings, defaults=True) - - def test_validDefault(self): - """Tests the settings for a default value on each setting - - :ref:`REQ7adc1f94-a423-46ca-9aff-e2276d07faa5` - """ - cs = settings.Settings() - for key in cs.settings.keys(): - cs[key] # pylint: disable=pointless-statement - - def test_commandLineSetting(self): - ep = MockEntryPoint() - cs = ep.cs - - someSetting = setting.FloatSetting( - "someSetting", {"type": "float", "default": "47.0"} - ) - cs.settings[someSetting.name] = someSetting - self.assertEqual(47.0, cs["someSetting"]) - - ep.createOptionFromSetting(someSetting.name) - ep.parse_args(["--someSetting", "92"]) - self.assertEqual(92.0, cs["someSetting"]) - self.assertEqual(92.0, someSetting.value) - - def test_cannotLoadSettingsAfterParsingCommandLineSetting(self): - self.test_commandLineSetting() - cs = settings.getMasterCs() - with self.assertRaises(exceptions.StateError): - cs.loadFromInputFile("somefile.xml") - - def test_loadFromXMLFileUpdatesCaseTitle(self): - cs = settings.Settings(fName=ARMI_RUN_PATH) - self.assertEqual(cs.caseTitle, "armiRun") - - def test_temporarilySet(self): - self.tS.temporarilySet("eigenProb", False) - self.assertEqual(self.tS["eigenProb"], False) - self.tS.unsetTemporarySettings() - self.assertEqual(self.tS["eigenProb"], True) - - -class TestSetting(unittest.TestCase): - """Test the functionality of Setting.py - - """ - - def test_settingGeneric(self): - """Individual setting test - - Should probe the creation of a setting and retention of the default attributes - - """ - key = "generic" - attrib = { - "type": "int", - "default": "0", - "description": "testbanana", - "label": "banana", - "ignored": "bananaNanana", - } # this attribute should do absolutely nothing - - # s = setting.Setting(attrib) - s = setting.Setting.factory(key, attrib) - - self.assertIsInstance(s, setting.IntSetting) - - self.assertEqual(s.underlyingType, int) - self.assertEqual(s.value, 0) - self.assertEqual(s.default, 0) - self.assertEqual(s.description, "testbanana") - self.assertEqual(s.label, "banana") - - def test_settingBool(self): - """Probes the creation, value change on a valid or invalid input and default value retention - on Boolean type settings - - :ref:`REQ798b2869-ab59-4164-911c-0a26b3f9b037` - """ - attribValues = {"type": "bool", "default": True} - attribStrings = {"type": "bool", "default": "True"} - sStrings = setting.BoolSetting("bugtester", attribStrings) - sValues = setting.BoolSetting("bugtester", attribValues) - - self.assertEqual( - True, sStrings.value, "The setting object does not have the correct value" - ) - self.assertEqual( - True, sValues.value, "The setting object does not have the correct value" - ) - - sStrings.setValue("False") - sValues.setValue(False) - self.assertEqual( - False, - sStrings.value, - "The setting object does not have the correct value after setting", - ) - self.assertEqual( - False, - sValues.value, - "The setting object does not have the correct value after setting", - ) - - self.assertEqual( - True, - sStrings.default, - "The setting object does not have the correct default value", - ) - - sValues.setValue(None) - self.assertEqual(sValues.value, False) - - # --------- malicious input ---------- - attrib = {"type": "bool", "default": "blueberry"} - - with self.assertRaises(ValueError): - setting.BoolSetting("bugMaker", attrib) - - def test_settingInt(self): - """Probes the creation, value change on a valid or invalid input and default value retention - on Integer type settings - - :ref:`REQ798b2869-ab59-4164-911c-0a26b3f9b037` - """ - attribStrings = {"type": "int", "default": "5"} - attribValues = {"type": "int", "default": 5} - sStrings = setting.IntSetting("bugtester", attribStrings) - sValues = setting.IntSetting("bugtester", attribValues) - - self.assertEqual( - 5, sStrings.value, "The setting object does not have the correct value" - ) - self.assertEqual( - 5, sValues.value, "The setting object does not have the correct value" - ) - - sStrings.setValue("10") - sValues.setValue(10) - self.assertEqual( - 10, - sStrings.value, - "The setting object does not have the correct value after setting", - ) - self.assertEqual( - 10, - sValues.value, - "The setting object does not have the correct value after setting", - ) - - sValues.setValue(1000000000000) # that has to be a long, right? - self.assertEqual( - 1000000000000, - sValues.value, - "The setting object does not have the correct long value", - ) - - self.assertEqual( - 5, - sStrings.default, - "The setting object does not have the correct default value", - ) - - sValues.setValue(None) - self.assertEqual(sValues.value, 0) - - # --------- malicious input ---------- - attrib = {"type": "int", "default": "blueberry", "max": 5, "min": 1.0} - - with self.assertRaises(ValueError): - setting.IntSetting("bugMaker", attrib) - - # should be out of range - attrib["value"] = "6" - with self.assertRaises(ValueError): - setting.IntSetting("bugMaker", attrib) - attrib["value"] = 0.5 - with self.assertRaises(ValueError): - setting.IntSetting("bugMaker", attrib) - attrib["value"] = 0 - with self.assertRaises(ValueError): - setting.IntSetting("bugMaker", attrib) - attrib["default"] = 3 - setting.IntSetting("bugMaker", attrib) - - def test_settingFloat(self): - """Probes the creation, value change on a valid or invalid input and default value retention - on Float type settings - - :ref:`REQ798b2869-ab59-4164-911c-0a26b3f9b037` - """ - attribStrings = {"type": "float", "default": "5.0"} - attribValues = {"type": "float", "default": 5.0} - - sStrings = setting.FloatSetting("bugtester", attribStrings) - sValues = setting.FloatSetting("bugtester", attribValues) - self.assertEqual( - 5.0, sStrings.value, "The setting object does not have the correct value" - ) - self.assertEqual( - 5.0, sValues.value, "The setting object does not have the correct value" - ) - - sStrings.setValue("10.0") - sValues.setValue(10.0) - self.assertEqual( - 10.0, - sStrings.value, - "The setting object does not have the correct value after setting", - ) - self.assertEqual( - 10.0, - sValues.value, - "The setting object does not have the correct value after setting", - ) - - self.assertEqual( - 5.0, - sStrings.default, - "The setting object does not have the correct default value", - ) - - sValues.setValue(None) - self.assertEqual(sValues.value, 0.0) - - # --------- malicious input ---------- - attrib = {"type": "float", "default": "blueberry", "max": 5, "min": 1.0} - - attrib["default"] = "6" - with self.assertRaises(ValueError): - setting.FloatSetting("bugMaker", attrib) - attrib["default"] = 0.5 - with self.assertRaises(ValueError): - setting.FloatSetting("bugMaker", attrib) - attrib["default"] = 0 - with self.assertRaises(ValueError): - setting.FloatSetting("bugMaker", attrib) - attrib["default"] = 3 - setting.FloatSetting("bugMaker", attrib) - - def test_settingStr(self): - """Probes the creation, value change on a valid or invalid input and default value retention - on String type settings - - :ref:`REQ798b2869-ab59-4164-911c-0a26b3f9b037` - """ - attribStrings = { - "type": "str", - "default": "banana", - "options": ["coconut", "apple", "banana"], - "enforcedOptions": True, - } - attribValues = { - "type": "str", - "default": "5", - "options": ["3", "20"], - "enforcedOptions": False, - } - - sStrings = setting.StrSetting("bugtester", attribStrings) - sValues = setting.StrSetting("bugtester", attribValues) - self.assertEqual( - "banana", - sStrings.value, - "The setting object does not have the correct value", - ) - self.assertEqual( - "5", sValues.value, "The setting object does not have the correct value" - ) - - sStrings.setValue("apple") - sValues.setValue("10") - self.assertEqual( - "apple", - sStrings.value, - "The setting object does not have the correct value after setting", - ) - self.assertEqual( - "10", - sValues.value, - "The setting object does not have the correct value after setting", - ) - - self.assertEqual( - "banana", - sStrings.default, - "The setting object does not have the correct default value", - ) - - sValues.setValue(None) - self.assertIsNone(sValues.value) - - # --------- malicious input ---------- - attrib = { - "type": "str", - "default": "blueberry", - "options": "['coconut', 'banana', 'pineapple']", - "enforcedOptions": "True", - } - - with self.assertRaises(ValueError): - setting.StrSetting("bugMaker", attrib) - - def test_settingPath(self): - """Probes the creation, value change on a valid or invalid input and default value retention - on Path type settings - """ - attribStrings = {"type": "path", "default": "banana.txt", "relativeTo": "RES"} - - sStrings = setting.PathSetting("bugtester", attribStrings) - self.assertEqual( - os.path.join(armi.RES, "banana.txt"), - sStrings.value, - "The setting object does not have the correct value", - ) - - sStrings.setValue("apple.jpg") - self.assertEqual( - os.path.join(armi.RES, "apple.jpg"), - sStrings.value, - "The setting object does not have the correct value", - ) - - sStrings.setValue(None) - self.assertIsNone(sStrings.value) - - # --------- malicious input ---------- - attrib = {"type": "path", "default": "blueberry", "mustExist": "True"} - - with self.assertRaises(ValueError): - setting.PathSetting("bugMaker", attrib) - - def test_relativeToSettingPath(self): - """Test the relative to functionality with and without a valid path""" - fileName = "banana.txt" - remoteFileLocation = os.path.join(TEST_ROOT, fileName) - relativeAttribStrings = { - "type": "path", - "default": fileName, - "relativeTo": "RES", - } - directAttribStrings = { - "type": "path", - "default": remoteFileLocation, - "relativeTo": "RES", - } - sStrings = setting.PathSetting("relativePathTest", relativeAttribStrings) - self.assertEqual(sStrings.value, os.path.join(armi.RES, fileName)) - with open(remoteFileLocation, "w") as f: - f.write( - "Temporary file to test that when a valid path is provided the setting will be directed to " - "the input path rather than using the default relativeTo path." - ) - sStrings = setting.PathSetting("directPathTest", directAttribStrings) - self.assertEqual(sStrings.value, remoteFileLocation) - os.remove(remoteFileLocation) - - def test_settingList(self): - """Probes the creation, value change on a valid or invalid input and default value retention - on List type settings - - :ref:`REQ798b2869-ab59-4164-911c-0a26b3f9b037` - """ - attribStrings = {"type": "list", "default": "['5','6','4','5','7']"} - attribValues = { - "type": "list", - "default": "['5','6',4,5,7]", - "containedType": "int", - } - - sStrings = setting.ListSetting("bugtester", attribStrings) - sValues = setting.ListSetting("bugtester", attribValues) - self.assertEqual( - ["5", "6", "4", "5", "7"], - sStrings.value, - "The setting object does not have the correct value", - ) - self.assertEqual( - [5, 6, 4, 5, 7], - sValues.value, - "The setting object does not have the correct value", - ) - - sStrings.setValue("['10']") - sValues.setValue([10]) - self.assertEqual( - ["10"], - sStrings.value, - "The setting object does not have the correct value after setting", - ) - self.assertEqual( - [10], - sValues.value, - "The setting object does not have the correct value after setting", - ) - - attribNew = {"type": "list", "default": "['5','6','4','5','7']"} - sNew = setting.ListSetting("bugtester", attribNew) - sNew.setValue("['custom,14,14']") - self.assertEqual(["custom,14,14"], sNew.value, "Parsing issues") - - self.assertEqual( - ["5", "6", "4", "5", "7"], - sStrings.default, - "The setting object does not have the correct default value", - ) - - sValues.setValue([int(v) for v in ["5", "6", "7", "8", "9"]]) - self.assertEqual([5, 6, 7, 8, 9], sValues.value) - - attribNew = {"type": "list", "default": "['5','6','4','5','7']"} - sNew = setting.ListSetting("bugMaker", attribNew) - sNew.setValue("['custom,14,14']") - self.assertIsInstance(sNew, setting.ListSetting) - self.assertEqual(["custom,14,14"], sNew.value, "Parsing issues") - - sValues.setValue(None) - self.assertEqual(sValues.value, []) - - with self.assertRaises(ValueError): - sValues.setValue("0.2") - - # --------- malicious input ---------- - attrib = { - "type": "list", - "default": "['5','6','banana','5','7']", - "containedType": "float", - } - - with self.assertRaises(ValueError): - setting.ListSetting("bugMaker", attrib) - - attrib["value"] = ["5", 5, True, False, {}] - del attrib["containedType"] - setting.ListSetting("bugMaker", attrib) - - -class SettingsFailureTests(unittest.TestCase): - def test_settingsObjSetting(self): - sets = settings.Settings() - with self.assertRaises(exceptions.NonexistentSetting): - sets[ - "idontexist" - ] = "this test should fail because no setting named idontexist should exist." - - def test_malformedCreation(self): - """Setting creation test - - Tests that a few unsupported types properly fail to create - - """ - s = settings.Settings() - - key = "bugMaker" - attrib = {"type": "tuple", "default": 5.0, "description": "d", "label": "l"} - - with self.assertRaises(TypeError): - s.settings[key] = setting.Setting.factory(key, attrib) - attrib["type"] = tuple - with self.assertRaises(TypeError): - s.settings[key] = setting.Setting.factory(key, attrib) - - attrib["type"] = "dict" - with self.assertRaises(TypeError): - s.settings[key] = setting.Setting.factory(key, attrib) - attrib["type"] = dict - with self.assertRaises(TypeError): - s.settings[key] = setting.Setting.factory(key, attrib) - - def test_loadFromXmlFailsOnBadNames(self): - ss = settings.Settings() - with self.assertRaises(TypeError): - ss.loadFromInputFile(None) - with self.assertRaises(IOError): - ss.loadFromInputFile("this-settings-file-does-not-exist.xml") - - -class SettingsReaderTests(unittest.TestCase): - def setUp(self): - self.init_mode = armi.CURRENT_MODE - - def tearDown(self): - armi.Mode.setMode(self.init_mode) - - def test_conversions(self): - """Tests that settings convert based on a set of rules before being created - - :ref:`REQfbefba64-3de7-4aea-b155-102c7b375722` - """ - mock = settings.Settings() - mock.settings["newSetting"] = setting.FloatSetting( - "newSetting", - { - "description": "Hola", - "label": "hello", - "type": "float", - "default": "2.0", - }, - ) - # make sure everything is as expected - self.assertEqual(2.0, mock["newSetting"]) - - # read some settings, and see that everything makes sense - xml = six.StringIO('') - reader = settingsIO.SettingsReader(mock) - - # add a rename - settingsRules.RENAMES["deprecated"] = "newSetting" - - reader.readFromStream(xml, fmt=reader.SettingsInputFormat.XML) - self.assertEqual(17.0, mock["newSetting"]) - del settingsRules.RENAMES["deprecated"] - - # read settings - xml2 = six.StringIO('') - reader2 = settingsIO.SettingsReader(mock) - reader2.readFromStream(xml2, fmt=reader.SettingsInputFormat.XML) - self.assertEqual(92.0, mock["newSetting"]) - - def test_enforcements(self): - mock = settings.Settings() - - xml = six.StringIO( - '' - ) - reader = settingsIO.SettingsDefinitionReader(mock) - - # put 'okaySetting' into the mock settings object - reader.readFromStream(xml, fmt=reader.SettingsInputFormat.XML) - - self.assertEqual(mock["okaySetting"], 17) - - # we'll allow ARMI to run while ignoring old settings, but will issue warnings. - xml = six.StringIO('') - reader = settingsIO.SettingsReader(mock) - reader.readFromStream(xml, fmt=reader.SettingsInputFormat.XML) - with self.assertRaises(exceptions.NonexistentSetting): - mock["OOGLYBOOGLY"] - - settingsRules.RENAMES["OOGLYBOOGLY"] = "okaySetting" - xml = six.StringIO('') - reader = settingsIO.SettingsReader(mock) - reader.readFromStream(xml, fmt=reader.SettingsInputFormat.XML) - - self.assertEqual(mock["okaySetting"], 18) - - def test_noSharedName(self): - """Tests that settings can't have the same name - - :ref:`REQ78f4a816-4dff-4525-82d9-7e0620943eaa` - """ - mock = settings.Settings() - - xml = six.StringIO( - '' - '' - ) - with self.assertRaises(exceptions.SettingNameCollision): - reader = settingsIO.SettingsDefinitionReader(mock) - reader.readFromStream(xml, fmt=reader.SettingsInputFormat.XML) - - def test_noAmbiguous(self): - """Tests that settings need essentially full definitions - - :ref:`REQ32335060-e995-4ef8-a818-aaba4e8d1f85` - """ - mock = settings.Settings() - - xml = six.StringIO( - '' "" - ) - with self.assertRaises(exceptions.SettingException): - reader = settingsIO.SettingsDefinitionReader(mock) - reader.readFromStream(xml, fmt=reader.SettingsInputFormat.XML) - - def test_basicRules(self): - """Tests that settings need some basic rule following behavior - - :ref:`REQd9e90f54-1add-43b4-943a-bbccaf34c7dc` - """ - mock = settings.Settings() - - xml = six.StringIO( - '' - "" - ) - reader = settingsIO.SettingsDefinitionReader(mock) - reader.readFromStream(xml, fmt=reader.SettingsInputFormat.XML) - - with self.assertRaises(ValueError): - mock["banana"] = "spicy" - with self.assertRaises(ValueError): - mock["banana"] = 1 - with self.assertRaises(ValueError): - mock["banana"] = 25 - - mock["banana"] = 2 - mock["banana"] = 12 - mock["banana"] = 20 - - def test_settingsVersioning(self): - """Tests the version protection and run-stops in settings - - :ref:`REQb03e7fc0-754b-46b1-8400-238622e5ba0c` - """ - mock = settings.Settings() - xml = six.StringIO( - '' - ) - - with self.assertRaises(exceptions.SettingException): - reader = settingsIO.SettingsDefinitionReader(mock) - reader.readFromStream(xml, fmt=reader.SettingsInputFormat.XML) - - def test_multipleDefinedSettingsFails(self): - """ - If someone defines a setting twice, that should crash. - - :ref:`REQ82a55fe7-cd0e-4588-9f5b-fb266b8d6637` - """ - mock = settings.Settings() - erroneousSettings = six.StringIO( - '' - '' - ) - with self.assertRaises(exceptions.SettingException): - reader = settingsIO.SettingsReader(mock) - reader.readFromStream(erroneousSettings, fmt=reader.SettingsInputFormat.XML) - - -class SettingsWriterTests(unittest.TestCase): - def setUp(self): - self.init_mode = armi.CURRENT_MODE - self.filepath = os.path.join( - os.getcwd(), self._testMethodName + "test_setting_io.xml" - ) - self.filepathYaml = os.path.join( - os.getcwd(), self._testMethodName + "test_setting_io.yaml" - ) - self.cs = settings.Settings() - self.cs["nCycles"] = 55 - - def tearDown(self): - if os.path.exists(self.filepath): - os.remove(self.filepath) - armi.Mode.setMode(self.init_mode) - - def test_writeShorthand(self): - """Setting output as a sparse file""" - self.cs.writeToXMLFile(self.filepath, style="short") - self.cs.loadFromInputFile(self.filepath) - - def test_writeAll(self): - """Setting output as a fully defined file""" - self.cs.writeToXMLFile(self.filepath, style="full") - self.cs.loadFromInputFile(self.filepath) - - def test_writeYaml(self): - self.cs.writeToYamlFile(self.filepathYaml) - self.cs.loadFromInputFile(self.filepathYaml) - self.assertEqual(self.cs["nCycles"], 55) - - -if __name__ == "__main__": - # import sys;sys.argv = ['', 'TestSetting.test_relativeToSettingPath'] - unittest.main() diff --git a/armi/settings/tests/test_settings2.py b/armi/settings/tests/test_settings2.py index db06dda934..b5821ff36a 100644 --- a/armi/settings/tests/test_settings2.py +++ b/armi/settings/tests/test_settings2.py @@ -25,7 +25,7 @@ from armi.physics.fuelCycle import FuelHandlerPlugin from armi import settings from armi.settings import caseSettings -from armi.settings import setting2 as setting +from armi.settings import setting from armi.operators import settingsValidation from armi import plugins From 8891d98c821858fb2e9a44494afd9e0a4ed2c3d4 Mon Sep 17 00:00:00 2001 From: Mitchell Young Date: Fri, 17 Apr 2020 13:41:54 -0700 Subject: [PATCH 2/2] Add some testing for SettingIO --- armi/settings/settingsIO.py | 35 +++++-------- armi/settings/tests/test_settingsIO.py | 68 ++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 24 deletions(-) create mode 100644 armi/settings/tests/test_settingsIO.py diff --git a/armi/settings/settingsIO.py b/armi/settings/settingsIO.py index 21b6113d08..f70914ac32 100644 --- a/armi/settings/settingsIO.py +++ b/armi/settings/settingsIO.py @@ -308,38 +308,32 @@ def applyTypeConversions(settingObj, value): return value -class SettingsWriter(object): +class SettingsWriter: """Writes settings out to files. - This can write in three styles: - - definition - setting definitions listing, includes every setting + This can write in two styles: + short setting values that are not their defaults only - full + full all setting values regardless of default status """ - class Styles(object): - """Collection of valid output styles""" + class Styles: + """Enumeration of valid output styles""" - definition = "definition" short = "short" full = "full" def __init__(self, settings_instance, style="short"): self.cs = settings_instance self.style = style - if style not in {self.Styles.definition, self.Styles.short, self.Styles.full}: + if style not in {self.Styles.short, self.Styles.full}: raise ValueError("Invalid supplied setting writing style {}".format(style)) def _getVersion(self): - if self.style == self.Styles.definition: - tag, attrib = Roots.DEFINITION, {} - else: - tag, attrib = Roots.CUSTOM, {Roots.VERSION: armi.__version__} + tag, attrib = Roots.CUSTOM, {Roots.VERSION: armi.__version__} return tag, attrib def writeXml(self, stream): @@ -355,11 +349,7 @@ def writeXml(self, stream): settingNode.set(attribName, str(attribValue)) stream.write('\n') - stream.write( - self.prettyPrintXmlRecursively( - tree.getroot(), spacing=self.style == self.Styles.definition - ) - ) + stream.write(self.prettyPrintXmlRecursively(tree.getroot(), spacing=False)) def writeYaml(self, stream): """Write settings to YAML file.""" @@ -403,11 +393,8 @@ def _getSettingDataToWrite(self): ): if self.style == self.Styles.short and not settingObject.offDefault: continue - attribs = ( - settingObject.getDefaultAttributes().items() - if self.style == self.Styles.definition - else settingObject.getCustomAttributes().items() - ) + + attribs = settingObject.getCustomAttributes().items() settingDatum = {} for (attribName, attribValue) in attribs: if isinstance(attribValue, type): diff --git a/armi/settings/tests/test_settingsIO.py b/armi/settings/tests/test_settingsIO.py new file mode 100644 index 0000000000..742e593667 --- /dev/null +++ b/armi/settings/tests/test_settingsIO.py @@ -0,0 +1,68 @@ +# 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. + +import os +import unittest + +import armi +from armi.utils import directoryChangers +from armi import settings +from armi.settings import setting +from armi.localization import exceptions + + +class SettingsFailureTests(unittest.TestCase): + def test_settingsObjSetting(self): + sets = settings.Settings() + with self.assertRaises(exceptions.NonexistentSetting): + sets[ + "idontexist" + ] = "this test should fail because no setting named idontexist should exist." + + def test_loadFromXmlFailsOnBadNames(self): + ss = settings.Settings() + with self.assertRaises(TypeError): + ss.loadFromInputFile(None) + with self.assertRaises(IOError): + ss.loadFromInputFile("this-settings-file-does-not-exist.xml") + + +class SettingsWriterTests(unittest.TestCase): + def setUp(self): + self.td = directoryChangers.TemporaryDirectoryChanger() + self.td.__enter__() + self.init_mode = armi.CURRENT_MODE + self.filepathXml = os.path.join( + os.getcwd(), self._testMethodName + "test_setting_io.xml" + ) + self.filepathYaml = os.path.join( + os.getcwd(), self._testMethodName + "test_setting_io.yaml" + ) + self.cs = settings.Settings() + self.cs["nCycles"] = 55 + + def tearDown(self): + armi.Mode.setMode(self.init_mode) + self.td.__exit__(None, None, None) + + def test_writeShorthand(self): + """Setting output as a sparse file""" + self.cs.writeToXMLFile(self.filepathXml, style="short") + self.cs.loadFromInputFile(self.filepathXml) + self.assertEqual(self.cs["nCycles"], 55) + + def test_writeYaml(self): + self.cs.writeToYamlFile(self.filepathYaml) + self.cs.loadFromInputFile(self.filepathYaml) + self.assertEqual(self.cs["nCycles"], 55)