Coverage for python/lsst/pex/config/configChoiceField.py : 15%

Hot-keys 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 copy
31import collections.abc
33from .config import Config, Field, FieldValidationError, _typeStr, _joinNamePath
34from .comparison import getComparisonName, compareScalars, compareConfigs
35from .callStack import getCallStack, getStackFrame
38class SelectionSet(collections.abc.MutableSet):
39 """A mutable set class that tracks the selection of multi-select
40 `~lsst.pex.config.ConfigChoiceField` objects.
42 Parameters
43 ----------
44 dict_ : `ConfigInstanceDict`
45 The dictionary of instantiated configs.
46 value
47 The selected key.
48 at : `lsst.pex.config.callStack.StackFrame`, optional
49 The call stack when the selection was made.
50 label : `str`, optional
51 Label for history tracking.
52 setHistory : `bool`, optional
53 Add this even to the history, if `True`.
55 Notes
56 -----
57 This class allows a user of a multi-select
58 `~lsst.pex.config.ConfigChoiceField` to add or discard items from the set
59 of active configs. Each change to the selection is tracked in the field's
60 history.
61 """
63 def __init__(self, dict_, value, at=None, label="assignment", setHistory=True):
64 if at is None:
65 at = getCallStack()
66 self._dict = dict_
67 self._field = self._dict._field
68 self._config = self._dict._config
69 self.__history = self._config._history.setdefault(self._field.name, [])
70 if value is not None:
71 try:
72 for v in value:
73 if v not in self._dict:
74 # invoke __getitem__ to ensure it's present
75 self._dict.__getitem__(v, at=at)
76 except TypeError:
77 msg = "Value %s is of incorrect type %s. Sequence type expected" % (value, _typeStr(value))
78 raise FieldValidationError(self._field, self._config, msg)
79 self._set = set(value)
80 else:
81 self._set = set()
83 if setHistory:
84 self.__history.append(("Set selection to %s" % self, at, label))
86 def add(self, value, at=None):
87 """Add a value to the selected set.
88 """
89 if self._config._frozen:
90 raise FieldValidationError(self._field, self._config,
91 "Cannot modify a frozen Config")
93 if at is None:
94 at = getCallStack()
96 if value not in self._dict:
97 # invoke __getitem__ to make sure it's present
98 self._dict.__getitem__(value, at=at)
100 self.__history.append(("added %s to selection" % value, at, "selection"))
101 self._set.add(value)
103 def discard(self, value, at=None):
104 """Discard a value from the selected set.
105 """
106 if self._config._frozen:
107 raise FieldValidationError(self._field, self._config,
108 "Cannot modify a frozen Config")
110 if value not in self._dict:
111 return
113 if at is None:
114 at = getCallStack()
116 self.__history.append(("removed %s from selection" % value, at, "selection"))
117 self._set.discard(value)
119 def __len__(self):
120 return len(self._set)
122 def __iter__(self):
123 return iter(self._set)
125 def __contains__(self, value):
126 return value in self._set
128 def __repr__(self):
129 return repr(list(self._set))
131 def __str__(self):
132 return str(list(self._set))
135class ConfigInstanceDict(collections.abc.Mapping):
136 """Dictionary of instantiated configs, used to populate a
137 `~lsst.pex.config.ConfigChoiceField`.
139 Parameters
140 ----------
141 config : `lsst.pex.config.Config`
142 A configuration instance.
143 field : `lsst.pex.config.Field`-type
144 A configuration field. Note that the `lsst.pex.config.Field.fieldmap`
145 attribute must provide key-based access to configuration classes,
146 (that is, ``typemap[name]``).
147 """
148 def __init__(self, config, field):
149 collections.abc.Mapping.__init__(self)
150 self._dict = dict()
151 self._selection = None
152 self._config = config
153 self._field = field
154 self._history = config._history.setdefault(field.name, [])
155 self.__doc__ = field.doc
156 self._typemap = None
158 @property
159 def types(self):
160 return self._typemap if self._typemap is not None else self._field.typemap
162 def __contains__(self, k):
163 return k in self.types
165 def __len__(self):
166 return len(self.types)
168 def __iter__(self):
169 return iter(self.types)
171 def _setSelection(self, value, at=None, label="assignment"):
172 if self._config._frozen:
173 raise FieldValidationError(self._field, self._config, "Cannot modify a frozen Config")
175 if at is None:
176 at = getCallStack(1)
178 if value is None:
179 self._selection = None
180 elif self._field.multi:
181 self._selection = SelectionSet(self, value, setHistory=False)
182 else:
183 if value not in self._dict:
184 self.__getitem__(value, at=at) # just invoke __getitem__ to make sure it's present
185 self._selection = value
186 self._history.append((value, at, label))
188 def _getNames(self):
189 if not self._field.multi:
190 raise FieldValidationError(self._field, self._config,
191 "Single-selection field has no attribute 'names'")
192 return self._selection
194 def _setNames(self, value):
195 if not self._field.multi:
196 raise FieldValidationError(self._field, self._config,
197 "Single-selection field has no attribute 'names'")
198 self._setSelection(value)
200 def _delNames(self):
201 if not self._field.multi:
202 raise FieldValidationError(self._field, self._config,
203 "Single-selection field has no attribute 'names'")
204 self._selection = None
206 def _getName(self):
207 if self._field.multi:
208 raise FieldValidationError(self._field, self._config,
209 "Multi-selection field has no attribute 'name'")
210 return self._selection
212 def _setName(self, value):
213 if self._field.multi:
214 raise FieldValidationError(self._field, self._config,
215 "Multi-selection field has no attribute 'name'")
216 self._setSelection(value)
218 def _delName(self):
219 if self._field.multi:
220 raise FieldValidationError(self._field, self._config,
221 "Multi-selection field has no attribute 'name'")
222 self._selection = None
224 names = property(_getNames, _setNames, _delNames)
225 """List of names of active items in a multi-selection
226 ``ConfigInstanceDict``. Disabled in a single-selection ``_Registry``; use
227 the `name` attribute instead.
228 """
230 name = property(_getName, _setName, _delName)
231 """Name of the active item in a single-selection ``ConfigInstanceDict``.
232 Disabled in a multi-selection ``_Registry``; use the ``names`` attribute
233 instead.
234 """
236 def _getActive(self):
237 if self._selection is None:
238 return None
240 if self._field.multi:
241 return [self[c] for c in self._selection]
242 else:
243 return self[self._selection]
245 active = property(_getActive)
246 """The selected items.
248 For multi-selection, this is equivalent to: ``[self[name] for name in
249 self.names]``. For single-selection, this is equivalent to: ``self[name]``.
250 """
252 def __getitem__(self, k, at=None, label="default"):
253 try:
254 value = self._dict[k]
255 except KeyError:
256 try:
257 dtype = self.types[k]
258 except Exception:
259 raise FieldValidationError(self._field, self._config,
260 "Unknown key %r in Registry/ConfigChoiceField" % k)
261 name = _joinNamePath(self._config._name, self._field.name, k)
262 if at is None:
263 at = getCallStack()
264 at.insert(0, dtype._source)
265 value = self._dict.setdefault(k, dtype(__name=name, __at=at, __label=label))
266 return value
268 def __setitem__(self, k, value, at=None, label="assignment"):
269 if self._config._frozen:
270 raise FieldValidationError(self._field, self._config, "Cannot modify a frozen Config")
272 try:
273 dtype = self.types[k]
274 except Exception:
275 raise FieldValidationError(self._field, self._config, "Unknown key %r" % k)
277 if value != dtype and type(value) != dtype:
278 msg = "Value %s at key %k is of incorrect type %s. Expected type %s" % \
279 (value, k, _typeStr(value), _typeStr(dtype))
280 raise FieldValidationError(self._field, self._config, msg)
282 if at is None:
283 at = getCallStack()
284 name = _joinNamePath(self._config._name, self._field.name, k)
285 oldValue = self._dict.get(k, None)
286 if oldValue is None:
287 if value == dtype:
288 self._dict[k] = value(__name=name, __at=at, __label=label)
289 else:
290 self._dict[k] = dtype(__name=name, __at=at, __label=label, **value._storage)
291 else:
292 if value == dtype:
293 value = value()
294 oldValue.update(__at=at, __label=label, **value._storage)
296 def _rename(self, fullname):
297 for k, v in self._dict.items():
298 v._rename(_joinNamePath(name=fullname, index=k))
300 def __setattr__(self, attr, value, at=None, label="assignment"):
301 if hasattr(getattr(self.__class__, attr, None), '__set__'):
302 # This allows properties to work.
303 object.__setattr__(self, attr, value)
304 elif attr in self.__dict__ or attr in ["_history", "_field", "_config", "_dict",
305 "_selection", "__doc__", "_typemap"]:
306 # This allows specific private attributes to work.
307 object.__setattr__(self, attr, value)
308 else:
309 # We throw everything else.
310 msg = "%s has no attribute %s" % (_typeStr(self._field), attr)
311 raise FieldValidationError(self._field, self._config, msg)
313 def freeze(self):
314 """Invoking this freeze method will create a local copy of the field
315 attribute's typemap. This decouples this instance dict from the
316 underlying objects type map ensuring that and subsequent changes to the
317 typemap will not be reflected in this instance (i.e imports adding
318 additional registry entries).
319 """
320 if self._typemap is None:
321 self._typemap = copy.deepcopy(self.types)
324class ConfigChoiceField(Field):
325 """A configuration field (`~lsst.pex.config.Field` subclass) that allows a
326 user to choose from a set of `~lsst.pex.config.Config` types.
328 Parameters
329 ----------
330 doc : `str`
331 Documentation string for the field.
332 typemap : `dict`-like
333 A mapping between keys and `~lsst.pex.config.Config`-types as values.
334 See *Examples* for details.
335 default : `str`, optional
336 The default configuration name.
337 optional : `bool`, optional
338 When `False`, `lsst.pex.config.Config.validate` will fail if the
339 field's value is `None`.
340 multi : `bool`, optional
341 If `True`, the field allows multiple selections. In this case, set the
342 selections by assigning a sequence to the ``names`` attribute of the
343 field.
345 If `False`, the field allows only a single selection. In this case,
346 set the active config by assigning the config's key from the
347 ``typemap`` to the field's ``name`` attribute (see *Examples*).
348 deprecated : None or `str`, optional
349 A description of why this Field is deprecated, including removal date.
350 If not None, the string is appended to the docstring for this Field.
352 See also
353 --------
354 ChoiceField
355 ConfigDictField
356 ConfigField
357 ConfigurableField
358 DictField
359 Field
360 ListField
361 RangeField
362 RegistryField
364 Notes
365 -----
366 ``ConfigChoiceField`` instances can allow either single selections or
367 multiple selections, depending on the ``multi`` parameter. For
368 single-selection fields, set the selection with the ``name`` attribute.
369 For multi-selection fields, set the selection though the ``names``
370 attribute.
372 This field is validated only against the active selection. If the
373 ``active`` attribute is `None` and the field is not optional, validation
374 will fail.
376 When saving a configuration with a ``ConfigChoiceField``, the entire set is
377 saved, as well as the active selection.
379 Examples
380 --------
381 While the ``typemap`` is shared by all instances of the field, each
382 instance of the field has its own instance of a particular sub-config type.
384 For example, ``AaaConfig`` is a config object
386 >>> from lsst.pex.config import Config, ConfigChoiceField, Field
387 >>> class AaaConfig(Config):
388 ... somefield = Field("doc", int)
389 ...
391 The ``MyConfig`` config has a ``ConfigChoiceField`` field called ``choice``
392 that maps the ``AaaConfig`` type to the ``"AAA"`` key:
394 >>> TYPEMAP = {"AAA", AaaConfig}
395 >>> class MyConfig(Config):
396 ... choice = ConfigChoiceField("doc for choice", TYPEMAP)
397 ...
399 Creating an instance of ``MyConfig``:
401 >>> instance = MyConfig()
403 Setting value of the field ``somefield`` on the "AAA" key of the ``choice``
404 field:
406 >>> instance.choice['AAA'].somefield = 5
408 **Selecting the active configuration**
410 Make the ``"AAA"`` key the active configuration value for the ``choice``
411 field:
413 >>> instance.choice = "AAA"
415 Alternatively, the last line can be written:
417 >>> instance.choice.name = "AAA"
419 (If the config instance allows multiple selections, you'd assign a sequence
420 to the ``names`` attribute instead.)
422 ``ConfigChoiceField`` instances also allow multiple values of the same
423 type:
425 >>> TYPEMAP["CCC"] = AaaConfig
426 >>> TYPEMAP["BBB"] = AaaConfig
427 """
429 instanceDictClass = ConfigInstanceDict
431 def __init__(self, doc, typemap, default=None, optional=False, multi=False, deprecated=None):
432 source = getStackFrame()
433 self._setup(doc=doc, dtype=self.instanceDictClass, default=default, check=None, optional=optional,
434 source=source, deprecated=deprecated)
435 self.typemap = typemap
436 self.multi = multi
438 def _getOrMake(self, instance, label="default"):
439 instanceDict = instance._storage.get(self.name)
440 if instanceDict is None:
441 at = getCallStack(1)
442 instanceDict = self.dtype(instance, self)
443 instanceDict.__doc__ = self.doc
444 instance._storage[self.name] = instanceDict
445 history = instance._history.setdefault(self.name, [])
446 history.append(("Initialized from defaults", at, label))
448 return instanceDict
450 def __get__(self, instance, owner=None):
451 if instance is None or not isinstance(instance, Config):
452 return self
453 else:
454 return self._getOrMake(instance)
456 def __set__(self, instance, value, at=None, label="assignment"):
457 if instance._frozen:
458 raise FieldValidationError(self, instance, "Cannot modify a frozen Config")
459 if at is None:
460 at = getCallStack()
461 instanceDict = self._getOrMake(instance)
462 if isinstance(value, self.instanceDictClass):
463 for k, v in value.items():
464 instanceDict.__setitem__(k, v, at=at, label=label)
465 instanceDict._setSelection(value._selection, at=at, label=label)
467 else:
468 instanceDict._setSelection(value, at=at, label=label)
470 def rename(self, instance):
471 instanceDict = self.__get__(instance)
472 fullname = _joinNamePath(instance._name, self.name)
473 instanceDict._rename(fullname)
475 def validate(self, instance):
476 instanceDict = self.__get__(instance)
477 if instanceDict.active is None and not self.optional:
478 msg = "Required field cannot be None"
479 raise FieldValidationError(self, instance, msg)
480 elif instanceDict.active is not None:
481 if self.multi:
482 for a in instanceDict.active:
483 a.validate()
484 else:
485 instanceDict.active.validate()
487 def toDict(self, instance):
488 instanceDict = self.__get__(instance)
490 dict_ = {}
491 if self.multi:
492 dict_["names"] = instanceDict.names
493 else:
494 dict_["name"] = instanceDict.name
496 values = {}
497 for k, v in instanceDict.items():
498 values[k] = v.toDict()
499 dict_["values"] = values
501 return dict_
503 def freeze(self, instance):
504 instanceDict = self.__get__(instance)
505 instanceDict.freeze()
506 for v in instanceDict.values():
507 v.freeze()
509 def _collectImports(self, instance, imports):
510 instanceDict = self.__get__(instance)
511 for config in instanceDict.values():
512 config._collectImports()
513 imports |= config._imports
515 def save(self, outfile, instance):
516 instanceDict = self.__get__(instance)
517 fullname = _joinNamePath(instance._name, self.name)
518 for v in instanceDict.values():
519 v._save(outfile)
520 if self.multi:
521 outfile.write(u"{}.names={!r}\n".format(fullname, instanceDict.names))
522 else:
523 outfile.write(u"{}.name={!r}\n".format(fullname, instanceDict.name))
525 def __deepcopy__(self, memo):
526 """Customize deep-copying, because we always want a reference to the
527 original typemap.
529 WARNING: this must be overridden by subclasses if they change the
530 constructor signature!
531 """
532 other = type(self)(doc=self.doc, typemap=self.typemap, default=copy.deepcopy(self.default),
533 optional=self.optional, multi=self.multi)
534 other.source = self.source
535 return other
537 def _compare(self, instance1, instance2, shortcut, rtol, atol, output):
538 """Compare two fields for equality.
540 Used by `lsst.pex.ConfigChoiceField.compare`.
542 Parameters
543 ----------
544 instance1 : `lsst.pex.config.Config`
545 Left-hand side config instance to compare.
546 instance2 : `lsst.pex.config.Config`
547 Right-hand side config instance to compare.
548 shortcut : `bool`
549 If `True`, this function returns as soon as an inequality if found.
550 rtol : `float`
551 Relative tolerance for floating point comparisons.
552 atol : `float`
553 Absolute tolerance for floating point comparisons.
554 output : callable
555 A callable that takes a string, used (possibly repeatedly) to
556 report inequalities.
558 Returns
559 -------
560 isEqual : bool
561 `True` if the fields are equal, `False` otherwise.
563 Notes
564 -----
565 Only the selected configurations are compared, as the parameters of any
566 others do not matter.
568 Floating point comparisons are performed by `numpy.allclose`.
569 """
570 d1 = getattr(instance1, self.name)
571 d2 = getattr(instance2, self.name)
572 name = getComparisonName(
573 _joinNamePath(instance1._name, self.name),
574 _joinNamePath(instance2._name, self.name)
575 )
576 if not compareScalars("selection for %s" % name, d1._selection, d2._selection, output=output):
577 return False
578 if d1._selection is None:
579 return True
580 if self.multi:
581 nested = [(k, d1[k], d2[k]) for k in d1._selection]
582 else:
583 nested = [(d1._selection, d1[d1._selection], d2[d1._selection])]
584 equal = True
585 for k, c1, c2 in nested:
586 result = compareConfigs("%s[%r]" % (name, k), c1, c2, shortcut=shortcut,
587 rtol=rtol, atol=atol, output=output)
588 if not result and shortcut:
589 return False
590 equal = equal and result
591 return equal