Coverage for python/lsst/pex/config/configurableActions/_configurableActionStructField.py: 22%
181 statements
« prev ^ index » next coverage.py v7.2.7, created at 2023-06-15 02:35 -0700
« prev ^ index » next coverage.py v7.2.7, created at 2023-06-15 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# (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 types import GenericAlias, SimpleNamespace
27from typing import (
28 Any,
29 Dict,
30 Generic,
31 Iterable,
32 Iterator,
33 List,
34 Mapping,
35 Optional,
36 Tuple,
37 Type,
38 TypeVar,
39 Union,
40 overload,
41)
43from lsst.pex.config.callStack import StackFrame, getCallStack, getStackFrame
44from lsst.pex.config.comparison import compareConfigs, compareScalars, getComparisonName
45from lsst.pex.config.config import Config, Field, FieldValidationError, _joinNamePath, _typeStr
47from . import ActionTypeVar, ConfigurableAction
50class ConfigurableActionStructUpdater:
51 """This descriptor exists to abstract the logic of using a dictionary to
52 update a ConfigurableActionStruct through attribute assignment. This is
53 useful in the context of setting configuration through pipelines or on
54 the command line.
55 """
57 def __set__(
58 self,
59 instance: ConfigurableActionStruct,
60 value: Union[Mapping[str, ConfigurableAction], ConfigurableActionStruct],
61 ) -> None:
62 if isinstance(value, Mapping):
63 pass
64 elif isinstance(value, ConfigurableActionStruct):
65 # If the update target is a ConfigurableActionStruct, get the
66 # internal dictionary
67 value = value._attrs
68 else:
69 raise ValueError(
70 "Can only update a ConfigurableActionStruct with an instance of such, or a " "mapping"
71 )
72 for name, action in value.items():
73 setattr(instance, name, action)
75 def __get__(self, instance, objtype=None) -> None:
76 # This descriptor does not support fetching any value
77 return None
80class ConfigurableActionStructRemover:
81 """This descriptor exists to abstract the logic of removing an iterable
82 of action names from a ConfigurableActionStruct at one time using
83 attribute assignment. This is useful in the context of setting
84 configuration through pipelines or on the command line.
86 Raises
87 ------
88 AttributeError
89 Raised if an attribute specified for removal does not exist in the
90 ConfigurableActionStruct
91 """
93 def __set__(self, instance: ConfigurableActionStruct, value: Union[str, Iterable[str]]) -> None:
94 # strings are iterable, but not in the way that is intended. If a
95 # single name is specified, turn it into a tuple before attempting
96 # to remove the attribute
97 if isinstance(value, str):
98 value = (value,)
99 for name in value:
100 delattr(instance, name)
102 def __get__(self, instance, objtype=None) -> None:
103 # This descriptor does not support fetching any value
104 return None
107class ConfigurableActionStruct(Generic[ActionTypeVar]):
108 """A ConfigurableActionStruct is the storage backend class that supports
109 the ConfigurableActionStructField. This class should not be created
110 directly.
112 This class allows managing a collection of `ConfigurableActions` with a
113 struct like interface, that is to say in an attribute like notation.
115 Attributes can be dynamically added or removed as such:
117 ConfigurableActionStructInstance.variable1 = a_configurable_action
118 del ConfigurableActionStructInstance.variable1
120 Each action is then available to be individually configured as a normal
121 `lsst.pex.config.Config` object.
123 ConfigurableActionStruct supports two special convenience attributes.
125 The first is ``update``. You may assign a dict of `ConfigurableActions` or
126 a `ConfigurableActionStruct` to this attribute which will update the
127 `ConfigurableActionStruct` on which the attribute is invoked such that it
128 will be updated to contain the entries specified by the structure on the
129 right hand side of the equals sign.
131 The second convenience attribute is named ``remove``. You may assign an
132 iterable of strings which correspond to attribute names on the
133 `ConfigurableActionStruct`. All of the corresponding attributes will then
134 be removed. If any attribute does not exist, an `AttributeError` will be
135 raised. Any attributes in the Iterable prior to the name which raises will
136 have been removed from the `ConfigurableActionStruct`
137 """
139 # declare attributes that are set with __setattr__
140 _config_: weakref.ref
141 _attrs: Dict[str, ActionTypeVar]
142 _field: ConfigurableActionStructField
143 _history: List[tuple]
145 # create descriptors to handle special update and remove behavior
146 update = ConfigurableActionStructUpdater()
147 remove = ConfigurableActionStructRemover()
149 def __init__(
150 self,
151 config: Config,
152 field: ConfigurableActionStructField,
153 value: Mapping[str, ConfigurableAction],
154 at: Any,
155 label: str,
156 ):
157 object.__setattr__(self, "_config_", weakref.ref(config))
158 object.__setattr__(self, "_attrs", {})
159 object.__setattr__(self, "_field", field)
160 object.__setattr__(self, "_history", [])
162 self.history.append(("Struct initialized", at, label))
164 if value is not None:
165 for k, v in value.items():
166 setattr(self, k, v)
168 @property
169 def _config(self) -> Config:
170 # Config Fields should never outlive their config class instance
171 # assert that as such here
172 value = self._config_()
173 assert value is not None
174 return value
176 @property
177 def history(self) -> List[tuple]:
178 return self._history
180 @property
181 def fieldNames(self) -> Iterable[str]:
182 return self._attrs.keys()
184 def __setattr__(
185 self,
186 attr: str,
187 value: Union[ActionTypeVar, Type[ActionTypeVar]],
188 at=None,
189 label="setattr",
190 setHistory=False,
191 ) -> None:
192 if hasattr(self._config, "_frozen") and self._config._frozen:
193 msg = "Cannot modify a frozen Config. " f"Attempting to set item {attr} to value {value}"
194 raise FieldValidationError(self._field, self._config, msg)
196 # verify that someone has not passed a string with a space or leading
197 # number or something through the dict assignment update interface
198 if not attr.isidentifier():
199 raise ValueError("Names used in ConfigurableStructs must be valid as python variable names")
201 if attr not in (self.__dict__.keys() | type(self).__dict__.keys()):
202 base_name = _joinNamePath(self._config._name, self._field.name)
203 name = _joinNamePath(base_name, attr)
204 if at is None:
205 at = getCallStack()
206 if isinstance(value, ConfigurableAction):
207 valueInst = type(value)(__name=name, __at=at, __label=label, **value._storage)
208 else:
209 valueInst = value(__name=name, __at=at, __label=label)
210 self._attrs[attr] = valueInst
211 else:
212 super().__setattr__(attr, value)
214 def __getattr__(self, attr) -> Any:
215 if attr in object.__getattribute__(self, "_attrs"):
216 result = self._attrs[attr]
217 result.identity = attr
218 return result
219 else:
220 super().__getattribute__(attr)
222 def __delattr__(self, name):
223 if name in self._attrs:
224 del self._attrs[name]
225 else:
226 super().__delattr__(name)
228 def __iter__(self) -> Iterator[ActionTypeVar]:
229 for name in self.fieldNames:
230 yield getattr(self, name)
232 def items(self) -> Iterable[Tuple[str, ActionTypeVar]]:
233 for name in self.fieldNames:
234 yield name, getattr(self, name)
236 def __bool__(self) -> bool:
237 return bool(self._attrs)
240T = TypeVar("T", bound="ConfigurableActionStructField")
243class ConfigurableActionStructField(Field[ActionTypeVar]):
244 r"""`ConfigurableActionStructField` is a `~lsst.pex.config.Field` subclass
245 that allows `ConfigurableAction`\ s to be organized in a
246 `~lsst.pex.config.Config` class in a manner similar to how a
247 `~lsst.pipe.base.Struct` works.
249 This class uses a `ConfigurableActionStruct` as an intermediary
250 object to organize the `ConfigurableActions`. See its documentation for
251 further information.
252 """
253 # specify StructClass to make this more generic for potential future
254 # inheritance
255 StructClass = ConfigurableActionStruct
257 # Explicitly annotate these on the class, they are present in the base
258 # class through injection, so type systems have trouble seeing them.
259 name: str
260 default: Optional[Mapping[str, ConfigurableAction]]
262 def __init__(
263 self,
264 doc: str,
265 default: Optional[Mapping[str, ConfigurableAction]] = None,
266 optional: bool = False,
267 deprecated=None,
268 ):
269 source = getStackFrame()
270 self._setup(
271 doc=doc,
272 dtype=self.__class__,
273 default=default,
274 check=None,
275 optional=optional,
276 source=source,
277 deprecated=deprecated,
278 )
280 def __class_getitem__(cls, params):
281 return GenericAlias(cls, params)
283 def __set__(
284 self,
285 instance: Config,
286 value: Union[
287 None,
288 Mapping[str, ConfigurableAction],
289 SimpleNamespace,
290 ConfigurableActionStruct,
291 ConfigurableActionStructField,
292 Type[ConfigurableActionStructField],
293 ],
294 at: Iterable[StackFrame] = None,
295 label: str = "assigment",
296 ):
297 if instance._frozen:
298 msg = "Cannot modify a frozen Config. " "Attempting to set field to value %s" % value
299 raise FieldValidationError(self, instance, msg)
301 if at is None:
302 at = getCallStack()
304 if value is None or (self.default is not None and self.default == value):
305 value = self.StructClass(instance, self, value, at=at, label=label)
306 else:
307 # An actual value is being assigned check for what it is
308 if isinstance(value, self.StructClass):
309 # If this is a ConfigurableActionStruct, we need to make our
310 # own copy that references this current field
311 value = self.StructClass(instance, self, value._attrs, at=at, label=label)
312 elif isinstance(value, SimpleNamespace):
313 # If this is a a python analogous container, we need to make
314 # a ConfigurableActionStruct initialized with this data
315 value = self.StructClass(instance, self, vars(value), at=at, label=label)
317 elif type(value) == ConfigurableActionStructField:
318 raise ValueError(
319 "ConfigurableActionStructFields can only be used in a class body declaration"
320 f"Use a {self.StructClass}, SimpleNamespace or Struct"
321 )
322 else:
323 raise ValueError(f"Unrecognized value {value}, cannot be assigned to this field")
325 history = instance._history.setdefault(self.name, [])
326 history.append((value, at, label))
328 if not isinstance(value, ConfigurableActionStruct):
329 raise FieldValidationError(
330 self, instance, "Can only assign things that are subclasses of Configurable Action"
331 )
332 instance._storage[self.name] = value
334 @overload
335 def __get__(
336 self, instance: None, owner: Any = None, at: Any = None, label: str = "default"
337 ) -> ConfigurableActionStruct[ActionTypeVar]:
338 ...
340 @overload
341 def __get__(
342 self, instance: Config, owner: Any = None, at: Any = None, label: str = "default"
343 ) -> ConfigurableActionStruct[ActionTypeVar]:
344 ...
346 def __get__(self, instance, owner=None, at=None, label="default"):
347 if instance is None or not isinstance(instance, Config):
348 return self
349 else:
350 field: Optional[ConfigurableActionStruct] = instance._storage[self.name]
351 return field
353 def rename(self, instance: Config):
354 actionStruct: ConfigurableActionStruct = self.__get__(instance)
355 if actionStruct is not None:
356 for k, v in actionStruct.items():
357 base_name = _joinNamePath(instance._name, self.name)
358 fullname = _joinNamePath(base_name, k)
359 v._rename(fullname)
361 def validate(self, instance: Config):
362 value = self.__get__(instance)
363 if value is not None:
364 for item in value:
365 item.validate()
367 def toDict(self, instance):
368 actionStruct = self.__get__(instance)
369 if actionStruct is None:
370 return None
372 dict_ = {k: v.toDict() for k, v in actionStruct.items()}
374 return dict_
376 def save(self, outfile, instance):
377 actionStruct = self.__get__(instance)
378 fullname = _joinNamePath(instance._name, self.name)
380 # Ensure that a struct is always empty before assigning to it.
381 outfile.write(f"{fullname}=None\n")
383 if actionStruct is None:
384 return
386 for _, v in sorted(actionStruct.items()):
387 outfile.write("{}={}()\n".format(v._name, _typeStr(v)))
388 v._save(outfile)
390 def freeze(self, instance):
391 actionStruct = self.__get__(instance)
392 if actionStruct is not None:
393 for v in actionStruct:
394 v.freeze()
396 def _collectImports(self, instance, imports):
397 # docstring inherited from Field
398 actionStruct = self.__get__(instance)
399 for v in actionStruct:
400 v._collectImports()
401 imports |= v._imports
403 def _compare(self, instance1, instance2, shortcut, rtol, atol, output):
404 """Compare two fields for equality.
406 Parameters
407 ----------
408 instance1 : `lsst.pex.config.Config`
409 Left-hand side config instance to compare.
410 instance2 : `lsst.pex.config.Config`
411 Right-hand side config instance to compare.
412 shortcut : `bool`
413 If `True`, this function returns as soon as an inequality if found.
414 rtol : `float`
415 Relative tolerance for floating point comparisons.
416 atol : `float`
417 Absolute tolerance for floating point comparisons.
418 output : callable
419 A callable that takes a string, used (possibly repeatedly) to
420 report inequalities.
422 Returns
423 -------
424 isEqual : bool
425 `True` if the fields are equal, `False` otherwise.
427 Notes
428 -----
429 Floating point comparisons are performed by `numpy.allclose`.
430 """
431 d1: ConfigurableActionStruct = getattr(instance1, self.name)
432 d2: ConfigurableActionStruct = getattr(instance2, self.name)
433 name = getComparisonName(
434 _joinNamePath(instance1._name, self.name), _joinNamePath(instance2._name, self.name)
435 )
436 if not compareScalars(f"keys for {name}", set(d1.fieldNames), set(d2.fieldNames), output=output):
437 return False
438 equal = True
439 for k, v1 in d1.items():
440 v2 = getattr(d2, k)
441 result = compareConfigs(
442 f"{name}.{k}", v1, v2, shortcut=shortcut, rtol=rtol, atol=atol, output=output
443 )
444 if not result and shortcut:
445 return False
446 equal = equal and result
447 return equal