Coverage for python/lsst/pex/config/configurableActions/_configurableActionStructField.py: 25%
182 statements
« prev ^ index » next coverage.py v7.4.2, created at 2024-02-23 11:29 +0000
« prev ^ index » next coverage.py v7.4.2, created at 2024-02-23 11:29 +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# (https://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 program is free software: you can redistribute it and/or modify
10# it under the terms of the GNU General Public License as published by
11# the Free Software Foundation, either version 3 of the License, or
12# (at your option) any later version.
13#
14# This program is distributed in the hope that it will be useful,
15# but WITHOUT ANY WARRANTY; without even the implied warranty of
16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17# GNU General Public License for more details.
18#
19# You should have received a copy of the GNU General Public License
20# along with this program. If not, see <https://www.gnu.org/licenses/>.
21from __future__ import annotations
23__all__ = ("ConfigurableActionStructField", "ConfigurableActionStruct")
25import weakref
26from collections.abc import Iterable, Iterator, Mapping
27from types import GenericAlias, SimpleNamespace
28from typing import Any, Generic, TypeVar, overload
30from lsst.pex.config.callStack import StackFrame, getCallStack, getStackFrame
31from lsst.pex.config.comparison import compareConfigs, compareScalars, getComparisonName
32from lsst.pex.config.config import Config, Field, FieldValidationError, _joinNamePath, _typeStr
34from . import ActionTypeVar, ConfigurableAction
37class ConfigurableActionStructUpdater:
38 """Abstract the logic of using a dictionary to update a
39 `ConfigurableActionStruct` through attribute assignment.
41 This is useful in the context of setting configuration through pipelines
42 or on the command line.
43 """
45 def __set__(
46 self,
47 instance: ConfigurableActionStruct,
48 value: Mapping[str, ConfigurableAction] | ConfigurableActionStruct,
49 ) -> None:
50 if isinstance(value, Mapping):
51 pass
52 elif isinstance(value, ConfigurableActionStruct):
53 # If the update target is a ConfigurableActionStruct, get the
54 # internal dictionary
55 value = value._attrs
56 else:
57 raise ValueError(
58 "Can only update a ConfigurableActionStruct with an instance of such, or a " "mapping"
59 )
60 for name, action in value.items():
61 setattr(instance, name, action)
63 def __get__(self, instance, objtype=None) -> None:
64 # This descriptor does not support fetching any value
65 return None
68class ConfigurableActionStructRemover:
69 """Abstract the logic of removing an iterable of action names from a
70 `ConfigurableActionStruct` at one time using attribute assignment.
72 This is useful in the context of setting configuration through pipelines
73 or on the command line.
75 Raises
76 ------
77 AttributeError
78 Raised if an attribute specified for removal does not exist in the
79 ConfigurableActionStruct
80 """
82 def __set__(self, instance: ConfigurableActionStruct, value: str | Iterable[str]) -> None:
83 # strings are iterable, but not in the way that is intended. If a
84 # single name is specified, turn it into a tuple before attempting
85 # to remove the attribute
86 if isinstance(value, str):
87 value = (value,)
88 for name in value:
89 delattr(instance, name)
91 def __get__(self, instance, objtype=None) -> None:
92 # This descriptor does not support fetching any value
93 return None
96class ConfigurableActionStruct(Generic[ActionTypeVar]):
97 """A ConfigurableActionStruct is the storage backend class that supports
98 the ConfigurableActionStructField. This class should not be created
99 directly.
101 This class allows managing a collection of `ConfigurableAction` with a
102 struct like interface, that is to say in an attribute like notation.
104 Parameters
105 ----------
106 config : `~lsst.pex.config.Config`
107 Config to use.
108 field : `ConfigurableActionStructField`
109 Field to use.
110 value : `~collections.abc.Mapping` [`str`, `ConfigurableAction`]
111 Value to assign.
112 at : `list` of `~lsst.pex.config.callStack.StackFrame` or `None`, optional
113 Stack frames to use for history recording.
114 label : `str`, optional
115 Label to use for history recording.
117 Notes
118 -----
119 Attributes can be dynamically added or removed as such:
121 .. code-block:: python
123 ConfigurableActionStructInstance.variable1 = a_configurable_action
124 del ConfigurableActionStructInstance.variable1
126 Each action is then available to be individually configured as a normal
127 `lsst.pex.config.Config` object.
129 `ConfigurableActionStruct` supports two special convenience attributes.
131 The first is ``update``. You may assign a dict of `ConfigurableAction` or a
132 `ConfigurableActionStruct` to this attribute which will update the
133 `ConfigurableActionStruct` on which the attribute is invoked such that it
134 will be updated to contain the entries specified by the structure on the
135 right hand side of the equals sign.
137 The second convenience attribute is named ``remove``. You may assign an
138 iterable of strings which correspond to attribute names on the
139 `ConfigurableActionStruct`. All of the corresponding attributes will then
140 be removed. If any attribute does not exist, an `AttributeError` will be
141 raised. Any attributes in the Iterable prior to the name which raises will
142 have been removed from the `ConfigurableActionStruct`
143 """
145 # declare attributes that are set with __setattr__
146 _config_: weakref.ref
147 _attrs: dict[str, ActionTypeVar]
148 _field: ConfigurableActionStructField
149 _history: list[tuple]
151 # create descriptors to handle special update and remove behavior
152 update = ConfigurableActionStructUpdater()
153 remove = ConfigurableActionStructRemover()
155 def __init__(
156 self,
157 config: Config,
158 field: ConfigurableActionStructField,
159 value: Mapping[str, ConfigurableAction],
160 at: Any,
161 label: str,
162 ):
163 object.__setattr__(self, "_config_", weakref.ref(config))
164 object.__setattr__(self, "_attrs", {})
165 object.__setattr__(self, "_field", field)
166 object.__setattr__(self, "_history", [])
168 self.history.append(("Struct initialized", at, label))
170 if value is not None:
171 for k, v in value.items():
172 setattr(self, k, v)
174 @property
175 def _config(self) -> Config:
176 # Config Fields should never outlive their config class instance
177 # assert that as such here
178 value = self._config_()
179 assert value is not None
180 return value
182 @property
183 def history(self) -> list[tuple]:
184 return self._history
186 @property
187 def fieldNames(self) -> Iterable[str]:
188 return self._attrs.keys()
190 def __setattr__(
191 self,
192 attr: str,
193 value: ActionTypeVar | type[ActionTypeVar],
194 at=None,
195 label="setattr",
196 setHistory=False,
197 ) -> None:
198 if hasattr(self._config, "_frozen") and self._config._frozen:
199 msg = "Cannot modify a frozen Config. " f"Attempting to set item {attr} to value {value}"
200 raise FieldValidationError(self._field, self._config, msg)
202 # verify that someone has not passed a string with a space or leading
203 # number or something through the dict assignment update interface
204 if not attr.isidentifier():
205 raise ValueError("Names used in ConfigurableStructs must be valid as python variable names")
207 if attr not in (self.__dict__.keys() | type(self).__dict__.keys()):
208 base_name = _joinNamePath(self._config._name, self._field.name)
209 name = _joinNamePath(base_name, attr)
210 if at is None:
211 at = getCallStack()
212 if isinstance(value, ConfigurableAction):
213 valueInst = type(value)(__name=name, __at=at, __label=label, **value._storage)
214 else:
215 valueInst = value(__name=name, __at=at, __label=label)
216 self._attrs[attr] = valueInst
217 else:
218 super().__setattr__(attr, value)
220 def __getattr__(self, attr) -> Any:
221 if attr in object.__getattribute__(self, "_attrs"):
222 result = self._attrs[attr]
223 result.identity = attr
224 return result
225 else:
226 super().__getattribute__(attr)
228 def __delattr__(self, name):
229 if name in self._attrs:
230 del self._attrs[name]
231 else:
232 super().__delattr__(name)
234 def __iter__(self) -> Iterator[ActionTypeVar]:
235 for name in self.fieldNames:
236 yield getattr(self, name)
238 def items(self) -> Iterable[tuple[str, ActionTypeVar]]:
239 for name in self.fieldNames:
240 yield name, getattr(self, name)
242 def __bool__(self) -> bool:
243 return bool(self._attrs)
246T = TypeVar("T", bound="ConfigurableActionStructField")
249class ConfigurableActionStructField(Field[ActionTypeVar]):
250 """`ConfigurableActionStructField` is a `~lsst.pex.config.Field` subclass
251 that allows a `ConfigurableAction` to be organized in a
252 `~lsst.pex.config.Config` class in a manner similar to how a
253 `~lsst.pipe.base.Struct` works.
255 This class uses a `ConfigurableActionStruct` as an intermediary object to
256 organize the `ConfigurableAction`. See its documentation for further
257 information.
259 Parameters
260 ----------
261 doc : `str`
262 Documentation string.
263 default : `~collections.abc.Mapping` [ `str`, `ConfigurableAction` ] \
264 or `None`, optional
265 Default value.
266 optional : `bool`, optional
267 If `True`, the field doesn't need to have a set value.
268 deprecated : `bool` or `None`, optional
269 A description of why this Field is deprecated, including removal date.
270 If not `None`, the string is appended to the docstring for this Field.
271 """
273 # specify StructClass to make this more generic for potential future
274 # inheritance
275 StructClass = ConfigurableActionStruct
277 # Explicitly annotate these on the class, they are present in the base
278 # class through injection, so type systems have trouble seeing them.
279 name: str
280 default: Mapping[str, ConfigurableAction] | None
282 def __init__(
283 self,
284 doc: str,
285 default: Mapping[str, ConfigurableAction] | None = None,
286 optional: bool = False,
287 deprecated=None,
288 ):
289 source = getStackFrame()
290 self._setup(
291 doc=doc,
292 dtype=self.__class__,
293 default=default,
294 check=None,
295 optional=optional,
296 source=source,
297 deprecated=deprecated,
298 )
300 def __class_getitem__(cls, params):
301 return GenericAlias(cls, params)
303 def __set__(
304 self,
305 instance: Config,
306 value: (
307 None
308 | Mapping[str, ConfigurableAction]
309 | SimpleNamespace
310 | ConfigurableActionStruct
311 | ConfigurableActionStructField
312 | type[ConfigurableActionStructField]
313 ),
314 at: Iterable[StackFrame] = None,
315 label: str = "assigment",
316 ):
317 if instance._frozen:
318 msg = "Cannot modify a frozen Config. " "Attempting to set field to value %s" % value
319 raise FieldValidationError(self, instance, msg)
321 if at is None:
322 at = getCallStack()
324 if value is None or (self.default is not None and self.default == value):
325 value = self.StructClass(instance, self, value, at=at, label=label)
326 else:
327 # An actual value is being assigned check for what it is
328 if isinstance(value, self.StructClass):
329 # If this is a ConfigurableActionStruct, we need to make our
330 # own copy that references this current field
331 value = self.StructClass(instance, self, value._attrs, at=at, label=label)
332 elif isinstance(value, SimpleNamespace):
333 # If this is a a python analogous container, we need to make
334 # a ConfigurableActionStruct initialized with this data
335 value = self.StructClass(instance, self, vars(value), at=at, label=label)
337 elif type(value) is ConfigurableActionStructField:
338 raise ValueError(
339 "ConfigurableActionStructFields can only be used in a class body declaration"
340 f"Use a {self.StructClass}, SimpleNamespace or Struct"
341 )
342 else:
343 raise ValueError(f"Unrecognized value {value}, cannot be assigned to this field")
345 history = instance._history.setdefault(self.name, [])
346 history.append((value, at, label))
348 if not isinstance(value, ConfigurableActionStruct):
349 raise FieldValidationError(
350 self, instance, "Can only assign things that are subclasses of Configurable Action"
351 )
352 instance._storage[self.name] = value
354 @overload
355 def __get__(
356 self, instance: None, owner: Any = None, at: Any = None, label: str = "default"
357 ) -> ConfigurableActionStruct[ActionTypeVar]:
358 ...
360 @overload
361 def __get__(
362 self, instance: Config, owner: Any = None, at: Any = None, label: str = "default"
363 ) -> ConfigurableActionStruct[ActionTypeVar]:
364 ...
366 def __get__(self, instance, owner=None, at=None, label="default"):
367 if instance is None or not isinstance(instance, Config):
368 return self
369 else:
370 field: ConfigurableActionStruct | None = instance._storage[self.name]
371 return field
373 def rename(self, instance: Config):
374 actionStruct: ConfigurableActionStruct = self.__get__(instance)
375 if actionStruct is not None:
376 for k, v in actionStruct.items():
377 base_name = _joinNamePath(instance._name, self.name)
378 fullname = _joinNamePath(base_name, k)
379 v._rename(fullname)
381 def validate(self, instance: Config):
382 value = self.__get__(instance)
383 if value is not None:
384 for item in value:
385 item.validate()
387 def toDict(self, instance):
388 actionStruct = self.__get__(instance)
389 if actionStruct is None:
390 return None
392 dict_ = {k: v.toDict() for k, v in actionStruct.items()}
394 return dict_
396 def save(self, outfile, instance):
397 actionStruct = self.__get__(instance)
398 fullname = _joinNamePath(instance._name, self.name)
400 # Ensure that a struct is always empty before assigning to it.
401 outfile.write(f"{fullname}=None\n")
403 if actionStruct is None:
404 return
406 for _, v in sorted(actionStruct.items()):
407 outfile.write(f"{v._name}={_typeStr(v)}()\n")
408 v._save(outfile)
410 def freeze(self, instance):
411 actionStruct = self.__get__(instance)
412 if actionStruct is not None:
413 for v in actionStruct:
414 v.freeze()
416 def _collectImports(self, instance, imports):
417 # docstring inherited from Field
418 actionStruct = self.__get__(instance)
419 for v in actionStruct:
420 v._collectImports()
421 imports |= v._imports
423 def _compare(self, instance1, instance2, shortcut, rtol, atol, output):
424 """Compare two fields for equality.
426 Parameters
427 ----------
428 instance1 : `lsst.pex.config.Config`
429 Left-hand side config instance to compare.
430 instance2 : `lsst.pex.config.Config`
431 Right-hand side config instance to compare.
432 shortcut : `bool`
433 If `True`, this function returns as soon as an inequality if found.
434 rtol : `float`
435 Relative tolerance for floating point comparisons.
436 atol : `float`
437 Absolute tolerance for floating point comparisons.
438 output : callable
439 A callable that takes a string, used (possibly repeatedly) to
440 report inequalities.
442 Returns
443 -------
444 isEqual : bool
445 `True` if the fields are equal, `False` otherwise.
447 Notes
448 -----
449 Floating point comparisons are performed by `numpy.allclose`.
450 """
451 d1: ConfigurableActionStruct = getattr(instance1, self.name)
452 d2: ConfigurableActionStruct = getattr(instance2, self.name)
453 name = getComparisonName(
454 _joinNamePath(instance1._name, self.name), _joinNamePath(instance2._name, self.name)
455 )
456 if not compareScalars(f"keys for {name}", set(d1.fieldNames), set(d2.fieldNames), output=output):
457 return False
458 equal = True
459 for k, v1 in d1.items():
460 v2 = getattr(d2, k)
461 result = compareConfigs(
462 f"{name}.{k}", v1, v2, shortcut=shortcut, rtol=rtol, atol=atol, output=output
463 )
464 if not result and shortcut:
465 return False
466 equal = equal and result
467 return equal