Coverage for python/lsst/pex/config/configChoiceField.py: 17%
Shortcuts on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
Shortcuts on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
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, _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))
139class ConfigInstanceDict(collections.abc.Mapping):
140 """Dictionary of instantiated configs, used to populate a
141 `~lsst.pex.config.ConfigChoiceField`.
143 Parameters
144 ----------
145 config : `lsst.pex.config.Config`
146 A configuration instance.
147 field : `lsst.pex.config.Field`-type
148 A configuration field. Note that the `lsst.pex.config.Field.fieldmap`
149 attribute must provide key-based access to configuration classes,
150 (that is, ``typemap[name]``).
151 """
153 def __init__(self, config, field):
154 collections.abc.Mapping.__init__(self)
155 self._dict = dict()
156 self._selection = None
157 self._config = config
158 self._field = field
159 self._history = config._history.setdefault(field.name, [])
160 self.__doc__ = field.doc
161 self._typemap = None
163 @property
164 def types(self):
165 return self._typemap if self._typemap is not None else self._field.typemap
167 def __contains__(self, k):
168 return k in self.types
170 def __len__(self):
171 return len(self.types)
173 def __iter__(self):
174 return iter(self.types)
176 def _setSelection(self, value, at=None, label="assignment"):
177 if self._config._frozen:
178 raise FieldValidationError(self._field, self._config, "Cannot modify a frozen Config")
180 if at is None:
181 at = getCallStack(1)
183 if value is None:
184 self._selection = None
185 elif self._field.multi:
186 self._selection = SelectionSet(self, value, setHistory=False)
187 else:
188 if value not in self._dict:
189 self.__getitem__(value, at=at) # just invoke __getitem__ to make sure it's present
190 self._selection = value
191 self._history.append((value, at, label))
193 def _getNames(self):
194 if not self._field.multi:
195 raise FieldValidationError(
196 self._field, self._config, "Single-selection field has no attribute 'names'"
197 )
198 return self._selection
200 def _setNames(self, value):
201 if not self._field.multi:
202 raise FieldValidationError(
203 self._field, self._config, "Single-selection field has no attribute 'names'"
204 )
205 self._setSelection(value)
207 def _delNames(self):
208 if not self._field.multi:
209 raise FieldValidationError(
210 self._field, self._config, "Single-selection field has no attribute 'names'"
211 )
212 self._selection = None
214 def _getName(self):
215 if self._field.multi:
216 raise FieldValidationError(
217 self._field, self._config, "Multi-selection field has no attribute 'name'"
218 )
219 return self._selection
221 def _setName(self, value):
222 if self._field.multi:
223 raise FieldValidationError(
224 self._field, self._config, "Multi-selection field has no attribute 'name'"
225 )
226 self._setSelection(value)
228 def _delName(self):
229 if self._field.multi:
230 raise FieldValidationError(
231 self._field, self._config, "Multi-selection field has no attribute 'name'"
232 )
233 self._selection = None
235 names = property(_getNames, _setNames, _delNames)
236 """List of names of active items in a multi-selection
237 ``ConfigInstanceDict``. Disabled in a single-selection ``_Registry``; use
238 the `name` attribute instead.
239 """
241 name = property(_getName, _setName, _delName)
242 """Name of the active item in a single-selection ``ConfigInstanceDict``.
243 Disabled in a multi-selection ``_Registry``; use the ``names`` attribute
244 instead.
245 """
247 def _getActive(self):
248 if self._selection is None:
249 return None
251 if self._field.multi:
252 return [self[c] for c in self._selection]
253 else:
254 return self[self._selection]
256 active = property(_getActive)
257 """The selected items.
259 For multi-selection, this is equivalent to: ``[self[name] for name in
260 self.names]``. For single-selection, this is equivalent to: ``self[name]``.
261 """
263 def __getitem__(self, k, at=None, label="default"):
264 try:
265 value = self._dict[k]
266 except KeyError:
267 try:
268 dtype = self.types[k]
269 except Exception:
270 raise FieldValidationError(
271 self._field, self._config, "Unknown key %r in Registry/ConfigChoiceField" % k
272 )
273 name = _joinNamePath(self._config._name, self._field.name, k)
274 if at is None:
275 at = getCallStack()
276 at.insert(0, dtype._source)
277 value = self._dict.setdefault(k, dtype(__name=name, __at=at, __label=label))
278 return value
280 def __setitem__(self, k, value, at=None, label="assignment"):
281 if self._config._frozen:
282 raise FieldValidationError(self._field, self._config, "Cannot modify a frozen Config")
284 try:
285 dtype = self.types[k]
286 except Exception:
287 raise FieldValidationError(self._field, self._config, "Unknown key %r" % k)
289 if value != dtype and type(value) != dtype:
290 msg = "Value %s at key %s is of incorrect type %s. Expected type %s" % (
291 value,
292 k,
293 _typeStr(value),
294 _typeStr(dtype),
295 )
296 raise FieldValidationError(self._field, self._config, msg)
298 if at is None:
299 at = getCallStack()
300 name = _joinNamePath(self._config._name, self._field.name, k)
301 oldValue = self._dict.get(k, None)
302 if oldValue is None:
303 if value == dtype:
304 self._dict[k] = value(__name=name, __at=at, __label=label)
305 else:
306 self._dict[k] = dtype(__name=name, __at=at, __label=label, **value._storage)
307 else:
308 if value == dtype:
309 value = value()
310 oldValue.update(__at=at, __label=label, **value._storage)
312 def _rename(self, fullname):
313 for k, v in self._dict.items():
314 v._rename(_joinNamePath(name=fullname, index=k))
316 def __setattr__(self, attr, value, at=None, label="assignment"):
317 if hasattr(getattr(self.__class__, attr, None), "__set__"):
318 # This allows properties to work.
319 object.__setattr__(self, attr, value)
320 elif attr in self.__dict__ or attr in [
321 "_history",
322 "_field",
323 "_config",
324 "_dict",
325 "_selection",
326 "__doc__",
327 "_typemap",
328 ]:
329 # This allows specific private attributes to work.
330 object.__setattr__(self, attr, value)
331 else:
332 # We throw everything else.
333 msg = "%s has no attribute %s" % (_typeStr(self._field), attr)
334 raise FieldValidationError(self._field, self._config, msg)
336 def freeze(self):
337 """Invoking this freeze method will create a local copy of the field
338 attribute's typemap. This decouples this instance dict from the
339 underlying objects type map ensuring that and subsequent changes to the
340 typemap will not be reflected in this instance (i.e imports adding
341 additional registry entries).
342 """
343 if self._typemap is None:
344 self._typemap = copy.deepcopy(self.types)
347class ConfigChoiceField(Field):
348 """A configuration field (`~lsst.pex.config.Field` subclass) that allows a
349 user to choose from a set of `~lsst.pex.config.Config` types.
351 Parameters
352 ----------
353 doc : `str`
354 Documentation string for the field.
355 typemap : `dict`-like
356 A mapping between keys and `~lsst.pex.config.Config`-types as values.
357 See *Examples* for details.
358 default : `str`, optional
359 The default configuration name.
360 optional : `bool`, optional
361 When `False`, `lsst.pex.config.Config.validate` will fail if the
362 field's value is `None`.
363 multi : `bool`, optional
364 If `True`, the field allows multiple selections. In this case, set the
365 selections by assigning a sequence to the ``names`` attribute of the
366 field.
368 If `False`, the field allows only a single selection. In this case,
369 set the active config by assigning the config's key from the
370 ``typemap`` to the field's ``name`` attribute (see *Examples*).
371 deprecated : None or `str`, optional
372 A description of why this Field is deprecated, including removal date.
373 If not None, the string is appended to the docstring for this Field.
375 See also
376 --------
377 ChoiceField
378 ConfigDictField
379 ConfigField
380 ConfigurableField
381 DictField
382 Field
383 ListField
384 RangeField
385 RegistryField
387 Notes
388 -----
389 ``ConfigChoiceField`` instances can allow either single selections or
390 multiple selections, depending on the ``multi`` parameter. For
391 single-selection fields, set the selection with the ``name`` attribute.
392 For multi-selection fields, set the selection though the ``names``
393 attribute.
395 This field is validated only against the active selection. If the
396 ``active`` attribute is `None` and the field is not optional, validation
397 will fail.
399 When saving a configuration with a ``ConfigChoiceField``, the entire set is
400 saved, as well as the active selection.
402 Examples
403 --------
404 While the ``typemap`` is shared by all instances of the field, each
405 instance of the field has its own instance of a particular sub-config type.
407 For example, ``AaaConfig`` is a config object
409 >>> from lsst.pex.config import Config, ConfigChoiceField, Field
410 >>> class AaaConfig(Config):
411 ... somefield = Field("doc", int)
412 ...
414 The ``MyConfig`` config has a ``ConfigChoiceField`` field called ``choice``
415 that maps the ``AaaConfig`` type to the ``"AAA"`` key:
417 >>> TYPEMAP = {"AAA", AaaConfig}
418 >>> class MyConfig(Config):
419 ... choice = ConfigChoiceField("doc for choice", TYPEMAP)
420 ...
422 Creating an instance of ``MyConfig``:
424 >>> instance = MyConfig()
426 Setting value of the field ``somefield`` on the "AAA" key of the ``choice``
427 field:
429 >>> instance.choice['AAA'].somefield = 5
431 **Selecting the active configuration**
433 Make the ``"AAA"`` key the active configuration value for the ``choice``
434 field:
436 >>> instance.choice = "AAA"
438 Alternatively, the last line can be written:
440 >>> instance.choice.name = "AAA"
442 (If the config instance allows multiple selections, you'd assign a sequence
443 to the ``names`` attribute instead.)
445 ``ConfigChoiceField`` instances also allow multiple values of the same
446 type:
448 >>> TYPEMAP["CCC"] = AaaConfig
449 >>> TYPEMAP["BBB"] = AaaConfig
450 """
452 instanceDictClass = ConfigInstanceDict
454 def __init__(self, doc, typemap, default=None, optional=False, multi=False, deprecated=None):
455 source = getStackFrame()
456 self._setup(
457 doc=doc,
458 dtype=self.instanceDictClass,
459 default=default,
460 check=None,
461 optional=optional,
462 source=source,
463 deprecated=deprecated,
464 )
465 self.typemap = typemap
466 self.multi = multi
468 def _getOrMake(self, instance, label="default"):
469 instanceDict = instance._storage.get(self.name)
470 if instanceDict is None:
471 at = getCallStack(1)
472 instanceDict = self.dtype(instance, self)
473 instanceDict.__doc__ = self.doc
474 instance._storage[self.name] = instanceDict
475 history = instance._history.setdefault(self.name, [])
476 history.append(("Initialized from defaults", at, label))
478 return instanceDict
480 def __get__(self, instance, owner=None):
481 if instance is None or not isinstance(instance, Config):
482 return self
483 else:
484 return self._getOrMake(instance)
486 def __set__(self, instance, value, at=None, label="assignment"):
487 if instance._frozen:
488 raise FieldValidationError(self, instance, "Cannot modify a frozen Config")
489 if at is None:
490 at = getCallStack()
491 instanceDict = self._getOrMake(instance)
492 if isinstance(value, self.instanceDictClass):
493 for k, v in value.items():
494 instanceDict.__setitem__(k, v, at=at, label=label)
495 instanceDict._setSelection(value._selection, at=at, label=label)
497 else:
498 instanceDict._setSelection(value, at=at, label=label)
500 def rename(self, instance):
501 instanceDict = self.__get__(instance)
502 fullname = _joinNamePath(instance._name, self.name)
503 instanceDict._rename(fullname)
505 def validate(self, instance):
506 instanceDict = self.__get__(instance)
507 if instanceDict.active is None and not self.optional:
508 msg = "Required field cannot be None"
509 raise FieldValidationError(self, instance, msg)
510 elif instanceDict.active is not None:
511 if self.multi:
512 for a in instanceDict.active:
513 a.validate()
514 else:
515 instanceDict.active.validate()
517 def toDict(self, instance):
518 instanceDict = self.__get__(instance)
520 dict_ = {}
521 if self.multi:
522 dict_["names"] = instanceDict.names
523 else:
524 dict_["name"] = instanceDict.name
526 values = {}
527 for k, v in instanceDict.items():
528 values[k] = v.toDict()
529 dict_["values"] = values
531 return dict_
533 def freeze(self, instance):
534 instanceDict = self.__get__(instance)
535 instanceDict.freeze()
536 for v in instanceDict.values():
537 v.freeze()
539 def _collectImports(self, instance, imports):
540 instanceDict = self.__get__(instance)
541 for config in instanceDict.values():
542 config._collectImports()
543 imports |= config._imports
545 def save(self, outfile, instance):
546 instanceDict = self.__get__(instance)
547 fullname = _joinNamePath(instance._name, self.name)
548 for v in instanceDict.values():
549 v._save(outfile)
550 if self.multi:
551 outfile.write("{}.names={!r}\n".format(fullname, instanceDict.names))
552 else:
553 outfile.write("{}.name={!r}\n".format(fullname, instanceDict.name))
555 def __deepcopy__(self, memo):
556 """Customize deep-copying, because we always want a reference to the
557 original typemap.
559 WARNING: this must be overridden by subclasses if they change the
560 constructor signature!
561 """
562 other = type(self)(
563 doc=self.doc,
564 typemap=self.typemap,
565 default=copy.deepcopy(self.default),
566 optional=self.optional,
567 multi=self.multi,
568 )
569 other.source = self.source
570 return other
572 def _compare(self, instance1, instance2, shortcut, rtol, atol, output):
573 """Compare two fields for equality.
575 Used by `lsst.pex.ConfigChoiceField.compare`.
577 Parameters
578 ----------
579 instance1 : `lsst.pex.config.Config`
580 Left-hand side config instance to compare.
581 instance2 : `lsst.pex.config.Config`
582 Right-hand side config instance to compare.
583 shortcut : `bool`
584 If `True`, this function returns as soon as an inequality if found.
585 rtol : `float`
586 Relative tolerance for floating point comparisons.
587 atol : `float`
588 Absolute tolerance for floating point comparisons.
589 output : callable
590 A callable that takes a string, used (possibly repeatedly) to
591 report inequalities.
593 Returns
594 -------
595 isEqual : bool
596 `True` if the fields are equal, `False` otherwise.
598 Notes
599 -----
600 Only the selected configurations are compared, as the parameters of any
601 others do not matter.
603 Floating point comparisons are performed by `numpy.allclose`.
604 """
605 d1 = getattr(instance1, self.name)
606 d2 = getattr(instance2, self.name)
607 name = getComparisonName(
608 _joinNamePath(instance1._name, self.name), _joinNamePath(instance2._name, self.name)
609 )
610 if not compareScalars("selection for %s" % name, d1._selection, d2._selection, output=output):
611 return False
612 if d1._selection is None:
613 return True
614 if self.multi:
615 nested = [(k, d1[k], d2[k]) for k in d1._selection]
616 else:
617 nested = [(d1._selection, d1[d1._selection], d2[d1._selection])]
618 equal = True
619 for k, c1, c2 in nested:
620 result = compareConfigs(
621 "%s[%r]" % (name, k), c1, c2, shortcut=shortcut, rtol=rtol, atol=atol, output=output
622 )
623 if not result and shortcut:
624 return False
625 equal = equal and result
626 return equal