/
setting.py
385 lines (330 loc) · 13.5 KB
/
setting.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
# 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
-----
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
import datetime
from typing import List, Optional, Tuple
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"])
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,
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, 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.
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.
"""
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
# 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.Invalid:
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
@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
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
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,
}
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
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,
)
@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 `{self}`")
return flagVals
def dump(self) -> List[str]:
"""Return a list of strings converted from the flag values."""
return [Flags.toString(v) for v in self.value]