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