Coverage for python/lsst/pex/config/configChoiceField.py: 18%
286 statements
« prev ^ index » next coverage.py v7.2.7, created at 2023-07-06 09:49 +0000
« prev ^ index » next coverage.py v7.2.7, created at 2023-07-06 09:49 +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/>.
27from __future__ import annotations
29__all__ = ["ConfigChoiceField"]
31import collections.abc
32import copy
33import weakref
34from typing import Any, ForwardRef, overload
36from .callStack import getCallStack, getStackFrame
37from .comparison import compareConfigs, compareScalars, getComparisonName
38from .config import Config, Field, FieldValidationError, UnexpectedProxyUsageError, _joinNamePath, _typeStr
41class SelectionSet(collections.abc.MutableSet):
42 """A mutable set class that tracks the selection of multi-select
43 `~lsst.pex.config.ConfigChoiceField` objects.
45 Parameters
46 ----------
47 dict_ : `ConfigInstanceDict`
48 The dictionary of instantiated configs.
49 value
50 The selected key.
51 at : `lsst.pex.config.callStack.StackFrame`, optional
52 The call stack when the selection was made.
53 label : `str`, optional
54 Label for history tracking.
55 setHistory : `bool`, optional
56 Add this even to the history, if `True`.
58 Notes
59 -----
60 This class allows a user of a multi-select
61 `~lsst.pex.config.ConfigChoiceField` to add or discard items from the set
62 of active configs. Each change to the selection is tracked in the field's
63 history.
64 """
66 def __init__(self, dict_, value, at=None, label="assignment", setHistory=True):
67 if at is None:
68 at = getCallStack()
69 self._dict = dict_
70 self._field = self._dict._field
71 self._config_ = weakref.ref(self._dict._config)
72 self.__history = self._config._history.setdefault(self._field.name, [])
73 if value is not None:
74 try:
75 for v in value:
76 if v not in self._dict:
77 # invoke __getitem__ to ensure it's present
78 self._dict.__getitem__(v, at=at)
79 except TypeError:
80 msg = f"Value {value} is of incorrect type {_typeStr(value)}. Sequence type expected"
81 raise FieldValidationError(self._field, self._config, msg)
82 self._set = set(value)
83 else:
84 self._set = set()
86 if setHistory:
87 self.__history.append(("Set selection to %s" % self, at, label))
89 @property
90 def _config(self) -> Config:
91 # Config Fields should never outlive their config class instance
92 # assert that as such here
93 assert self._config_() is not None
94 return self._config_()
96 def add(self, value, at=None):
97 """Add a value to the selected set."""
98 if self._config._frozen:
99 raise FieldValidationError(self._field, self._config, "Cannot modify a frozen Config")
101 if at is None:
102 at = getCallStack()
104 if value not in self._dict:
105 # invoke __getitem__ to make sure it's present
106 self._dict.__getitem__(value, at=at)
108 self.__history.append(("added %s to selection" % value, at, "selection"))
109 self._set.add(value)
111 def discard(self, value, at=None):
112 """Discard a value from the selected set."""
113 if self._config._frozen:
114 raise FieldValidationError(self._field, self._config, "Cannot modify a frozen Config")
116 if value not in self._dict:
117 return
119 if at is None:
120 at = getCallStack()
122 self.__history.append(("removed %s from selection" % value, at, "selection"))
123 self._set.discard(value)
125 def __len__(self):
126 return len(self._set)
128 def __iter__(self):
129 return iter(self._set)
131 def __contains__(self, value):
132 return value in self._set
134 def __repr__(self):
135 return repr(list(self._set))
137 def __str__(self):
138 return str(list(self._set))
140 def __reduce__(self):
141 raise UnexpectedProxyUsageError(
142 f"Proxy container for config field {self._field.name} cannot "
143 "be pickled; it should be converted to a built-in container before "
144 "being assigned to other objects or variables."
145 )
148class ConfigInstanceDict(collections.abc.Mapping[str, Config]):
149 """Dictionary of instantiated configs, used to populate a
150 `~lsst.pex.config.ConfigChoiceField`.
152 Parameters
153 ----------
154 config : `lsst.pex.config.Config`
155 A configuration instance.
156 field : `lsst.pex.config.Field`-type
157 A configuration field. Note that the `lsst.pex.config.Field.fieldmap`
158 attribute must provide key-based access to configuration classes,
159 (that is, ``typemap[name]``).
160 """
162 def __init__(self, config, field):
163 collections.abc.Mapping.__init__(self)
164 self._dict = {}
165 self._selection = None
166 self._config = config
167 self._field = field
168 self._history = config._history.setdefault(field.name, [])
169 self.__doc__ = field.doc
170 self._typemap = None
172 @property
173 def types(self):
174 return self._typemap if self._typemap is not None else self._field.typemap
176 def __contains__(self, k):
177 return k in self.types
179 def __len__(self):
180 return len(self.types)
182 def __iter__(self):
183 return iter(self.types)
185 def _setSelection(self, value, at=None, label="assignment"):
186 if self._config._frozen:
187 raise FieldValidationError(self._field, self._config, "Cannot modify a frozen Config")
189 if at is None:
190 at = getCallStack(1)
192 if value is None:
193 self._selection = None
194 elif self._field.multi:
195 self._selection = SelectionSet(self, value, setHistory=False)
196 else:
197 if value not in self._dict:
198 self.__getitem__(value, at=at) # just invoke __getitem__ to make sure it's present
199 self._selection = value
200 self._history.append((value, at, label))
202 def _getNames(self):
203 if not self._field.multi:
204 raise FieldValidationError(
205 self._field, self._config, "Single-selection field has no attribute 'names'"
206 )
207 return self._selection
209 def _setNames(self, value):
210 if not self._field.multi:
211 raise FieldValidationError(
212 self._field, self._config, "Single-selection field has no attribute 'names'"
213 )
214 self._setSelection(value)
216 def _delNames(self):
217 if not self._field.multi:
218 raise FieldValidationError(
219 self._field, self._config, "Single-selection field has no attribute 'names'"
220 )
221 self._selection = None
223 def _getName(self):
224 if self._field.multi:
225 raise FieldValidationError(
226 self._field, self._config, "Multi-selection field has no attribute 'name'"
227 )
228 return self._selection
230 def _setName(self, value):
231 if self._field.multi:
232 raise FieldValidationError(
233 self._field, self._config, "Multi-selection field has no attribute 'name'"
234 )
235 self._setSelection(value)
237 def _delName(self):
238 if self._field.multi:
239 raise FieldValidationError(
240 self._field, self._config, "Multi-selection field has no attribute 'name'"
241 )
242 self._selection = None
244 names = property(_getNames, _setNames, _delNames)
245 """List of names of active items in a multi-selection
246 ``ConfigInstanceDict``. Disabled in a single-selection ``_Registry``; use
247 the `name` attribute instead.
248 """
250 name = property(_getName, _setName, _delName)
251 """Name of the active item in a single-selection ``ConfigInstanceDict``.
252 Disabled in a multi-selection ``_Registry``; use the ``names`` attribute
253 instead.
254 """
256 def _getActive(self):
257 if self._selection is None:
258 return None
260 if self._field.multi:
261 return [self[c] for c in self._selection]
262 else:
263 return self[self._selection]
265 active = property(_getActive)
266 """The selected items.
268 For multi-selection, this is equivalent to: ``[self[name] for name in
269 self.names]``. For single-selection, this is equivalent to: ``self[name]``.
270 """
272 def __getitem__(self, k, at=None, label="default"):
273 try:
274 value = self._dict[k]
275 except KeyError:
276 try:
277 dtype = self.types[k]
278 except Exception:
279 raise FieldValidationError(
280 self._field, self._config, "Unknown key %r in Registry/ConfigChoiceField" % k
281 )
282 name = _joinNamePath(self._config._name, self._field.name, k)
283 if at is None:
284 at = getCallStack()
285 at.insert(0, dtype._source)
286 value = self._dict.setdefault(k, dtype(__name=name, __at=at, __label=label))
287 return value
289 def __setitem__(self, k, value, at=None, label="assignment"):
290 if self._config._frozen:
291 raise FieldValidationError(self._field, self._config, "Cannot modify a frozen Config")
293 try:
294 dtype = self.types[k]
295 except Exception:
296 raise FieldValidationError(self._field, self._config, "Unknown key %r" % k)
298 if value != dtype and type(value) != dtype:
299 msg = "Value {} at key {} is of incorrect type {}. Expected type {}".format(
300 value,
301 k,
302 _typeStr(value),
303 _typeStr(dtype),
304 )
305 raise FieldValidationError(self._field, self._config, msg)
307 if at is None:
308 at = getCallStack()
309 name = _joinNamePath(self._config._name, self._field.name, k)
310 oldValue = self._dict.get(k, None)
311 if oldValue is None:
312 if value == dtype:
313 self._dict[k] = value(__name=name, __at=at, __label=label)
314 else:
315 self._dict[k] = dtype(__name=name, __at=at, __label=label, **value._storage)
316 else:
317 if value == dtype:
318 value = value()
319 oldValue.update(__at=at, __label=label, **value._storage)
321 def _rename(self, fullname):
322 for k, v in self._dict.items():
323 v._rename(_joinNamePath(name=fullname, index=k))
325 def __setattr__(self, attr, value, at=None, label="assignment"):
326 if hasattr(getattr(self.__class__, attr, None), "__set__"):
327 # This allows properties to work.
328 object.__setattr__(self, attr, value)
329 elif attr in self.__dict__ or attr in [
330 "_history",
331 "_field",
332 "_config",
333 "_dict",
334 "_selection",
335 "__doc__",
336 "_typemap",
337 ]:
338 # This allows specific private attributes to work.
339 object.__setattr__(self, attr, value)
340 else:
341 # We throw everything else.
342 msg = f"{_typeStr(self._field)} has no attribute {attr}"
343 raise FieldValidationError(self._field, self._config, msg)
345 def freeze(self):
346 """Freeze the config.
348 Invoking this freeze method will create a local copy of the field
349 attribute's typemap. This decouples this instance dict from the
350 underlying objects type map ensuring that and subsequent changes to the
351 typemap will not be reflected in this instance (i.e imports adding
352 additional registry entries).
353 """
354 if self._typemap is None:
355 self._typemap = copy.deepcopy(self.types)
357 def __reduce__(self):
358 raise UnexpectedProxyUsageError(
359 f"Proxy container for config field {self._field.name} cannot "
360 "be pickled; it should be converted to a built-in container before "
361 "being assigned to other objects or variables."
362 )
365class ConfigChoiceField(Field[ConfigInstanceDict]):
366 """A configuration field (`~lsst.pex.config.Field` subclass) that allows a
367 user to choose from a set of `~lsst.pex.config.Config` types.
369 Parameters
370 ----------
371 doc : `str`
372 Documentation string for the field.
373 typemap : `dict`-like
374 A mapping between keys and `~lsst.pex.config.Config`-types as values.
375 See *Examples* for details.
376 default : `str`, optional
377 The default configuration name.
378 optional : `bool`, optional
379 When `False`, `lsst.pex.config.Config.validate` will fail if the
380 field's value is `None`.
381 multi : `bool`, optional
382 If `True`, the field allows multiple selections. In this case, set the
383 selections by assigning a sequence to the ``names`` attribute of the
384 field.
386 If `False`, the field allows only a single selection. In this case,
387 set the active config by assigning the config's key from the
388 ``typemap`` to the field's ``name`` attribute (see *Examples*).
389 deprecated : None or `str`, optional
390 A description of why this Field is deprecated, including removal date.
391 If not None, the string is appended to the docstring for this Field.
393 See Also
394 --------
395 ChoiceField
396 ConfigDictField
397 ConfigField
398 ConfigurableField
399 DictField
400 Field
401 ListField
402 RangeField
403 RegistryField
405 Notes
406 -----
407 ``ConfigChoiceField`` instances can allow either single selections or
408 multiple selections, depending on the ``multi`` parameter. For
409 single-selection fields, set the selection with the ``name`` attribute.
410 For multi-selection fields, set the selection though the ``names``
411 attribute.
413 This field is validated only against the active selection. If the
414 ``active`` attribute is `None` and the field is not optional, validation
415 will fail.
417 When saving a configuration with a ``ConfigChoiceField``, the entire set is
418 saved, as well as the active selection.
420 Examples
421 --------
422 While the ``typemap`` is shared by all instances of the field, each
423 instance of the field has its own instance of a particular sub-config type.
425 For example, ``AaaConfig`` is a config object
427 >>> from lsst.pex.config import Config, ConfigChoiceField, Field
428 >>> class AaaConfig(Config):
429 ... somefield = Field("doc", int)
430 ...
432 The ``MyConfig`` config has a ``ConfigChoiceField`` field called ``choice``
433 that maps the ``AaaConfig`` type to the ``"AAA"`` key:
435 >>> TYPEMAP = {"AAA", AaaConfig}
436 >>> class MyConfig(Config):
437 ... choice = ConfigChoiceField("doc for choice", TYPEMAP)
438 ...
440 Creating an instance of ``MyConfig``:
442 >>> instance = MyConfig()
444 Setting value of the field ``somefield`` on the "AAA" key of the ``choice``
445 field:
447 >>> instance.choice['AAA'].somefield = 5
449 **Selecting the active configuration**
451 Make the ``"AAA"`` key the active configuration value for the ``choice``
452 field:
454 >>> instance.choice = "AAA"
456 Alternatively, the last line can be written:
458 >>> instance.choice.name = "AAA"
460 (If the config instance allows multiple selections, you'd assign a sequence
461 to the ``names`` attribute instead.)
463 ``ConfigChoiceField`` instances also allow multiple values of the same
464 type:
466 >>> TYPEMAP["CCC"] = AaaConfig
467 >>> TYPEMAP["BBB"] = AaaConfig
468 """
470 instanceDictClass = ConfigInstanceDict
472 def __init__(self, doc, typemap, default=None, optional=False, multi=False, deprecated=None):
473 source = getStackFrame()
474 self._setup(
475 doc=doc,
476 dtype=self.instanceDictClass,
477 default=default,
478 check=None,
479 optional=optional,
480 source=source,
481 deprecated=deprecated,
482 )
483 self.typemap = typemap
484 self.multi = multi
486 def __class_getitem__(cls, params: tuple[type, ...] | type | ForwardRef):
487 raise ValueError("ConfigChoiceField does not support typing argument")
489 def _getOrMake(self, instance, label="default"):
490 instanceDict = instance._storage.get(self.name)
491 if instanceDict is None:
492 at = getCallStack(1)
493 instanceDict = self.dtype(instance, self)
494 instanceDict.__doc__ = self.doc
495 instance._storage[self.name] = instanceDict
496 history = instance._history.setdefault(self.name, [])
497 history.append(("Initialized from defaults", at, label))
499 return instanceDict
501 @overload
502 def __get__(
503 self, instance: None, owner: Any = None, at: Any = None, label: str = "default"
504 ) -> ConfigChoiceField:
505 ...
507 @overload
508 def __get__(
509 self, instance: Config, owner: Any = None, at: Any = None, label: str = "default"
510 ) -> ConfigInstanceDict:
511 ...
513 def __get__(self, instance, owner=None, at=None, label="default"):
514 if instance is None or not isinstance(instance, Config):
515 return self
516 else:
517 return self._getOrMake(instance)
519 def __set__(
520 self, instance: Config, value: ConfigInstanceDict | None, at: Any = None, label: str = "assignment"
521 ) -> None:
522 if instance._frozen:
523 raise FieldValidationError(self, instance, "Cannot modify a frozen Config")
524 if at is None:
525 at = getCallStack()
526 instanceDict = self._getOrMake(instance)
527 if isinstance(value, self.instanceDictClass):
528 for k, v in value.items():
529 instanceDict.__setitem__(k, v, at=at, label=label)
530 instanceDict._setSelection(value._selection, at=at, label=label)
532 else:
533 instanceDict._setSelection(value, at=at, label=label)
535 def rename(self, instance):
536 instanceDict = self.__get__(instance)
537 fullname = _joinNamePath(instance._name, self.name)
538 instanceDict._rename(fullname)
540 def validate(self, instance):
541 instanceDict = self.__get__(instance)
542 if instanceDict.active is None and not self.optional:
543 msg = "Required field cannot be None"
544 raise FieldValidationError(self, instance, msg)
545 elif instanceDict.active is not None:
546 if self.multi:
547 for a in instanceDict.active:
548 a.validate()
549 else:
550 instanceDict.active.validate()
552 def toDict(self, instance):
553 instanceDict = self.__get__(instance)
555 dict_ = {}
556 if self.multi:
557 dict_["names"] = instanceDict.names
558 else:
559 dict_["name"] = instanceDict.name
561 values = {}
562 for k, v in instanceDict.items():
563 values[k] = v.toDict()
564 dict_["values"] = values
566 return dict_
568 def freeze(self, instance):
569 instanceDict = self.__get__(instance)
570 instanceDict.freeze()
571 for v in instanceDict.values():
572 v.freeze()
574 def _collectImports(self, instance, imports):
575 instanceDict = self.__get__(instance)
576 for config in instanceDict.values():
577 config._collectImports()
578 imports |= config._imports
580 def save(self, outfile, instance):
581 instanceDict = self.__get__(instance)
582 fullname = _joinNamePath(instance._name, self.name)
583 for v in instanceDict.values():
584 v._save(outfile)
585 if self.multi:
586 outfile.write(f"{fullname}.names={sorted(instanceDict.names)!r}\n")
587 else:
588 outfile.write(f"{fullname}.name={instanceDict.name!r}\n")
590 def __deepcopy__(self, memo):
591 """Customize deep-copying, because we always want a reference to the
592 original typemap.
594 WARNING: this must be overridden by subclasses if they change the
595 constructor signature!
596 """
597 other = type(self)(
598 doc=self.doc,
599 typemap=self.typemap,
600 default=copy.deepcopy(self.default),
601 optional=self.optional,
602 multi=self.multi,
603 )
604 other.source = self.source
605 return other
607 def _compare(self, instance1, instance2, shortcut, rtol, atol, output):
608 """Compare two fields for equality.
610 Used by `lsst.pex.ConfigChoiceField.compare`.
612 Parameters
613 ----------
614 instance1 : `lsst.pex.config.Config`
615 Left-hand side config instance to compare.
616 instance2 : `lsst.pex.config.Config`
617 Right-hand side config instance to compare.
618 shortcut : `bool`
619 If `True`, this function returns as soon as an inequality if found.
620 rtol : `float`
621 Relative tolerance for floating point comparisons.
622 atol : `float`
623 Absolute tolerance for floating point comparisons.
624 output : callable
625 A callable that takes a string, used (possibly repeatedly) to
626 report inequalities.
628 Returns
629 -------
630 isEqual : bool
631 `True` if the fields are equal, `False` otherwise.
633 Notes
634 -----
635 Only the selected configurations are compared, as the parameters of any
636 others do not matter.
638 Floating point comparisons are performed by `numpy.allclose`.
639 """
640 d1 = getattr(instance1, self.name)
641 d2 = getattr(instance2, self.name)
642 name = getComparisonName(
643 _joinNamePath(instance1._name, self.name), _joinNamePath(instance2._name, self.name)
644 )
645 if not compareScalars("selection for %s" % name, d1._selection, d2._selection, output=output):
646 return False
647 if d1._selection is None:
648 return True
649 if self.multi:
650 nested = [(k, d1[k], d2[k]) for k in d1._selection]
651 else:
652 nested = [(d1._selection, d1[d1._selection], d2[d1._selection])]
653 equal = True
654 for k, c1, c2 in nested:
655 result = compareConfigs(
656 f"{name}[{k!r}]", c1, c2, shortcut=shortcut, rtol=rtol, atol=atol, output=output
657 )
658 if not result and shortcut:
659 return False
660 equal = equal and result
661 return equal