Coverage for python / lsst / pex / config / configChoiceField.py: 17%
289 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-30 08:43 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-30 08:43 +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
33from typing import Any, ForwardRef, overload
35from .callStack import getCallStack, getStackFrame
36from .comparison import compareConfigs, compareScalars, getComparisonName
37from .config import Config, Field, FieldValidationError, UnexpectedProxyUsageError, _joinNamePath, _typeStr
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 : `~typing.Any`
49 The selected key.
50 at : `list` of `~lsst.pex.config.callStack.StackFrame` or `None`, 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__(
66 self,
67 dict_: ConfigInstanceDict,
68 value: Any,
69 at=None,
70 label: str = "assignment",
71 setHistory: bool = True,
72 ):
73 if at is None:
74 at = getCallStack()
75 self._dict = dict_
76 self._field = self._dict._field
77 self._history = self._dict._config._history.setdefault(self._field.name, [])
78 if value is not None:
79 try:
80 for v in value:
81 if v not in self._dict:
82 # invoke __getitem__ to ensure it's present
83 self._dict.__getitem__(v, at=at)
84 except TypeError as e:
85 msg = f"Value {value} is of incorrect type {_typeStr(value)}. Sequence type expected"
86 raise FieldValidationError(self._field, self._dict._config, msg) from e
87 self._set = set(value)
88 else:
89 self._set = set()
91 if setHistory:
92 self._history.append((f"Set selection to {self}", at, label))
94 def add(self, value, at=None):
95 """Add a value to the selected set.
97 Parameters
98 ----------
99 value : `~typing.Any`
100 The selected key.
101 at : `list` of `~lsst.pex.config.callStack.StackFrame` or `None`,\
102 optional
103 Stack frames for history recording.
104 """
105 if self._dict._config._frozen:
106 raise FieldValidationError(self._field, self._config, "Cannot modify a frozen Config")
108 if at is None:
109 at = getCallStack()
111 if value not in self._dict:
112 # invoke __getitem__ to make sure it's present
113 self._dict.__getitem__(value, at=at)
115 self._history.append((f"added {value} to selection", at, "selection"))
116 self._set.add(value)
118 def discard(self, value, at=None):
119 """Discard a value from the selected set.
121 Parameters
122 ----------
123 value : `~typing.Any`
124 The selected key.
125 at : `list` of `~lsst.pex.config.callStack.StackFrame` or `None`,\
126 optional
127 Stack frames for history recording.
128 """
129 if self._dict._config._frozen:
130 raise FieldValidationError(self._field, self._dict._config, "Cannot modify a frozen Config")
132 if value not in self._dict:
133 return
135 if at is None:
136 at = getCallStack()
138 self._history.append((f"removed {value} from selection", at, "selection"))
139 self._set.discard(value)
141 def __len__(self):
142 return len(self._set)
144 def __iter__(self):
145 return iter(self._set)
147 def __contains__(self, value):
148 return value in self._set
150 def __repr__(self):
151 return repr(list(self._set))
153 def __str__(self):
154 return str(list(self._set))
156 def __reduce__(self):
157 raise UnexpectedProxyUsageError(
158 f"Proxy container for config field {self._field.name} cannot "
159 "be pickled; it should be converted to a built-in container before "
160 "being assigned to other objects or variables."
161 )
164class ConfigInstanceDict(collections.abc.Mapping[str, Config]):
165 """Dictionary of instantiated configs, used to populate a
166 `~lsst.pex.config.ConfigChoiceField`.
168 Parameters
169 ----------
170 config : `lsst.pex.config.Config`
171 A configuration instance.
172 field : `lsst.pex.config.Field`-type
173 A configuration field. Note that the `lsst.pex.config.Field.fieldmap`
174 attribute must provide key-based access to configuration classes,
175 (that is, ``typemap[name]``).
176 """
178 def __init__(self, config: Config, field: ConfigChoiceField):
179 collections.abc.Mapping.__init__(self)
180 self._dict: dict[str, Config] = {}
181 self._selection = None
182 self._config = config
183 self._field = field
184 self._history = config._history.setdefault(field.name, [])
185 self.__doc__ = field.doc
186 self._typemap = None
188 def _copy(self, config: Config) -> ConfigInstanceDict:
189 result = type(self)(config, self._field)
190 result._dict = {k: v.copy() for k, v in self._dict.items()}
191 result._history.extend(self._history)
192 result._typemap = self._typemap
193 if self._selection is not None:
194 if self._field.multi:
195 result._selection = SelectionSet(self, self._selection._set)
196 else:
197 result._selection = self._selection
198 return result
200 @property
201 def types(self):
202 return self._typemap if self._typemap is not None else self._field.typemap
204 def __contains__(self, k):
205 return k in self.types
207 def __len__(self):
208 return len(self.types)
210 def __iter__(self):
211 return iter(self.types)
213 def _setSelection(self, value, at=None, label="assignment"):
214 if self._config._frozen:
215 raise FieldValidationError(self._field, self._config, "Cannot modify a frozen Config")
217 if at is None:
218 at = getCallStack(1)
220 if value is None:
221 self._selection = None
222 elif self._field.multi:
223 self._selection = SelectionSet(self, value, setHistory=False)
224 else:
225 if value not in self._dict:
226 self.__getitem__(value, at=at) # just invoke __getitem__ to make sure it's present
227 self._selection = value
228 self._history.append((value, at, label))
230 def _getNames(self):
231 if not self._field.multi:
232 raise FieldValidationError(
233 self._field, self._config, "Single-selection field has no attribute 'names'"
234 )
235 return self._selection
237 def _setNames(self, value):
238 if not self._field.multi:
239 raise FieldValidationError(
240 self._field, self._config, "Single-selection field has no attribute 'names'"
241 )
242 self._setSelection(value)
244 def _delNames(self):
245 if not self._field.multi:
246 raise FieldValidationError(
247 self._field, self._config, "Single-selection field has no attribute 'names'"
248 )
249 self._selection = None
251 def _getName(self):
252 if self._field.multi:
253 raise FieldValidationError(
254 self._field, self._config, "Multi-selection field has no attribute 'name'"
255 )
256 return self._selection
258 def _setName(self, value):
259 if self._field.multi:
260 raise FieldValidationError(
261 self._field, self._config, "Multi-selection field has no attribute 'name'"
262 )
263 self._setSelection(value)
265 def _delName(self):
266 if self._field.multi:
267 raise FieldValidationError(
268 self._field, self._config, "Multi-selection field has no attribute 'name'"
269 )
270 self._selection = None
272 names = property(_getNames, _setNames, _delNames)
273 """List of names of active items in a multi-selection
274 ``ConfigInstanceDict``. Disabled in a single-selection ``_Registry``; use
275 the `name` attribute instead.
276 """
278 name = property(_getName, _setName, _delName)
279 """Name of the active item in a single-selection ``ConfigInstanceDict``.
280 Disabled in a multi-selection ``_Registry``; use the ``names`` attribute
281 instead.
282 """
284 def _getActive(self):
285 if self._selection is None:
286 return None
288 if self._field.multi:
289 return [self[c] for c in self._selection]
290 else:
291 return self[self._selection]
293 active = property(_getActive)
294 """The selected items.
296 For multi-selection, this is equivalent to: ``[self[name] for name in
297 self.names]``. For single-selection, this is equivalent to: ``self[name]``.
298 """
300 def __getitem__(self, k, at=None, label="default"):
301 try:
302 value = self._dict[k]
303 except KeyError:
304 try:
305 dtype = self.types[k]
306 except Exception as e:
307 raise FieldValidationError(
308 self._field, self._config, f"Unknown key {k!r} in Registry/ConfigChoiceField"
309 ) from e
310 name = _joinNamePath(self._config._name, self._field.name, k)
311 if at is None:
312 at = getCallStack()
313 at.insert(0, dtype._source)
314 value = self._dict.setdefault(k, dtype(__name=name, __at=at, __label=label))
315 return value
317 def __setitem__(self, k, value, at=None, label="assignment"):
318 if self._config._frozen:
319 raise FieldValidationError(self._field, self._config, "Cannot modify a frozen Config")
321 try:
322 dtype = self.types[k]
323 except Exception as e:
324 raise FieldValidationError(self._field, self._config, f"Unknown key {k!r}") from e
326 if value != dtype and type(value) is not dtype:
327 msg = (
328 f"Value {value} at key {k} is of incorrect type {_typeStr(value)}. "
329 f"Expected type {_typeStr(dtype)}"
330 )
331 raise FieldValidationError(self._field, self._config, msg)
333 if at is None:
334 at = getCallStack()
335 name = _joinNamePath(self._config._name, self._field.name, k)
336 oldValue = self._dict.get(k, None)
337 if oldValue is None:
338 if value == dtype:
339 self._dict[k] = value(__name=name, __at=at, __label=label)
340 else:
341 self._dict[k] = dtype(__name=name, __at=at, __label=label, **value._storage)
342 else:
343 if value == dtype:
344 value = value()
345 oldValue.update(__at=at, __label=label, **value._storage)
347 def _rename(self, fullname):
348 for k, v in self._dict.items():
349 v._rename(_joinNamePath(name=fullname, index=k))
351 def __setattr__(self, attr, value, at=None, label="assignment"):
352 if hasattr(getattr(self.__class__, attr, None), "__set__"):
353 # This allows properties to work.
354 object.__setattr__(self, attr, value)
355 elif attr in self.__dict__ or attr in [
356 "_history",
357 "_field",
358 "_config",
359 "_dict",
360 "_selection",
361 "__doc__",
362 "_typemap",
363 ]:
364 # This allows specific private attributes to work.
365 object.__setattr__(self, attr, value)
366 else:
367 # We throw everything else.
368 msg = f"{_typeStr(self._field)} has no attribute {attr}"
369 raise FieldValidationError(self._field, self._config, msg)
371 def freeze(self):
372 """Freeze the config.
374 Invoking this freeze method will create a local copy of the field
375 attribute's typemap. This decouples this instance dict from the
376 underlying objects type map ensuring that and subsequent changes to the
377 typemap will not be reflected in this instance (i.e imports adding
378 additional registry entries).
379 """
380 if self._typemap is None:
381 self._typemap = copy.deepcopy(self.types)
383 def __reduce__(self):
384 raise UnexpectedProxyUsageError(
385 f"Proxy container for config field {self._field.name} cannot "
386 "be pickled; it should be converted to a built-in container before "
387 "being assigned to other objects or variables."
388 )
391class ConfigChoiceField(Field[ConfigInstanceDict]):
392 """A configuration field (`~lsst.pex.config.Field` subclass) that allows a
393 user to choose from a set of `~lsst.pex.config.Config` types.
395 Parameters
396 ----------
397 doc : `str`
398 Documentation string for the field.
399 typemap : `dict`-like
400 A mapping between keys and `~lsst.pex.config.Config`-types as values.
401 See *Examples* for details.
402 default : `str`, optional
403 The default configuration name.
404 optional : `bool`, optional
405 When `False`, `lsst.pex.config.Config.validate` will fail if the
406 field's value is `None`.
407 multi : `bool`, optional
408 If `True`, the field allows multiple selections. In this case, set the
409 selections by assigning a sequence to the ``names`` attribute of the
410 field.
412 If `False`, the field allows only a single selection. In this case,
413 set the active config by assigning the config's key from the
414 ``typemap`` to the field's ``name`` attribute (see *Examples*).
415 deprecated : `None` or `str`, optional
416 A description of why this Field is deprecated, including removal date.
417 If not `None`, the string is appended to the docstring for this Field.
419 See Also
420 --------
421 ChoiceField
422 ConfigDictField
423 ConfigField
424 ConfigurableField
425 DictField
426 Field
427 ListField
428 RangeField
429 RegistryField
431 Notes
432 -----
433 ``ConfigChoiceField`` instances can allow either single selections or
434 multiple selections, depending on the ``multi`` parameter. For
435 single-selection fields, set the selection with the ``name`` attribute.
436 For multi-selection fields, set the selection though the ``names``
437 attribute.
439 This field is validated only against the active selection. If the
440 ``active`` attribute is `None` and the field is not optional, validation
441 will fail.
443 When saving a configuration with a ``ConfigChoiceField``, the entire set is
444 saved, as well as the active selection.
446 Examples
447 --------
448 While the ``typemap`` is shared by all instances of the field, each
449 instance of the field has its own instance of a particular sub-config type.
451 For example, ``AaaConfig`` is a config object
453 >>> from lsst.pex.config import Config, ConfigChoiceField, Field
454 >>> class AaaConfig(Config):
455 ... somefield = Field("doc", int)
457 The ``MyConfig`` config has a ``ConfigChoiceField`` field called ``choice``
458 that maps the ``AaaConfig`` type to the ``"AAA"`` key:
460 >>> TYPEMAP = {"AAA", AaaConfig}
461 >>> class MyConfig(Config):
462 ... choice = ConfigChoiceField("doc for choice", TYPEMAP)
464 Creating an instance of ``MyConfig``:
466 >>> instance = MyConfig()
468 Setting value of the field ``somefield`` on the "AAA" key of the ``choice``
469 field:
471 >>> instance.choice["AAA"].somefield = 5
473 **Selecting the active configuration**
475 Make the ``"AAA"`` key the active configuration value for the ``choice``
476 field:
478 >>> instance.choice = "AAA"
480 Alternatively, the last line can be written:
482 >>> instance.choice.name = "AAA"
484 (If the config instance allows multiple selections, you'd assign a sequence
485 to the ``names`` attribute instead.)
487 ``ConfigChoiceField`` instances also allow multiple values of the same
488 type:
490 >>> TYPEMAP["CCC"] = AaaConfig
491 >>> TYPEMAP["BBB"] = AaaConfig
492 """
494 instanceDictClass = ConfigInstanceDict
496 def __init__(self, doc, typemap, default=None, optional=False, multi=False, deprecated=None):
497 source = getStackFrame()
498 self._setup(
499 doc=doc,
500 dtype=self.instanceDictClass,
501 default=default,
502 check=None,
503 optional=optional,
504 source=source,
505 deprecated=deprecated,
506 )
507 self.typemap = typemap
508 self.multi = multi
510 def __class_getitem__(cls, params: tuple[type, ...] | type | ForwardRef):
511 raise ValueError("ConfigChoiceField does not support typing argument")
513 def _getOrMake(self, instance, label="default"):
514 instanceDict = instance._storage.get(self.name)
515 if instanceDict is None:
516 at = getCallStack(1)
517 instanceDict = self.dtype(instance, self)
518 instanceDict.__doc__ = self.doc
519 instance._storage[self.name] = instanceDict
520 history = instance._history.setdefault(self.name, [])
521 history.append(("Initialized from defaults", at, label))
523 return instanceDict
525 @overload
526 def __get__(
527 self, instance: None, owner: Any = None, at: Any = None, label: str = "default"
528 ) -> ConfigChoiceField: ...
530 @overload
531 def __get__(
532 self, instance: Config, owner: Any = None, at: Any = None, label: str = "default"
533 ) -> ConfigInstanceDict: ...
535 def __get__(self, instance, owner=None, at=None, label="default"):
536 if instance is None or not isinstance(instance, Config):
537 return self
538 else:
539 return self._getOrMake(instance)
541 def __set__(
542 self, instance: Config, value: ConfigInstanceDict | None, at: Any = None, label: str = "assignment"
543 ) -> None:
544 if instance._frozen:
545 raise FieldValidationError(self, instance, "Cannot modify a frozen Config")
546 if at is None:
547 at = getCallStack()
548 instanceDict = self._getOrMake(instance)
549 if isinstance(value, self.instanceDictClass):
550 for k, v in value.items():
551 instanceDict.__setitem__(k, v, at=at, label=label)
552 instanceDict._setSelection(value._selection, at=at, label=label)
554 else:
555 instanceDict._setSelection(value, at=at, label=label)
557 def _copy_storage(self, old: Config, new: Config) -> Any:
558 instance_dict: ConfigInstanceDict | None = old._storage.get(self.name)
559 if instance_dict is not None:
560 return instance_dict._copy(new)
561 else:
562 return None
564 def rename(self, instance):
565 instanceDict = self.__get__(instance)
566 fullname = _joinNamePath(instance._name, self.name)
567 instanceDict._rename(fullname)
569 def validate(self, instance):
570 instanceDict = self.__get__(instance)
571 if instanceDict.active is None and not self.optional:
572 msg = "Required field cannot be None"
573 raise FieldValidationError(self, instance, msg)
574 elif instanceDict.active is not None:
575 if self.multi:
576 for a in instanceDict.active:
577 a.validate()
578 else:
579 instanceDict.active.validate()
581 def toDict(self, instance):
582 instanceDict = self.__get__(instance)
584 dict_ = {}
585 if self.multi:
586 dict_["names"] = instanceDict.names
587 else:
588 dict_["name"] = instanceDict.name
590 values = {}
591 for k, v in instanceDict.items():
592 values[k] = v.toDict()
593 dict_["values"] = values
595 return dict_
597 def freeze(self, instance):
598 instanceDict = self.__get__(instance)
599 instanceDict.freeze()
600 for v in instanceDict.values():
601 v.freeze()
603 def _collectImports(self, instance, imports):
604 instanceDict = self.__get__(instance)
605 for config in instanceDict.values():
606 config._collectImports()
607 imports |= config._imports
609 def save(self, outfile, instance):
610 instanceDict = self.__get__(instance)
611 fullname = _joinNamePath(instance._name, self.name)
612 for v in instanceDict.values():
613 v._save(outfile)
614 if self.multi:
615 outfile.write(f"{fullname}.names={sorted(instanceDict.names)!r}\n")
616 else:
617 outfile.write(f"{fullname}.name={instanceDict.name!r}\n")
619 def __deepcopy__(self, memo):
620 """Customize deep-copying, because we always want a reference to the
621 original typemap.
623 WARNING: this must be overridden by subclasses if they change the
624 constructor signature!
625 """
626 other = type(self)(
627 doc=self.doc,
628 typemap=self.typemap,
629 default=copy.deepcopy(self.default),
630 optional=self.optional,
631 multi=self.multi,
632 )
633 other.source = self.source
634 return other
636 def _compare(self, instance1, instance2, shortcut, rtol, atol, output):
637 """Compare two fields for equality.
639 Used by `lsst.pex.ConfigChoiceField.compare`.
641 Parameters
642 ----------
643 instance1 : `lsst.pex.config.Config`
644 Left-hand side config instance to compare.
645 instance2 : `lsst.pex.config.Config`
646 Right-hand side config instance to compare.
647 shortcut : `bool`
648 If `True`, this function returns as soon as an inequality if found.
649 rtol : `float`
650 Relative tolerance for floating point comparisons.
651 atol : `float`
652 Absolute tolerance for floating point comparisons.
653 output : `collections.abc.Callable`
654 A callable that takes a string, used (possibly repeatedly) to
655 report inequalities.
657 Returns
658 -------
659 isEqual : bool
660 `True` if the fields are equal, `False` otherwise.
662 Notes
663 -----
664 Only the selected configurations are compared, as the parameters of any
665 others do not matter.
667 Floating point comparisons are performed by `numpy.allclose`.
668 """
669 d1 = getattr(instance1, self.name)
670 d2 = getattr(instance2, self.name)
671 name = getComparisonName(
672 _joinNamePath(instance1._name, self.name), _joinNamePath(instance2._name, self.name)
673 )
674 if not compareScalars(f"selection for {name}", d1._selection, d2._selection, output=output):
675 return False
676 if d1._selection is None:
677 return True
678 if self.multi:
679 nested = [(k, d1[k], d2[k]) for k in d1._selection]
680 else:
681 nested = [(d1._selection, d1[d1._selection], d2[d1._selection])]
682 equal = True
683 for k, c1, c2 in nested:
684 result = compareConfigs(
685 f"{name}[{k!r}]", c1, c2, shortcut=shortcut, rtol=rtol, atol=atol, output=output
686 )
687 if not result and shortcut:
688 return False
689 equal = equal and result
690 return equal