Coverage for python/lsst/pex/config/configChoiceField.py: 18%
276 statements
« prev ^ index » next coverage.py v6.4, created at 2022-06-02 03:31 -0700
« prev ^ index » next coverage.py v6.4, created at 2022-06-02 03:31 -0700
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 collections.abc
31import copy
32import weakref
34from .callStack import getCallStack, getStackFrame
35from .comparison import compareConfigs, compareScalars, getComparisonName
36from .config import Config, Field, FieldValidationError, UnexpectedProxyUsageError, _joinNamePath, _typeStr
39class SelectionSet(collections.abc.MutableSet):
40 """A mutable set class that tracks the selection of multi-select
41 `~lsst.pex.config.ConfigChoiceField` objects.
43 Parameters
44 ----------
45 dict_ : `ConfigInstanceDict`
46 The dictionary of instantiated configs.
47 value
48 The selected key.
49 at : `lsst.pex.config.callStack.StackFrame`, optional
50 The call stack when the selection was made.
51 label : `str`, optional
52 Label for history tracking.
53 setHistory : `bool`, optional
54 Add this even to the history, if `True`.
56 Notes
57 -----
58 This class allows a user of a multi-select
59 `~lsst.pex.config.ConfigChoiceField` to add or discard items from the set
60 of active configs. Each change to the selection is tracked in the field's
61 history.
62 """
64 def __init__(self, dict_, value, at=None, label="assignment", setHistory=True):
65 if at is None:
66 at = getCallStack()
67 self._dict = dict_
68 self._field = self._dict._field
69 self._config_ = weakref.ref(self._dict._config)
70 self.__history = self._config._history.setdefault(self._field.name, [])
71 if value is not None:
72 try:
73 for v in value:
74 if v not in self._dict:
75 # invoke __getitem__ to ensure it's present
76 self._dict.__getitem__(v, at=at)
77 except TypeError:
78 msg = "Value %s is of incorrect type %s. Sequence type expected" % (value, _typeStr(value))
79 raise FieldValidationError(self._field, self._config, msg)
80 self._set = set(value)
81 else:
82 self._set = set()
84 if setHistory:
85 self.__history.append(("Set selection to %s" % self, at, label))
87 @property
88 def _config(self) -> Config:
89 # Config Fields should never outlive their config class instance
90 # assert that as such here
91 assert self._config_() is not None
92 return self._config_()
94 def add(self, value, at=None):
95 """Add a value to the selected set."""
96 if self._config._frozen:
97 raise FieldValidationError(self._field, self._config, "Cannot modify a frozen Config")
99 if at is None:
100 at = getCallStack()
102 if value not in self._dict:
103 # invoke __getitem__ to make sure it's present
104 self._dict.__getitem__(value, at=at)
106 self.__history.append(("added %s to selection" % value, at, "selection"))
107 self._set.add(value)
109 def discard(self, value, at=None):
110 """Discard a value from the selected set."""
111 if self._config._frozen:
112 raise FieldValidationError(self._field, self._config, "Cannot modify a frozen Config")
114 if value not in self._dict:
115 return
117 if at is None:
118 at = getCallStack()
120 self.__history.append(("removed %s from selection" % value, at, "selection"))
121 self._set.discard(value)
123 def __len__(self):
124 return len(self._set)
126 def __iter__(self):
127 return iter(self._set)
129 def __contains__(self, value):
130 return value in self._set
132 def __repr__(self):
133 return repr(list(self._set))
135 def __str__(self):
136 return str(list(self._set))
138 def __reduce__(self):
139 raise UnexpectedProxyUsageError(
140 f"Proxy container for config field {self._field.name} cannot "
141 "be pickled; it should be converted to a built-in container before "
142 "being assigned to other objects or variables."
143 )
146class ConfigInstanceDict(collections.abc.Mapping):
147 """Dictionary of instantiated configs, used to populate a
148 `~lsst.pex.config.ConfigChoiceField`.
150 Parameters
151 ----------
152 config : `lsst.pex.config.Config`
153 A configuration instance.
154 field : `lsst.pex.config.Field`-type
155 A configuration field. Note that the `lsst.pex.config.Field.fieldmap`
156 attribute must provide key-based access to configuration classes,
157 (that is, ``typemap[name]``).
158 """
160 def __init__(self, config, field):
161 collections.abc.Mapping.__init__(self)
162 self._dict = dict()
163 self._selection = None
164 self._config = config
165 self._field = field
166 self._history = config._history.setdefault(field.name, [])
167 self.__doc__ = field.doc
168 self._typemap = None
170 @property
171 def types(self):
172 return self._typemap if self._typemap is not None else self._field.typemap
174 def __contains__(self, k):
175 return k in self.types
177 def __len__(self):
178 return len(self.types)
180 def __iter__(self):
181 return iter(self.types)
183 def _setSelection(self, value, at=None, label="assignment"):
184 if self._config._frozen:
185 raise FieldValidationError(self._field, self._config, "Cannot modify a frozen Config")
187 if at is None:
188 at = getCallStack(1)
190 if value is None:
191 self._selection = None
192 elif self._field.multi:
193 self._selection = SelectionSet(self, value, setHistory=False)
194 else:
195 if value not in self._dict:
196 self.__getitem__(value, at=at) # just invoke __getitem__ to make sure it's present
197 self._selection = value
198 self._history.append((value, at, label))
200 def _getNames(self):
201 if not self._field.multi:
202 raise FieldValidationError(
203 self._field, self._config, "Single-selection field has no attribute 'names'"
204 )
205 return self._selection
207 def _setNames(self, value):
208 if not self._field.multi:
209 raise FieldValidationError(
210 self._field, self._config, "Single-selection field has no attribute 'names'"
211 )
212 self._setSelection(value)
214 def _delNames(self):
215 if not self._field.multi:
216 raise FieldValidationError(
217 self._field, self._config, "Single-selection field has no attribute 'names'"
218 )
219 self._selection = None
221 def _getName(self):
222 if self._field.multi:
223 raise FieldValidationError(
224 self._field, self._config, "Multi-selection field has no attribute 'name'"
225 )
226 return self._selection
228 def _setName(self, value):
229 if self._field.multi:
230 raise FieldValidationError(
231 self._field, self._config, "Multi-selection field has no attribute 'name'"
232 )
233 self._setSelection(value)
235 def _delName(self):
236 if self._field.multi:
237 raise FieldValidationError(
238 self._field, self._config, "Multi-selection field has no attribute 'name'"
239 )
240 self._selection = None
242 names = property(_getNames, _setNames, _delNames)
243 """List of names of active items in a multi-selection
244 ``ConfigInstanceDict``. Disabled in a single-selection ``_Registry``; use
245 the `name` attribute instead.
246 """
248 name = property(_getName, _setName, _delName)
249 """Name of the active item in a single-selection ``ConfigInstanceDict``.
250 Disabled in a multi-selection ``_Registry``; use the ``names`` attribute
251 instead.
252 """
254 def _getActive(self):
255 if self._selection is None:
256 return None
258 if self._field.multi:
259 return [self[c] for c in self._selection]
260 else:
261 return self[self._selection]
263 active = property(_getActive)
264 """The selected items.
266 For multi-selection, this is equivalent to: ``[self[name] for name in
267 self.names]``. For single-selection, this is equivalent to: ``self[name]``.
268 """
270 def __getitem__(self, k, at=None, label="default"):
271 try:
272 value = self._dict[k]
273 except KeyError:
274 try:
275 dtype = self.types[k]
276 except Exception:
277 raise FieldValidationError(
278 self._field, self._config, "Unknown key %r in Registry/ConfigChoiceField" % k
279 )
280 name = _joinNamePath(self._config._name, self._field.name, k)
281 if at is None:
282 at = getCallStack()
283 at.insert(0, dtype._source)
284 value = self._dict.setdefault(k, dtype(__name=name, __at=at, __label=label))
285 return value
287 def __setitem__(self, k, value, at=None, label="assignment"):
288 if self._config._frozen:
289 raise FieldValidationError(self._field, self._config, "Cannot modify a frozen Config")
291 try:
292 dtype = self.types[k]
293 except Exception:
294 raise FieldValidationError(self._field, self._config, "Unknown key %r" % k)
296 if value != dtype and type(value) != dtype:
297 msg = "Value %s at key %s is of incorrect type %s. Expected type %s" % (
298 value,
299 k,
300 _typeStr(value),
301 _typeStr(dtype),
302 )
303 raise FieldValidationError(self._field, self._config, msg)
305 if at is None:
306 at = getCallStack()
307 name = _joinNamePath(self._config._name, self._field.name, k)
308 oldValue = self._dict.get(k, None)
309 if oldValue is None:
310 if value == dtype:
311 self._dict[k] = value(__name=name, __at=at, __label=label)
312 else:
313 self._dict[k] = dtype(__name=name, __at=at, __label=label, **value._storage)
314 else:
315 if value == dtype:
316 value = value()
317 oldValue.update(__at=at, __label=label, **value._storage)
319 def _rename(self, fullname):
320 for k, v in self._dict.items():
321 v._rename(_joinNamePath(name=fullname, index=k))
323 def __setattr__(self, attr, value, at=None, label="assignment"):
324 if hasattr(getattr(self.__class__, attr, None), "__set__"):
325 # This allows properties to work.
326 object.__setattr__(self, attr, value)
327 elif attr in self.__dict__ or attr in [
328 "_history",
329 "_field",
330 "_config",
331 "_dict",
332 "_selection",
333 "__doc__",
334 "_typemap",
335 ]:
336 # This allows specific private attributes to work.
337 object.__setattr__(self, attr, value)
338 else:
339 # We throw everything else.
340 msg = "%s has no attribute %s" % (_typeStr(self._field), attr)
341 raise FieldValidationError(self._field, self._config, msg)
343 def freeze(self):
344 """Invoking this freeze method will create a local copy of the field
345 attribute's typemap. This decouples this instance dict from the
346 underlying objects type map ensuring that and subsequent changes to the
347 typemap will not be reflected in this instance (i.e imports adding
348 additional registry entries).
349 """
350 if self._typemap is None:
351 self._typemap = copy.deepcopy(self.types)
353 def __reduce__(self):
354 raise UnexpectedProxyUsageError(
355 f"Proxy container for config field {self._field.name} cannot "
356 "be pickled; it should be converted to a built-in container before "
357 "being assigned to other objects or variables."
358 )
361class ConfigChoiceField(Field):
362 """A configuration field (`~lsst.pex.config.Field` subclass) that allows a
363 user to choose from a set of `~lsst.pex.config.Config` types.
365 Parameters
366 ----------
367 doc : `str`
368 Documentation string for the field.
369 typemap : `dict`-like
370 A mapping between keys and `~lsst.pex.config.Config`-types as values.
371 See *Examples* for details.
372 default : `str`, optional
373 The default configuration name.
374 optional : `bool`, optional
375 When `False`, `lsst.pex.config.Config.validate` will fail if the
376 field's value is `None`.
377 multi : `bool`, optional
378 If `True`, the field allows multiple selections. In this case, set the
379 selections by assigning a sequence to the ``names`` attribute of the
380 field.
382 If `False`, the field allows only a single selection. In this case,
383 set the active config by assigning the config's key from the
384 ``typemap`` to the field's ``name`` attribute (see *Examples*).
385 deprecated : None or `str`, optional
386 A description of why this Field is deprecated, including removal date.
387 If not None, the string is appended to the docstring for this Field.
389 See also
390 --------
391 ChoiceField
392 ConfigDictField
393 ConfigField
394 ConfigurableField
395 DictField
396 Field
397 ListField
398 RangeField
399 RegistryField
401 Notes
402 -----
403 ``ConfigChoiceField`` instances can allow either single selections or
404 multiple selections, depending on the ``multi`` parameter. For
405 single-selection fields, set the selection with the ``name`` attribute.
406 For multi-selection fields, set the selection though the ``names``
407 attribute.
409 This field is validated only against the active selection. If the
410 ``active`` attribute is `None` and the field is not optional, validation
411 will fail.
413 When saving a configuration with a ``ConfigChoiceField``, the entire set is
414 saved, as well as the active selection.
416 Examples
417 --------
418 While the ``typemap`` is shared by all instances of the field, each
419 instance of the field has its own instance of a particular sub-config type.
421 For example, ``AaaConfig`` is a config object
423 >>> from lsst.pex.config import Config, ConfigChoiceField, Field
424 >>> class AaaConfig(Config):
425 ... somefield = Field("doc", int)
426 ...
428 The ``MyConfig`` config has a ``ConfigChoiceField`` field called ``choice``
429 that maps the ``AaaConfig`` type to the ``"AAA"`` key:
431 >>> TYPEMAP = {"AAA", AaaConfig}
432 >>> class MyConfig(Config):
433 ... choice = ConfigChoiceField("doc for choice", TYPEMAP)
434 ...
436 Creating an instance of ``MyConfig``:
438 >>> instance = MyConfig()
440 Setting value of the field ``somefield`` on the "AAA" key of the ``choice``
441 field:
443 >>> instance.choice['AAA'].somefield = 5
445 **Selecting the active configuration**
447 Make the ``"AAA"`` key the active configuration value for the ``choice``
448 field:
450 >>> instance.choice = "AAA"
452 Alternatively, the last line can be written:
454 >>> instance.choice.name = "AAA"
456 (If the config instance allows multiple selections, you'd assign a sequence
457 to the ``names`` attribute instead.)
459 ``ConfigChoiceField`` instances also allow multiple values of the same
460 type:
462 >>> TYPEMAP["CCC"] = AaaConfig
463 >>> TYPEMAP["BBB"] = AaaConfig
464 """
466 instanceDictClass = ConfigInstanceDict
468 def __init__(self, doc, typemap, default=None, optional=False, multi=False, deprecated=None):
469 source = getStackFrame()
470 self._setup(
471 doc=doc,
472 dtype=self.instanceDictClass,
473 default=default,
474 check=None,
475 optional=optional,
476 source=source,
477 deprecated=deprecated,
478 )
479 self.typemap = typemap
480 self.multi = multi
482 def _getOrMake(self, instance, label="default"):
483 instanceDict = instance._storage.get(self.name)
484 if instanceDict is None:
485 at = getCallStack(1)
486 instanceDict = self.dtype(instance, self)
487 instanceDict.__doc__ = self.doc
488 instance._storage[self.name] = instanceDict
489 history = instance._history.setdefault(self.name, [])
490 history.append(("Initialized from defaults", at, label))
492 return instanceDict
494 def __get__(self, instance, owner=None):
495 if instance is None or not isinstance(instance, Config):
496 return self
497 else:
498 return self._getOrMake(instance)
500 def __set__(self, instance, value, at=None, label="assignment"):
501 if instance._frozen:
502 raise FieldValidationError(self, instance, "Cannot modify a frozen Config")
503 if at is None:
504 at = getCallStack()
505 instanceDict = self._getOrMake(instance)
506 if isinstance(value, self.instanceDictClass):
507 for k, v in value.items():
508 instanceDict.__setitem__(k, v, at=at, label=label)
509 instanceDict._setSelection(value._selection, at=at, label=label)
511 else:
512 instanceDict._setSelection(value, at=at, label=label)
514 def rename(self, instance):
515 instanceDict = self.__get__(instance)
516 fullname = _joinNamePath(instance._name, self.name)
517 instanceDict._rename(fullname)
519 def validate(self, instance):
520 instanceDict = self.__get__(instance)
521 if instanceDict.active is None and not self.optional:
522 msg = "Required field cannot be None"
523 raise FieldValidationError(self, instance, msg)
524 elif instanceDict.active is not None:
525 if self.multi:
526 for a in instanceDict.active:
527 a.validate()
528 else:
529 instanceDict.active.validate()
531 def toDict(self, instance):
532 instanceDict = self.__get__(instance)
534 dict_ = {}
535 if self.multi:
536 dict_["names"] = instanceDict.names
537 else:
538 dict_["name"] = instanceDict.name
540 values = {}
541 for k, v in instanceDict.items():
542 values[k] = v.toDict()
543 dict_["values"] = values
545 return dict_
547 def freeze(self, instance):
548 instanceDict = self.__get__(instance)
549 instanceDict.freeze()
550 for v in instanceDict.values():
551 v.freeze()
553 def _collectImports(self, instance, imports):
554 instanceDict = self.__get__(instance)
555 for config in instanceDict.values():
556 config._collectImports()
557 imports |= config._imports
559 def save(self, outfile, instance):
560 instanceDict = self.__get__(instance)
561 fullname = _joinNamePath(instance._name, self.name)
562 for v in instanceDict.values():
563 v._save(outfile)
564 if self.multi:
565 outfile.write("{}.names={!r}\n".format(fullname, instanceDict.names))
566 else:
567 outfile.write("{}.name={!r}\n".format(fullname, instanceDict.name))
569 def __deepcopy__(self, memo):
570 """Customize deep-copying, because we always want a reference to the
571 original typemap.
573 WARNING: this must be overridden by subclasses if they change the
574 constructor signature!
575 """
576 other = type(self)(
577 doc=self.doc,
578 typemap=self.typemap,
579 default=copy.deepcopy(self.default),
580 optional=self.optional,
581 multi=self.multi,
582 )
583 other.source = self.source
584 return other
586 def _compare(self, instance1, instance2, shortcut, rtol, atol, output):
587 """Compare two fields for equality.
589 Used by `lsst.pex.ConfigChoiceField.compare`.
591 Parameters
592 ----------
593 instance1 : `lsst.pex.config.Config`
594 Left-hand side config instance to compare.
595 instance2 : `lsst.pex.config.Config`
596 Right-hand side config instance to compare.
597 shortcut : `bool`
598 If `True`, this function returns as soon as an inequality if found.
599 rtol : `float`
600 Relative tolerance for floating point comparisons.
601 atol : `float`
602 Absolute tolerance for floating point comparisons.
603 output : callable
604 A callable that takes a string, used (possibly repeatedly) to
605 report inequalities.
607 Returns
608 -------
609 isEqual : bool
610 `True` if the fields are equal, `False` otherwise.
612 Notes
613 -----
614 Only the selected configurations are compared, as the parameters of any
615 others do not matter.
617 Floating point comparisons are performed by `numpy.allclose`.
618 """
619 d1 = getattr(instance1, self.name)
620 d2 = getattr(instance2, self.name)
621 name = getComparisonName(
622 _joinNamePath(instance1._name, self.name), _joinNamePath(instance2._name, self.name)
623 )
624 if not compareScalars("selection for %s" % name, d1._selection, d2._selection, output=output):
625 return False
626 if d1._selection is None:
627 return True
628 if self.multi:
629 nested = [(k, d1[k], d2[k]) for k in d1._selection]
630 else:
631 nested = [(d1._selection, d1[d1._selection], d2[d1._selection])]
632 equal = True
633 for k, c1, c2 in nested:
634 result = compareConfigs(
635 "%s[%r]" % (name, k), c1, c2, shortcut=shortcut, rtol=rtol, atol=atol, output=output
636 )
637 if not result and shortcut:
638 return False
639 equal = equal and result
640 return equal