LSST Applications g00d0e8bbd7+8c5ae1fdc5,g013ef56533+603670b062,g083dd6704c+2e189452a7,g199a45376c+0ba108daf9,g1c5cce2383+bc9f6103a4,g1fd858c14a+cd69ed4fc1,g210f2d0738+c4742f2e9e,g262e1987ae+612fa42d85,g29ae962dfc+83d129e820,g2cef7863aa+aef1011c0b,g35bb328faa+8c5ae1fdc5,g3fd5ace14f+5eaa884f2a,g47891489e3+e32160a944,g53246c7159+8c5ae1fdc5,g5b326b94bb+dcc56af22d,g64539dfbff+c4742f2e9e,g67b6fd64d1+e32160a944,g74acd417e5+c122e1277d,g786e29fd12+668abc6043,g87389fa792+8856018cbb,g88cb488625+47d24e4084,g89139ef638+e32160a944,g8d7436a09f+d14b4ff40a,g8ea07a8fe4+b212507b11,g90f42f885a+e1755607f3,g97be763408+34be90ab8c,g98df359435+ec1fa61bf1,ga2180abaac+8c5ae1fdc5,ga9e74d7ce9+43ac651df0,gbf99507273+8c5ae1fdc5,gc2a301910b+c4742f2e9e,gca7fc764a6+e32160a944,gd7ef33dd92+e32160a944,gdab6d2f7ff+c122e1277d,gdb1e2cdc75+1b18322db8,ge410e46f29+e32160a944,ge41e95a9f2+c4742f2e9e,geaed405ab2+0d91c11c6d,w.2025.44
LSST Data Management Base Package
Loading...
Searching...
No Matches
configChoiceField.py
Go to the documentation of this file.
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
28
29__all__ = ["ConfigChoiceField"]
30
31import collections.abc
32import copy
33from typing import Any, ForwardRef, overload
34
35from .callStack import getCallStack, getStackFrame
36from .comparison import compareConfigs, compareScalars, getComparisonName
37from .config import Config, Field, FieldValidationError, UnexpectedProxyUsageError, _joinNamePath, _typeStr
38
39
40class SelectionSet(collections.abc.MutableSet):
41 """A mutable set class that tracks the selection of multi-select
42 `~lsst.pex.config.ConfigChoiceField` objects.
43
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`.
56
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 """
64
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()
90
91 if setHistory:
92 self._history.append((f"Set selection to {self}", at, label))
93
94 def add(self, value, at=None):
95 """Add a value to the selected set.
96
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")
107
108 if at is None:
109 at = getCallStack()
110
111 if value not in self._dict:
112 # invoke __getitem__ to make sure it's present
113 self._dict.__getitem__(value, at=at)
114
115 self._history.append((f"added {value} to selection", at, "selection"))
116 self._set.add(value)
117
118 def discard(self, value, at=None):
119 """Discard a value from the selected set.
120
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")
131
132 if value not in self._dict:
133 return
134
135 if at is None:
136 at = getCallStack()
137
138 self._history.append((f"removed {value} from selection", at, "selection"))
139 self._set.discard(value)
140
141 def __len__(self):
142 return len(self._set)
143
144 def __iter__(self):
145 return iter(self._set)
146
147 def __contains__(self, value):
148 return value in self._set
149
150 def __repr__(self):
151 return repr(list(self._set))
152
153 def __str__(self):
154 return str(list(self._set))
155
156 def __reduce__(self):
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 )
162
163
164class ConfigInstanceDict(collections.abc.Mapping[str, Config]):
165 """Dictionary of instantiated configs, used to populate a
166 `~lsst.pex.config.ConfigChoiceField`.
167
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 """
177
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
187
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(result._dict, self._selection._set)
196 else:
197 result._selection = self._selection
198 return result
199
200 @property
201 def types(self):
202 return self._typemap if self._typemap is not None else self._field.typemap
203
204 def __contains__(self, k):
205 return k in self.types
206
207 def __len__(self):
208 return len(self.types)
209
210 def __iter__(self):
211 return iter(self.types)
212
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")
216
217 if at is None:
218 at = getCallStack(1)
219
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))
229
230 def _getNames(self):
231 if not self._field.multi:
233 self._field, self._config, "Single-selection field has no attribute 'names'"
234 )
235 return self._selection
236
237 def _setNames(self, value):
238 if not self._field.multi:
240 self._field, self._config, "Single-selection field has no attribute 'names'"
241 )
242 self._setSelection(value)
243
244 def _delNames(self):
245 if not self._field.multi:
247 self._field, self._config, "Single-selection field has no attribute 'names'"
248 )
249 self._selection = None
250
251 def _getName(self):
252 if self._field.multi:
254 self._field, self._config, "Multi-selection field has no attribute 'name'"
255 )
256 return self._selection
257
258 def _setName(self, value):
259 if self._field.multi:
261 self._field, self._config, "Multi-selection field has no attribute 'name'"
262 )
263 self._setSelection(value)
264
265 def _delName(self):
266 if self._field.multi:
268 self._field, self._config, "Multi-selection field has no attribute 'name'"
269 )
270 self._selection = None
271
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 """
277
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 """
283
284 def _getActive(self):
285 if self._selection is None:
286 return None
287
288 if self._field.multi:
289 return [self[c] for c in self._selection]
290 else:
291 return self[self._selection]
292
293 active = property(_getActive)
294 """The selected items.
295
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 """
299
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:
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
316
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")
320
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
325
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)
332
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)
346
347 def _rename(self, fullname):
348 for k, v in self._dict.items():
349 v._rename(_joinNamePath(name=fullname, index=k))
350
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)
370
371 def freeze(self):
372 """Freeze the config.
373
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)
382
383 def __reduce__(self):
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 )
389
390
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.
394
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.
411
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.
418
419 See Also
420 --------
421 ChoiceField
422 ConfigDictField
423 ConfigField
424 ConfigurableField
425 DictField
426 Field
427 ListField
428 RangeField
429 RegistryField
430
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.
438
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.
442
443 When saving a configuration with a ``ConfigChoiceField``, the entire set is
444 saved, as well as the active selection.
445
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.
450
451 For example, ``AaaConfig`` is a config object
452
453 >>> from lsst.pex.config import Config, ConfigChoiceField, Field
454 >>> class AaaConfig(Config):
455 ... somefield = Field("doc", int)
456
457 The ``MyConfig`` config has a ``ConfigChoiceField`` field called ``choice``
458 that maps the ``AaaConfig`` type to the ``"AAA"`` key:
459
460 >>> TYPEMAP = {"AAA", AaaConfig}
461 >>> class MyConfig(Config):
462 ... choice = ConfigChoiceField("doc for choice", TYPEMAP)
463
464 Creating an instance of ``MyConfig``:
465
466 >>> instance = MyConfig()
467
468 Setting value of the field ``somefield`` on the "AAA" key of the ``choice``
469 field:
470
471 >>> instance.choice["AAA"].somefield = 5
472
473 **Selecting the active configuration**
474
475 Make the ``"AAA"`` key the active configuration value for the ``choice``
476 field:
477
478 >>> instance.choice = "AAA"
479
480 Alternatively, the last line can be written:
481
482 >>> instance.choice.name = "AAA"
483
484 (If the config instance allows multiple selections, you'd assign a sequence
485 to the ``names`` attribute instead.)
486
487 ``ConfigChoiceField`` instances also allow multiple values of the same
488 type:
489
490 >>> TYPEMAP["CCC"] = AaaConfig
491 >>> TYPEMAP["BBB"] = AaaConfig
492 """
493
494 instanceDictClass = ConfigInstanceDict
495
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
509
510 def __class_getitem__(cls, params: tuple[type, ...] | type | ForwardRef):
511 raise ValueError("ConfigChoiceField does not support typing argument")
512
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))
522
523 return instanceDict
524
525 @overload
527 self, instance: None, owner: Any = None, at: Any = None, label: str = "default"
528 ) -> ConfigChoiceField: ...
529
530 @overload
532 self, instance: Config, owner: Any = None, at: Any = None, label: str = "default"
533 ) -> ConfigInstanceDict: ...
534
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)
540
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)
553
554 else:
555 instanceDict._setSelection(value, at=at, label=label)
556
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
563
564 def rename(self, instance):
565 instanceDict = self.__get__(instance)
566 fullname = _joinNamePath(instance._name, self.name)
567 instanceDict._rename(fullname)
568
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()
580
581 def toDict(self, instance):
582 instanceDict = self.__get__(instance)
583
584 dict_ = {}
585 if self.multi:
586 dict_["names"] = instanceDict.names
587 else:
588 dict_["name"] = instanceDict.name
589
590 values = {}
591 for k, v in instanceDict.items():
592 values[k] = v.toDict()
593 dict_["values"] = values
594
595 return dict_
596
597 def freeze(self, instance):
598 instanceDict = self.__get__(instance)
599 instanceDict.freeze()
600 for v in instanceDict.values():
601 v.freeze()
602
603 def _collectImports(self, instance, imports):
604 instanceDict = self.__get__(instance)
605 for config in instanceDict.values():
606 config._collectImports()
607 imports |= config._imports
608
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")
618
619 def __deepcopy__(self, memo):
620 """Customize deep-copying, because we always want a reference to the
621 original typemap.
622
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
635
636 def _compare(self, instance1, instance2, shortcut, rtol, atol, output):
637 """Compare two fields for equality.
638
639 Used by `lsst.pex.ConfigChoiceField.compare`.
640
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 : callable
654 A callable that takes a string, used (possibly repeatedly) to
655 report inequalities.
656
657 Returns
658 -------
659 isEqual : bool
660 `True` if the fields are equal, `False` otherwise.
661
662 Notes
663 -----
664 Only the selected configurations are compared, as the parameters of any
665 others do not matter.
666
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
Field[FieldTypeVar] __get__(self, None instance, Any owner=None, Any at=None, str label="default")
Definition config.py:714
_setup(self, doc, dtype, default, check, optional, source, deprecated)
Definition config.py:486
__init__(self, doc, typemap, default=None, optional=False, multi=False, deprecated=None)
__class_getitem__(cls, tuple[type,...]|type|ForwardRef params)
_compare(self, instance1, instance2, shortcut, rtol, atol, output)
None __set__(self, Config instance, ConfigInstanceDict|None value, Any at=None, str label="assignment")
ConfigChoiceField __get__(self, None instance, Any owner=None, Any at=None, str label="default")
__setattr__(self, attr, value, at=None, label="assignment")
__setitem__(self, k, value, at=None, label="assignment")
ConfigInstanceDict _copy(self, Config config)
__init__(self, Config config, ConfigChoiceField field)
_setSelection(self, value, at=None, label="assignment")
__init__(self, ConfigInstanceDict dict_, Any value, at=None, str label="assignment", bool setHistory=True)
compareConfigs(name, c1, c2, shortcut=True, rtol=1e-8, atol=1e-8, output=None)
getComparisonName(name1, name2)
Definition comparison.py:40
compareScalars(name, v1, v2, output, rtol=1e-8, atol=1e-8, dtype=None)
Definition comparison.py:62
_joinNamePath(prefix=None, name=None, index=None)
Definition config.py:107