Coverage for python/lsst/pex/config/configurableField.py: 30%
165 statements
« prev ^ index » next coverage.py v6.4.1, created at 2022-06-23 02:28 -0700
« prev ^ index » next coverage.py v6.4.1, created at 2022-06-23 02:28 -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# (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__ = ("ConfigurableInstance", "ConfigurableField")
30import copy
31import weakref
33from .callStack import getCallStack, getStackFrame
34from .comparison import compareConfigs, getComparisonName
35from .config import Config, Field, FieldValidationError, UnexpectedProxyUsageError, _joinNamePath, _typeStr
38class ConfigurableInstance:
39 """A retargetable configuration in a `ConfigurableField` that proxies
40 a `~lsst.pex.config.Config`.
42 Notes
43 -----
44 ``ConfigurableInstance`` implements ``__getattr__`` and ``__setattr__``
45 methods that forward to the `~lsst.pex.config.Config` it holds.
46 ``ConfigurableInstance`` adds a `retarget` method.
48 The actual `~lsst.pex.config.Config` instance is accessed using the
49 ``value`` property (e.g. to get its documentation). The associated
50 configurable object (usually a `~lsst.pipe.base.Task`) is accessed
51 using the ``target`` property.
52 """
54 def __initValue(self, at, label):
55 """Construct value of field.
57 Notes
58 -----
59 If field.default is an instance of `lsst.pex.config.ConfigClass`,
60 custom construct ``_value`` with the correct values from default.
61 Otherwise, call ``ConfigClass`` constructor
62 """
63 name = _joinNamePath(self._config._name, self._field.name)
64 if type(self._field.default) == self.ConfigClass:
65 storage = self._field.default._storage
66 else:
67 storage = {}
68 value = self._ConfigClass(__name=name, __at=at, __label=label, **storage)
69 object.__setattr__(self, "_value", value)
71 def __init__(self, config, field, at=None, label="default"):
72 object.__setattr__(self, "_config_", weakref.ref(config))
73 object.__setattr__(self, "_field", field)
74 object.__setattr__(self, "__doc__", config)
75 object.__setattr__(self, "_target", field.target)
76 object.__setattr__(self, "_ConfigClass", field.ConfigClass)
77 object.__setattr__(self, "_value", None)
79 if at is None:
80 at = getCallStack()
81 at += [self._field.source]
82 self.__initValue(at, label)
84 history = config._history.setdefault(field.name, [])
85 history.append(("Targeted and initialized from defaults", 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 target = property(lambda x: x._target) 94 ↛ exitline 94 didn't run the lambda on line 94
95 """The targeted configurable (read-only).
96 """
98 ConfigClass = property(lambda x: x._ConfigClass) 98 ↛ exitline 98 didn't run the lambda on line 98
99 """The configuration class (read-only)
100 """
102 value = property(lambda x: x._value) 102 ↛ exitline 102 didn't run the lambda on line 102
103 """The `ConfigClass` instance (`lsst.pex.config.ConfigClass`-type,
104 read-only).
105 """
107 def apply(self, *args, **kw):
108 """Call the configurable.
110 Notes
111 -----
112 In addition to the user-provided positional and keyword arguments,
113 the configurable is also provided a keyword argument ``config`` with
114 the value of `ConfigurableInstance.value`.
115 """
116 return self.target(*args, config=self.value, **kw)
118 def retarget(self, target, ConfigClass=None, at=None, label="retarget"):
119 """Target a new configurable and ConfigClass"""
120 if self._config._frozen:
121 raise FieldValidationError(self._field, self._config, "Cannot modify a frozen Config")
123 try:
124 ConfigClass = self._field.validateTarget(target, ConfigClass)
125 except BaseException as e:
126 raise FieldValidationError(self._field, self._config, e.message)
128 if at is None:
129 at = getCallStack()
130 object.__setattr__(self, "_target", target)
131 if ConfigClass != self.ConfigClass:
132 object.__setattr__(self, "_ConfigClass", ConfigClass)
133 self.__initValue(at, label)
135 history = self._config._history.setdefault(self._field.name, [])
136 msg = "retarget(target=%s, ConfigClass=%s)" % (_typeStr(target), _typeStr(ConfigClass))
137 history.append((msg, at, label))
139 def __getattr__(self, name):
140 return getattr(self._value, name)
142 def __setattr__(self, name, value, at=None, label="assignment"):
143 """Pretend to be an instance of ConfigClass.
145 Attributes defined by ConfigurableInstance will shadow those defined
146 in ConfigClass
147 """
148 if self._config._frozen:
149 raise FieldValidationError(self._field, self._config, "Cannot modify a frozen Config")
151 if name in self.__dict__:
152 # attribute exists in the ConfigurableInstance wrapper
153 object.__setattr__(self, name, value)
154 else:
155 if at is None:
156 at = getCallStack()
157 self._value.__setattr__(name, value, at=at, label=label)
159 def __delattr__(self, name, at=None, label="delete"):
160 """
161 Pretend to be an isntance of ConfigClass.
162 Attributes defiend by ConfigurableInstance will shadow those defined
163 in ConfigClass
164 """
165 if self._config._frozen:
166 raise FieldValidationError(self._field, self._config, "Cannot modify a frozen Config")
168 try:
169 # attribute exists in the ConfigurableInstance wrapper
170 object.__delattr__(self, name)
171 except AttributeError:
172 if at is None:
173 at = getCallStack()
174 self._value.__delattr__(name, at=at, label=label)
176 def __reduce__(self):
177 raise UnexpectedProxyUsageError(
178 f"Proxy object for config field {self._field.name} cannot "
179 "be pickled; it should be converted to a normal `Config` instance "
180 f"via the `value` property before being assigned to other objects "
181 "or variables."
182 )
185class ConfigurableField(Field):
186 """A configuration field (`~lsst.pex.config.Field` subclass) that can be
187 can be retargeted towards a different configurable (often a
188 `lsst.pipe.base.Task` subclass).
190 The ``ConfigurableField`` is often used to configure subtasks, which are
191 tasks (`~lsst.pipe.base.Task`) called by a parent task.
193 Parameters
194 ----------
195 doc : `str`
196 A description of the configuration field.
197 target : configurable class
198 The configurable target. Configurables have a ``ConfigClass``
199 attribute. Within the task framework, configurables are
200 `lsst.pipe.base.Task` subclasses)
201 ConfigClass : `lsst.pex.config.Config`-type, optional
202 The subclass of `lsst.pex.config.Config` expected as the configuration
203 class of the ``target``. If ``ConfigClass`` is unset then
204 ``target.ConfigClass`` is used.
205 default : ``ConfigClass``-type, optional
206 The default configuration class. Normally this parameter is not set,
207 and defaults to ``ConfigClass`` (or ``target.ConfigClass``).
208 check : callable, optional
209 Callable that takes the field's value (the ``target``) as its only
210 positional argument, and returns `True` if the ``target`` is valid (and
211 `False` otherwise).
212 deprecated : None or `str`, optional
213 A description of why this Field is deprecated, including removal date.
214 If not None, the string is appended to the docstring for this Field.
216 See also
217 --------
218 ChoiceField
219 ConfigChoiceField
220 ConfigDictField
221 ConfigField
222 DictField
223 Field
224 ListField
225 RangeField
226 RegistryField
228 Notes
229 -----
230 You can use the `ConfigurableInstance.apply` method to construct a
231 fully-configured configurable.
232 """
234 def validateTarget(self, target, ConfigClass):
235 """Validate the target and configuration class.
237 Parameters
238 ----------
239 target
240 The configurable being verified.
241 ConfigClass : `lsst.pex.config.Config`-type or `None`
242 The configuration class associated with the ``target``. This can
243 be `None` if ``target`` has a ``ConfigClass`` attribute.
245 Raises
246 ------
247 AttributeError
248 Raised if ``ConfigClass`` is `None` and ``target`` does not have a
249 ``ConfigClass`` attribute.
250 TypeError
251 Raised if ``ConfigClass`` is not a `~lsst.pex.config.Config`
252 subclass.
253 ValueError
254 Raised if:
256 - ``target`` is not callable (callables have a ``__call__``
257 method).
258 - ``target`` is not startically defined (does not have
259 ``__module__`` or ``__name__`` attributes).
260 """
261 if ConfigClass is None:
262 try:
263 ConfigClass = target.ConfigClass
264 except Exception:
265 raise AttributeError("'target' must define attribute 'ConfigClass'")
266 if not issubclass(ConfigClass, Config): 266 ↛ 267line 266 didn't jump to line 267, because the condition on line 266 was never true
267 raise TypeError(
268 "'ConfigClass' is of incorrect type %s."
269 "'ConfigClass' must be a subclass of Config" % _typeStr(ConfigClass)
270 )
271 if not hasattr(target, "__call__"): 271 ↛ 272line 271 didn't jump to line 272, because the condition on line 271 was never true
272 raise ValueError("'target' must be callable")
273 if not hasattr(target, "__module__") or not hasattr(target, "__name__"): 273 ↛ 274line 273 didn't jump to line 274, because the condition on line 273 was never true
274 raise ValueError(
275 "'target' must be statically defined (must have '__module__' and '__name__' attributes)"
276 )
277 return ConfigClass
279 def __init__(self, doc, target, ConfigClass=None, default=None, check=None, deprecated=None):
280 ConfigClass = self.validateTarget(target, ConfigClass)
282 if default is None:
283 default = ConfigClass
284 if default != ConfigClass and type(default) != ConfigClass: 284 ↛ 285line 284 didn't jump to line 285, because the condition on line 284 was never true
285 raise TypeError(
286 "'default' is of incorrect type %s. Expected %s" % (_typeStr(default), _typeStr(ConfigClass))
287 )
289 source = getStackFrame()
290 self._setup(
291 doc=doc,
292 dtype=ConfigurableInstance,
293 default=default,
294 check=check,
295 optional=False,
296 source=source,
297 deprecated=deprecated,
298 )
299 self.target = target
300 self.ConfigClass = ConfigClass
302 def __getOrMake(self, instance, at=None, label="default"):
303 value = instance._storage.get(self.name, None)
304 if value is None:
305 if at is None:
306 at = getCallStack(1)
307 value = ConfigurableInstance(instance, self, at=at, label=label)
308 instance._storage[self.name] = value
309 return value
311 def __get__(self, instance, owner=None, at=None, label="default"):
312 if instance is None or not isinstance(instance, Config):
313 return self
314 else:
315 return self.__getOrMake(instance, at=at, label=label)
317 def __set__(self, instance, value, at=None, label="assignment"):
318 if instance._frozen:
319 raise FieldValidationError(self, instance, "Cannot modify a frozen Config")
320 if at is None:
321 at = getCallStack()
322 oldValue = self.__getOrMake(instance, at=at)
324 if isinstance(value, ConfigurableInstance):
325 oldValue.retarget(value.target, value.ConfigClass, at, label)
326 oldValue.update(__at=at, __label=label, **value._storage)
327 elif type(value) == oldValue._ConfigClass:
328 oldValue.update(__at=at, __label=label, **value._storage)
329 elif value == oldValue.ConfigClass:
330 value = oldValue.ConfigClass()
331 oldValue.update(__at=at, __label=label, **value._storage)
332 else:
333 msg = "Value %s is of incorrect type %s. Expected %s" % (
334 value,
335 _typeStr(value),
336 _typeStr(oldValue.ConfigClass),
337 )
338 raise FieldValidationError(self, instance, msg)
340 def rename(self, instance):
341 fullname = _joinNamePath(instance._name, self.name)
342 value = self.__getOrMake(instance)
343 value._rename(fullname)
345 def _collectImports(self, instance, imports):
346 value = self.__get__(instance)
347 target = value.target
348 imports.add(target.__module__)
349 value.value._collectImports()
350 imports |= value.value._imports
352 def save(self, outfile, instance):
353 fullname = _joinNamePath(instance._name, self.name)
354 value = self.__getOrMake(instance)
355 target = value.target
357 if target != self.target:
358 # not targeting the field-default target.
359 # save target information
360 ConfigClass = value.ConfigClass
361 outfile.write(
362 "{}.retarget(target={}, ConfigClass={})\n\n".format(
363 fullname, _typeStr(target), _typeStr(ConfigClass)
364 )
365 )
366 # save field values
367 value._save(outfile)
369 def freeze(self, instance):
370 value = self.__getOrMake(instance)
371 value.freeze()
373 def toDict(self, instance):
374 value = self.__get__(instance)
375 return value.toDict()
377 def validate(self, instance):
378 value = self.__get__(instance)
379 value.validate()
381 if self.check is not None and not self.check(value):
382 msg = "%s is not a valid value" % str(value)
383 raise FieldValidationError(self, instance, msg)
385 def __deepcopy__(self, memo):
386 """Customize deep-copying, because we always want a reference to the
387 original typemap.
389 WARNING: this must be overridden by subclasses if they change the
390 constructor signature!
391 """
392 return type(self)(
393 doc=self.doc,
394 target=self.target,
395 ConfigClass=self.ConfigClass,
396 default=copy.deepcopy(self.default),
397 )
399 def _compare(self, instance1, instance2, shortcut, rtol, atol, output):
400 """Compare two fields for equality.
402 Used by `lsst.pex.ConfigDictField.compare`.
404 Parameters
405 ----------
406 instance1 : `lsst.pex.config.Config`
407 Left-hand side config instance to compare.
408 instance2 : `lsst.pex.config.Config`
409 Right-hand side config instance to compare.
410 shortcut : `bool`
411 If `True`, this function returns as soon as an inequality if found.
412 rtol : `float`
413 Relative tolerance for floating point comparisons.
414 atol : `float`
415 Absolute tolerance for floating point comparisons.
416 output : callable
417 A callable that takes a string, used (possibly repeatedly) to
418 report inequalities. For example: `print`.
420 Returns
421 -------
422 isEqual : bool
423 `True` if the fields are equal, `False` otherwise.
425 Notes
426 -----
427 Floating point comparisons are performed by `numpy.allclose`.
428 """
429 c1 = getattr(instance1, self.name)._value
430 c2 = getattr(instance2, self.name)._value
431 name = getComparisonName(
432 _joinNamePath(instance1._name, self.name), _joinNamePath(instance2._name, self.name)
433 )
434 return compareConfigs(name, c1, c2, shortcut=shortcut, rtol=rtol, atol=atol, output=output)