Coverage for python/lsst/pex/config/configChoiceField.py: 16%
269 statements
« prev ^ index » next coverage.py v6.5.0, created at 2022-12-01 19:59 +0000
« prev ^ index » next coverage.py v6.5.0, created at 2022-12-01 19:59 +0000
1# This file is part of pex_config.
2#
3# Developed for the LSST Data Management System.
4# This product includes software developed by the LSST Project
5# (http://www.lsst.org).
6# See the COPYRIGHT file at the top-level directory of this distribution
7# for details of code ownership.
8#
9# This software is dual licensed under the GNU General Public License and also
10# under a 3-clause BSD license. Recipients may choose which of these licenses
11# to use; please see the files gpl-3.0.txt and/or bsd_license.txt,
12# respectively. If you choose the GPL option then the following text applies
13# (but note that there is still no warranty even if you opt for BSD instead):
14#
15# This program is free software: you can redistribute it and/or modify
16# it under the terms of the GNU General Public License as published by
17# the Free Software Foundation, either version 3 of the License, or
18# (at your option) any later version.
19#
20# This program is distributed in the hope that it will be useful,
21# but WITHOUT ANY WARRANTY; without even the implied warranty of
22# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
23# GNU General Public License for more details.
24#
25# You should have received a copy of the GNU General Public License
26# along with this program. If not, see <http://www.gnu.org/licenses/>.
28__all__ = ["ConfigChoiceField"]
30import copy
31import collections.abc
33from .config import Config, Field, FieldValidationError, _typeStr, _joinNamePath
34from .comparison import getComparisonName, compareScalars, compareConfigs
35from .callStack import getCallStack, getStackFrame
37import weakref
40class SelectionSet(collections.abc.MutableSet):
41 """A mutable set class that tracks the selection of multi-select
42 `~lsst.pex.config.ConfigChoiceField` objects.
44 Parameters
45 ----------
46 dict_ : `ConfigInstanceDict`
47 The dictionary of instantiated configs.
48 value
49 The selected key.
50 at : `lsst.pex.config.callStack.StackFrame`, optional
51 The call stack when the selection was made.
52 label : `str`, optional
53 Label for history tracking.
54 setHistory : `bool`, optional
55 Add this even to the history, if `True`.
57 Notes
58 -----
59 This class allows a user of a multi-select
60 `~lsst.pex.config.ConfigChoiceField` to add or discard items from the set
61 of active configs. Each change to the selection is tracked in the field's
62 history.
63 """
65 def __init__(self, dict_, value, at=None, label="assignment", setHistory=True):
66 if at is None:
67 at = getCallStack()
68 self._dict = dict_
69 self._field = self._dict._field
70 self._config_ = weakref.ref(self._dict._config)
71 self.__history = self._config._history.setdefault(self._field.name, [])
72 if value is not None:
73 try:
74 for v in value:
75 if v not in self._dict:
76 # invoke __getitem__ to ensure it's present
77 self._dict.__getitem__(v, at=at)
78 except TypeError:
79 msg = "Value %s is of incorrect type %s. Sequence type expected" % (value, _typeStr(value))
80 raise FieldValidationError(self._field, self._config, msg)
81 self._set = set(value)
82 else:
83 self._set = set()
85 if setHistory:
86 self.__history.append(("Set selection to %s" % self, at, label))
88 @property
89 def _config(self) -> Config:
90 # Config Fields should never outlive their config class instance
91 # assert that as such here
92 assert(self._config_() is not None)
93 return self._config_()
95 def add(self, value, at=None):
96 """Add a value to the selected set.
97 """
98 if self._config._frozen:
99 raise FieldValidationError(self._field, self._config,
100 "Cannot modify a frozen Config")
102 if at is None:
103 at = getCallStack()
105 if value not in self._dict:
106 # invoke __getitem__ to make sure it's present
107 self._dict.__getitem__(value, at=at)
109 self.__history.append(("added %s to selection" % value, at, "selection"))
110 self._set.add(value)
112 def discard(self, value, at=None):
113 """Discard a value from the selected set.
114 """
115 if self._config._frozen:
116 raise FieldValidationError(self._field, self._config,
117 "Cannot modify a frozen Config")
119 if value not in self._dict:
120 return
122 if at is None:
123 at = getCallStack()
125 self.__history.append(("removed %s from selection" % value, at, "selection"))
126 self._set.discard(value)
128 def __len__(self):
129 return len(self._set)
131 def __iter__(self):
132 return iter(self._set)
134 def __contains__(self, value):
135 return value in self._set
137 def __repr__(self):
138 return repr(list(self._set))
140 def __str__(self):
141 return str(list(self._set))
144class ConfigInstanceDict(collections.abc.Mapping):
145 """Dictionary of instantiated configs, used to populate a
146 `~lsst.pex.config.ConfigChoiceField`.
148 Parameters
149 ----------
150 config : `lsst.pex.config.Config`
151 A configuration instance.
152 field : `lsst.pex.config.Field`-type
153 A configuration field. Note that the `lsst.pex.config.Field.fieldmap`
154 attribute must provide key-based access to configuration classes,
155 (that is, ``typemap[name]``).
156 """
157 def __init__(self, config, field):
158 collections.abc.Mapping.__init__(self)
159 self._dict = dict()
160 self._selection = None
161 self._config = config
162 self._field = field
163 self._history = config._history.setdefault(field.name, [])
164 self.__doc__ = field.doc
165 self._typemap = None
167 @property
168 def types(self):
169 return self._typemap if self._typemap is not None else self._field.typemap
171 def __contains__(self, k):
172 return k in self.types
174 def __len__(self):
175 return len(self.types)
177 def __iter__(self):
178 return iter(self.types)
180 def _setSelection(self, value, at=None, label="assignment"):
181 if self._config._frozen:
182 raise FieldValidationError(self._field, self._config, "Cannot modify a frozen Config")
184 if at is None:
185 at = getCallStack(1)
187 if value is None:
188 self._selection = None
189 elif self._field.multi:
190 self._selection = SelectionSet(self, value, setHistory=False)
191 else:
192 if value not in self._dict:
193 self.__getitem__(value, at=at) # just invoke __getitem__ to make sure it's present
194 self._selection = value
195 self._history.append((value, at, label))
197 def _getNames(self):
198 if not self._field.multi:
199 raise FieldValidationError(self._field, self._config,
200 "Single-selection field has no attribute 'names'")
201 return self._selection
203 def _setNames(self, value):
204 if not self._field.multi:
205 raise FieldValidationError(self._field, self._config,
206 "Single-selection field has no attribute 'names'")
207 self._setSelection(value)
209 def _delNames(self):
210 if not self._field.multi:
211 raise FieldValidationError(self._field, self._config,
212 "Single-selection field has no attribute 'names'")
213 self._selection = None
215 def _getName(self):
216 if self._field.multi:
217 raise FieldValidationError(self._field, self._config,
218 "Multi-selection field has no attribute 'name'")
219 return self._selection
221 def _setName(self, value):
222 if self._field.multi:
223 raise FieldValidationError(self._field, self._config,
224 "Multi-selection field has no attribute 'name'")
225 self._setSelection(value)
227 def _delName(self):
228 if self._field.multi:
229 raise FieldValidationError(self._field, self._config,
230 "Multi-selection field has no attribute 'name'")
231 self._selection = None
233 names = property(_getNames, _setNames, _delNames)
234 """List of names of active items in a multi-selection
235 ``ConfigInstanceDict``. Disabled in a single-selection ``_Registry``; use
236 the `name` attribute instead.
237 """
239 name = property(_getName, _setName, _delName)
240 """Name of the active item in a single-selection ``ConfigInstanceDict``.
241 Disabled in a multi-selection ``_Registry``; use the ``names`` attribute
242 instead.
243 """
245 def _getActive(self):
246 if self._selection is None:
247 return None
249 if self._field.multi:
250 return [self[c] for c in self._selection]
251 else:
252 return self[self._selection]
254 active = property(_getActive)
255 """The selected items.
257 For multi-selection, this is equivalent to: ``[self[name] for name in
258 self.names]``. For single-selection, this is equivalent to: ``self[name]``.
259 """
261 def __getitem__(self, k, at=None, label="default"):
262 try:
263 value = self._dict[k]
264 except KeyError:
265 try:
266 dtype = self.types[k]
267 except Exception:
268 raise FieldValidationError(self._field, self._config,
269 "Unknown key %r in Registry/ConfigChoiceField" % k)
270 name = _joinNamePath(self._config._name, self._field.name, k)
271 if at is None:
272 at = getCallStack()
273 at.insert(0, dtype._source)
274 value = self._dict.setdefault(k, dtype(__name=name, __at=at, __label=label))
275 return value
277 def __setitem__(self, k, value, at=None, label="assignment"):
278 if self._config._frozen:
279 raise FieldValidationError(self._field, self._config, "Cannot modify a frozen Config")
281 try:
282 dtype = self.types[k]
283 except Exception:
284 raise FieldValidationError(self._field, self._config, "Unknown key %r" % k)
286 if value != dtype and type(value) != dtype:
287 msg = "Value %s at key %s is of incorrect type %s. Expected type %s" % \
288 (value, k, _typeStr(value), _typeStr(dtype))
289 raise FieldValidationError(self._field, self._config, msg)
291 if at is None:
292 at = getCallStack()
293 name = _joinNamePath(self._config._name, self._field.name, k)
294 oldValue = self._dict.get(k, None)
295 if oldValue is None:
296 if value == dtype:
297 self._dict[k] = value(__name=name, __at=at, __label=label)
298 else:
299 self._dict[k] = dtype(__name=name, __at=at, __label=label, **value._storage)
300 else:
301 if value == dtype:
302 value = value()
303 oldValue.update(__at=at, __label=label, **value._storage)
305 def _rename(self, fullname):
306 for k, v in self._dict.items():
307 v._rename(_joinNamePath(name=fullname, index=k))
309 def __setattr__(self, attr, value, at=None, label="assignment"):
310 if hasattr(getattr(self.__class__, attr, None), '__set__'):
311 # This allows properties to work.
312 object.__setattr__(self, attr, value)
313 elif attr in self.__dict__ or attr in ["_history", "_field", "_config", "_dict",
314 "_selection", "__doc__", "_typemap"]:
315 # This allows specific private attributes to work.
316 object.__setattr__(self, attr, value)
317 else:
318 # We throw everything else.
319 msg = "%s has no attribute %s" % (_typeStr(self._field), attr)
320 raise FieldValidationError(self._field, self._config, msg)
322 def freeze(self):
323 """Invoking this freeze method will create a local copy of the field
324 attribute's typemap. This decouples this instance dict from the
325 underlying objects type map ensuring that and subsequent changes to the
326 typemap will not be reflected in this instance (i.e imports adding
327 additional registry entries).
328 """
329 if self._typemap is None:
330 self._typemap = copy.deepcopy(self.types)
333class ConfigChoiceField(Field):
334 """A configuration field (`~lsst.pex.config.Field` subclass) that allows a
335 user to choose from a set of `~lsst.pex.config.Config` types.
337 Parameters
338 ----------
339 doc : `str`
340 Documentation string for the field.
341 typemap : `dict`-like
342 A mapping between keys and `~lsst.pex.config.Config`-types as values.
343 See *Examples* for details.
344 default : `str`, optional
345 The default configuration name.
346 optional : `bool`, optional
347 When `False`, `lsst.pex.config.Config.validate` will fail if the
348 field's value is `None`.
349 multi : `bool`, optional
350 If `True`, the field allows multiple selections. In this case, set the
351 selections by assigning a sequence to the ``names`` attribute of the
352 field.
354 If `False`, the field allows only a single selection. In this case,
355 set the active config by assigning the config's key from the
356 ``typemap`` to the field's ``name`` attribute (see *Examples*).
357 deprecated : None or `str`, optional
358 A description of why this Field is deprecated, including removal date.
359 If not None, the string is appended to the docstring for this Field.
361 See also
362 --------
363 ChoiceField
364 ConfigDictField
365 ConfigField
366 ConfigurableField
367 DictField
368 Field
369 ListField
370 RangeField
371 RegistryField
373 Notes
374 -----
375 ``ConfigChoiceField`` instances can allow either single selections or
376 multiple selections, depending on the ``multi`` parameter. For
377 single-selection fields, set the selection with the ``name`` attribute.
378 For multi-selection fields, set the selection though the ``names``
379 attribute.
381 This field is validated only against the active selection. If the
382 ``active`` attribute is `None` and the field is not optional, validation
383 will fail.
385 When saving a configuration with a ``ConfigChoiceField``, the entire set is
386 saved, as well as the active selection.
388 Examples
389 --------
390 While the ``typemap`` is shared by all instances of the field, each
391 instance of the field has its own instance of a particular sub-config type.
393 For example, ``AaaConfig`` is a config object
395 >>> from lsst.pex.config import Config, ConfigChoiceField, Field
396 >>> class AaaConfig(Config):
397 ... somefield = Field("doc", int)
398 ...
400 The ``MyConfig`` config has a ``ConfigChoiceField`` field called ``choice``
401 that maps the ``AaaConfig`` type to the ``"AAA"`` key:
403 >>> TYPEMAP = {"AAA", AaaConfig}
404 >>> class MyConfig(Config):
405 ... choice = ConfigChoiceField("doc for choice", TYPEMAP)
406 ...
408 Creating an instance of ``MyConfig``:
410 >>> instance = MyConfig()
412 Setting value of the field ``somefield`` on the "AAA" key of the ``choice``
413 field:
415 >>> instance.choice['AAA'].somefield = 5
417 **Selecting the active configuration**
419 Make the ``"AAA"`` key the active configuration value for the ``choice``
420 field:
422 >>> instance.choice = "AAA"
424 Alternatively, the last line can be written:
426 >>> instance.choice.name = "AAA"
428 (If the config instance allows multiple selections, you'd assign a sequence
429 to the ``names`` attribute instead.)
431 ``ConfigChoiceField`` instances also allow multiple values of the same
432 type:
434 >>> TYPEMAP["CCC"] = AaaConfig
435 >>> TYPEMAP["BBB"] = AaaConfig
436 """
438 instanceDictClass = ConfigInstanceDict
440 def __init__(self, doc, typemap, default=None, optional=False, multi=False, deprecated=None):
441 source = getStackFrame()
442 self._setup(doc=doc, dtype=self.instanceDictClass, default=default, check=None, optional=optional,
443 source=source, deprecated=deprecated)
444 self.typemap = typemap
445 self.multi = multi
447 def _getOrMake(self, instance, label="default"):
448 instanceDict = instance._storage.get(self.name)
449 if instanceDict is None:
450 at = getCallStack(1)
451 instanceDict = self.dtype(instance, self)
452 instanceDict.__doc__ = self.doc
453 instance._storage[self.name] = instanceDict
454 history = instance._history.setdefault(self.name, [])
455 history.append(("Initialized from defaults", at, label))
457 return instanceDict
459 def __get__(self, instance, owner=None):
460 if instance is None or not isinstance(instance, Config):
461 return self
462 else:
463 return self._getOrMake(instance)
465 def __set__(self, instance, value, at=None, label="assignment"):
466 if instance._frozen:
467 raise FieldValidationError(self, instance, "Cannot modify a frozen Config")
468 if at is None:
469 at = getCallStack()
470 instanceDict = self._getOrMake(instance)
471 if isinstance(value, self.instanceDictClass):
472 for k, v in value.items():
473 instanceDict.__setitem__(k, v, at=at, label=label)
474 instanceDict._setSelection(value._selection, at=at, label=label)
476 else:
477 instanceDict._setSelection(value, at=at, label=label)
479 def rename(self, instance):
480 instanceDict = self.__get__(instance)
481 fullname = _joinNamePath(instance._name, self.name)
482 instanceDict._rename(fullname)
484 def validate(self, instance):
485 instanceDict = self.__get__(instance)
486 if instanceDict.active is None and not self.optional:
487 msg = "Required field cannot be None"
488 raise FieldValidationError(self, instance, msg)
489 elif instanceDict.active is not None:
490 if self.multi:
491 for a in instanceDict.active:
492 a.validate()
493 else:
494 instanceDict.active.validate()
496 def toDict(self, instance):
497 instanceDict = self.__get__(instance)
499 dict_ = {}
500 if self.multi:
501 dict_["names"] = instanceDict.names
502 else:
503 dict_["name"] = instanceDict.name
505 values = {}
506 for k, v in instanceDict.items():
507 values[k] = v.toDict()
508 dict_["values"] = values
510 return dict_
512 def freeze(self, instance):
513 instanceDict = self.__get__(instance)
514 instanceDict.freeze()
515 for v in instanceDict.values():
516 v.freeze()
518 def _collectImports(self, instance, imports):
519 instanceDict = self.__get__(instance)
520 for config in instanceDict.values():
521 config._collectImports()
522 imports |= config._imports
524 def save(self, outfile, instance):
525 instanceDict = self.__get__(instance)
526 fullname = _joinNamePath(instance._name, self.name)
527 for v in instanceDict.values():
528 v._save(outfile)
529 if self.multi:
530 outfile.write(u"{}.names={!r}\n".format(fullname, instanceDict.names))
531 else:
532 outfile.write(u"{}.name={!r}\n".format(fullname, instanceDict.name))
534 def __deepcopy__(self, memo):
535 """Customize deep-copying, because we always want a reference to the
536 original typemap.
538 WARNING: this must be overridden by subclasses if they change the
539 constructor signature!
540 """
541 other = type(self)(doc=self.doc, typemap=self.typemap, default=copy.deepcopy(self.default),
542 optional=self.optional, multi=self.multi)
543 other.source = self.source
544 return other
546 def _compare(self, instance1, instance2, shortcut, rtol, atol, output):
547 """Compare two fields for equality.
549 Used by `lsst.pex.ConfigChoiceField.compare`.
551 Parameters
552 ----------
553 instance1 : `lsst.pex.config.Config`
554 Left-hand side config instance to compare.
555 instance2 : `lsst.pex.config.Config`
556 Right-hand side config instance to compare.
557 shortcut : `bool`
558 If `True`, this function returns as soon as an inequality if found.
559 rtol : `float`
560 Relative tolerance for floating point comparisons.
561 atol : `float`
562 Absolute tolerance for floating point comparisons.
563 output : callable
564 A callable that takes a string, used (possibly repeatedly) to
565 report inequalities.
567 Returns
568 -------
569 isEqual : bool
570 `True` if the fields are equal, `False` otherwise.
572 Notes
573 -----
574 Only the selected configurations are compared, as the parameters of any
575 others do not matter.
577 Floating point comparisons are performed by `numpy.allclose`.
578 """
579 d1 = getattr(instance1, self.name)
580 d2 = getattr(instance2, self.name)
581 name = getComparisonName(
582 _joinNamePath(instance1._name, self.name),
583 _joinNamePath(instance2._name, self.name)
584 )
585 if not compareScalars("selection for %s" % name, d1._selection, d2._selection, output=output):
586 return False
587 if d1._selection is None:
588 return True
589 if self.multi:
590 nested = [(k, d1[k], d2[k]) for k in d1._selection]
591 else:
592 nested = [(d1._selection, d1[d1._selection], d2[d1._selection])]
593 equal = True
594 for k, c1, c2 in nested:
595 result = compareConfigs("%s[%r]" % (name, k), c1, c2, shortcut=shortcut,
596 rtol=rtol, atol=atol, output=output)
597 if not result and shortcut:
598 return False
599 equal = equal and result
600 return equal