Coverage for python / lsst / pex / config / configurableActions / _configurableActionStructField.py: 21%
186 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-26 08:53 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-26 08:53 +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__ = ("ConfigurableActionStruct", "ConfigurableActionStructField")
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 if at is not None:
169 self.history.append(("Struct initialized", at, label))
171 if value is not None:
172 for k, v in value.items():
173 setattr(self, k, v)
175 def _copy(self, config: Config) -> ConfigurableActionStruct:
176 result = ConfigurableActionStruct(config, self._field, self._attrs, at=None, label="copy")
177 result.history.extend(self.history)
178 return result
180 @property
181 def _config(self) -> Config:
182 # Config Fields should never outlive their config class instance
183 # assert that as such here
184 value = self._config_()
185 assert value is not None
186 return value
188 @property
189 def history(self) -> list[tuple]:
190 return self._history
192 @property
193 def fieldNames(self) -> Iterable[str]:
194 return self._attrs.keys()
196 def __setattr__(
197 self,
198 attr: str,
199 value: ActionTypeVar | type[ActionTypeVar],
200 at=None,
201 label="setattr",
202 setHistory=False,
203 ) -> None:
204 if hasattr(self._config, "_frozen") and self._config._frozen:
205 msg = f"Cannot modify a frozen Config. Attempting to set item {attr} to value {value}"
206 raise FieldValidationError(self._field, self._config, msg)
208 # verify that someone has not passed a string with a space or leading
209 # number or something through the dict assignment update interface
210 if not attr.isidentifier():
211 raise ValueError("Names used in ConfigurableStructs must be valid as python variable names")
213 if attr not in (self.__dict__.keys() | type(self).__dict__.keys()):
214 base_name = _joinNamePath(self._config._name, self._field.name)
215 name = _joinNamePath(base_name, attr)
216 if at is None:
217 at = getCallStack()
218 if isinstance(value, ConfigurableAction):
219 valueInst = type(value)(__name=name, __at=at, __label=label, **value._storage)
220 else:
221 valueInst = value(__name=name, __at=at, __label=label)
222 self._attrs[attr] = valueInst
223 else:
224 super().__setattr__(attr, value)
226 def __getattr__(self, attr) -> Any:
227 if attr in object.__getattribute__(self, "_attrs"):
228 result = self._attrs[attr]
229 result.identity = attr
230 return result
231 else:
232 super().__getattribute__(attr)
234 def __delattr__(self, name):
235 if name in self._attrs:
236 del self._attrs[name]
237 else:
238 super().__delattr__(name)
240 def __iter__(self) -> Iterator[ActionTypeVar]:
241 for name in self.fieldNames:
242 yield getattr(self, name)
244 def items(self) -> Iterable[tuple[str, ActionTypeVar]]:
245 for name in self.fieldNames:
246 yield name, getattr(self, name)
248 def __bool__(self) -> bool:
249 return bool(self._attrs)
252T = TypeVar("T", bound="ConfigurableActionStructField")
255class ConfigurableActionStructField(Field[ActionTypeVar]):
256 """`ConfigurableActionStructField` is a `~lsst.pex.config.Field` subclass
257 that allows a `ConfigurableAction` to be organized in a
258 `~lsst.pex.config.Config` class in a manner similar to how a
259 `~lsst.pipe.base.Struct` works.
261 This class uses a `ConfigurableActionStruct` as an intermediary object to
262 organize the `ConfigurableAction`. See its documentation for further
263 information.
265 Parameters
266 ----------
267 doc : `str`
268 Documentation string.
269 default : `~collections.abc.Mapping` [ `str`, `ConfigurableAction` ] \
270 or `None`, optional
271 Default value.
272 optional : `bool`, optional
273 If `True`, the field doesn't need to have a set value.
274 deprecated : `bool` or `None`, optional
275 A description of why this Field is deprecated, including removal date.
276 If not `None`, the string is appended to the docstring for this Field.
277 """
279 # specify StructClass to make this more generic for potential future
280 # inheritance
281 StructClass = ConfigurableActionStruct
283 # Explicitly annotate these on the class, they are present in the base
284 # class through injection, so type systems have trouble seeing them.
285 name: str
286 default: Mapping[str, ConfigurableAction] | None
288 def __init__(
289 self,
290 doc: str,
291 default: Mapping[str, ConfigurableAction] | None = None,
292 optional: bool = False,
293 deprecated=None,
294 ):
295 source = getStackFrame()
296 self._setup(
297 doc=doc,
298 dtype=self.__class__,
299 default=default,
300 check=None,
301 optional=optional,
302 source=source,
303 deprecated=deprecated,
304 )
306 def __class_getitem__(cls, params):
307 return GenericAlias(cls, params)
309 def __set__(
310 self,
311 instance: Config,
312 value: (
313 None
314 | Mapping[str, ConfigurableAction]
315 | SimpleNamespace
316 | ConfigurableActionStruct
317 | ConfigurableActionStructField
318 | type[ConfigurableActionStructField]
319 ),
320 at: Iterable[StackFrame] = None,
321 label: str = "assigment",
322 ):
323 if instance._frozen:
324 msg = f"Cannot modify a frozen Config. Attempting to set field to value {value}"
325 raise FieldValidationError(self, instance, msg)
327 if at is None:
328 at = getCallStack()
330 if value is None or (self.default is not None and self.default == value):
331 value = self.StructClass(instance, self, value, at=at, label=label)
332 else:
333 # An actual value is being assigned check for what it is
334 if isinstance(value, self.StructClass):
335 # If this is a ConfigurableActionStruct, we need to make our
336 # own copy that references this current field
337 value = self.StructClass(instance, self, value._attrs, at=at, label=label)
338 elif isinstance(value, SimpleNamespace):
339 # If this is a a python analogous container, we need to make
340 # a ConfigurableActionStruct initialized with this data
341 value = self.StructClass(instance, self, vars(value), at=at, label=label)
343 elif type(value) is ConfigurableActionStructField:
344 raise ValueError(
345 "ConfigurableActionStructFields can only be used in a class body declaration"
346 f"Use a {self.StructClass}, SimpleNamespace or Struct"
347 )
348 else:
349 raise ValueError(f"Unrecognized value {value}, cannot be assigned to this field")
351 history = instance._history.setdefault(self.name, [])
352 history.append((value, at, label))
354 if not isinstance(value, ConfigurableActionStruct):
355 raise FieldValidationError(
356 self, instance, "Can only assign things that are subclasses of Configurable Action"
357 )
358 instance._storage[self.name] = value
360 @overload
361 def __get__(
362 self, instance: None, owner: Any = None, at: Any = None, label: str = "default"
363 ) -> ConfigurableActionStruct[ActionTypeVar]: ...
365 @overload
366 def __get__(
367 self, instance: Config, owner: Any = None, at: Any = None, label: str = "default"
368 ) -> ConfigurableActionStruct[ActionTypeVar]: ...
370 def __get__(self, instance, owner=None, at=None, label="default"):
371 if instance is None or not isinstance(instance, Config):
372 return self
373 else:
374 field: ConfigurableActionStruct | None = instance._storage[self.name]
375 return field
377 def rename(self, instance: Config):
378 actionStruct: ConfigurableActionStruct = self.__get__(instance)
379 if actionStruct is not None:
380 for k, v in actionStruct.items():
381 base_name = _joinNamePath(instance._name, self.name)
382 fullname = _joinNamePath(base_name, k)
383 v._rename(fullname)
385 def validate(self, instance: Config):
386 value = self.__get__(instance)
387 if value is not None:
388 for item in value:
389 item.validate()
391 def toDict(self, instance):
392 actionStruct = self.__get__(instance)
393 if actionStruct is None:
394 return None
396 dict_ = {k: v.toDict() for k, v in actionStruct.items()}
398 return dict_
400 def _copy_storage(self, old: Config, new: Config) -> ConfigurableActionStruct:
401 struct: ConfigurableActionStruct | None = old._storage.get(self.name)
402 if struct is not None:
403 return struct._copy(new)
404 else:
405 return None
407 def save(self, outfile, instance):
408 actionStruct = self.__get__(instance)
409 fullname = _joinNamePath(instance._name, self.name)
411 # Ensure that a struct is always empty before assigning to it.
412 outfile.write(f"{fullname}=None\n")
414 if actionStruct is None:
415 return
417 for _, v in sorted(actionStruct.items()):
418 outfile.write(f"{v._name}={_typeStr(v)}()\n")
419 v._save(outfile)
421 def freeze(self, instance):
422 actionStruct = self.__get__(instance)
423 if actionStruct is not None:
424 for v in actionStruct:
425 v.freeze()
427 def _collectImports(self, instance, imports):
428 # docstring inherited from Field
429 actionStruct = self.__get__(instance)
430 for v in actionStruct:
431 v._collectImports()
432 imports |= v._imports
434 def _compare(self, instance1, instance2, shortcut, rtol, atol, output):
435 """Compare two fields for equality.
437 Parameters
438 ----------
439 instance1 : `lsst.pex.config.Config`
440 Left-hand side config instance to compare.
441 instance2 : `lsst.pex.config.Config`
442 Right-hand side config instance to compare.
443 shortcut : `bool`
444 If `True`, this function returns as soon as an inequality if found.
445 rtol : `float`
446 Relative tolerance for floating point comparisons.
447 atol : `float`
448 Absolute tolerance for floating point comparisons.
449 output : `collections.abc.Callable`
450 A callable that takes a string, used (possibly repeatedly) to
451 report inequalities.
453 Returns
454 -------
455 isEqual : bool
456 `True` if the fields are equal, `False` otherwise.
458 Notes
459 -----
460 Floating point comparisons are performed by `numpy.allclose`.
461 """
462 d1: ConfigurableActionStruct = getattr(instance1, self.name)
463 d2: ConfigurableActionStruct = getattr(instance2, self.name)
464 name = getComparisonName(
465 _joinNamePath(instance1._name, self.name), _joinNamePath(instance2._name, self.name)
466 )
467 if not compareScalars(f"{name} (fields)", set(d1.fieldNames), set(d2.fieldNames), output=output):
468 return False
469 equal = True
470 for k, v1 in d1.items():
471 v2 = getattr(d2, k)
472 result = compareConfigs(
473 f"{name}.{k}", v1, v2, shortcut=shortcut, rtol=rtol, atol=atol, output=output
474 )
475 if not result and shortcut:
476 return False
477 equal = equal and result
478 return equal