Coverage for python/lsst/pex/config/listField.py: 26%
182 statements
« prev ^ index » next coverage.py v7.10.6, created at 2025-10-09 03:27 -0700
« prev ^ index » next coverage.py v7.10.6, created at 2025-10-09 03:27 -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__ = ["ListField"]
30import collections.abc
31import weakref
32from collections.abc import Iterable, MutableSequence
33from typing import Any, Generic, overload
35from .callStack import getCallStack, getStackFrame
36from .comparison import compareScalars, getComparisonName
37from .config import (
38 Config,
39 Field,
40 FieldTypeVar,
41 FieldValidationError,
42 UnexpectedProxyUsageError,
43 _autocast,
44 _joinNamePath,
45 _typeStr,
46)
49class List(collections.abc.MutableSequence[FieldTypeVar]):
50 """List collection used internally by `ListField`.
52 Parameters
53 ----------
54 config : `lsst.pex.config.Config`
55 Config instance that contains the ``field``.
56 field : `ListField`
57 Instance of the `ListField` using this ``List``.
58 value : sequence
59 Sequence of values that are inserted into this ``List``.
60 at : `list` of `~lsst.pex.config.callStack.StackFrame`
61 The call stack (created by `lsst.pex.config.callStack.getCallStack`).
62 label : `str`
63 Event label for the history.
64 setHistory : `bool`, optional
65 Enable setting the field's history, using the value of the ``at``
66 parameter. Default is `True`.
68 Raises
69 ------
70 FieldValidationError
71 Raised if an item in the ``value`` parameter does not have the
72 appropriate type for this field or does not pass the
73 `ListField.itemCheck` method of the ``field`` parameter.
74 """
76 def __init__(self, config, field, value, at, label, setHistory=True):
77 self._field = field
78 self._config_ = weakref.ref(config)
79 self._history = self._config._history.setdefault(self._field.name, [])
80 self._list = []
81 self.__doc__ = field.doc
82 if value is not None:
83 try:
84 for i, x in enumerate(value):
85 self.insert(i, x, setHistory=False)
86 except TypeError as e:
87 msg = f"Value {value} is of incorrect type {_typeStr(value)}. Sequence type expected"
88 raise FieldValidationError(self._field, config, msg) from e
89 if setHistory:
90 self.history.append((list(self._list), at, label))
92 @property
93 def _config(self) -> Config:
94 # Config Fields should never outlive their config class instance
95 # assert that as such here
96 value = self._config_()
97 assert value is not None
98 return value
100 def validateItem(self, i, x):
101 """Validate an item to determine if it can be included in the list.
103 Parameters
104 ----------
105 i : `int`
106 Index of the item in the `list`.
107 x : object
108 Item in the `list`.
110 Raises
111 ------
112 FieldValidationError
113 Raised if an item in the ``value`` parameter does not have the
114 appropriate type for this field or does not pass the field's
115 `ListField.itemCheck` method.
116 """
117 if not isinstance(x, self._field.itemtype) and x is not None:
118 msg = (
119 f"Item at position {i} with value {x} is of incorrect type {_typeStr(x)}. "
120 f"Expected {_typeStr(self._field.itemtype)}"
121 )
122 raise FieldValidationError(self._field, self._config, msg)
124 if self._field.itemCheck is not None and not self._field.itemCheck(x):
125 msg = f"Item at position {i} is not a valid value: {x}"
126 raise FieldValidationError(self._field, self._config, msg)
128 def list(self):
129 """Sequence of items contained by the `List` (`list`)."""
130 return self._list
132 history = property(lambda x: x._history)
133 """Read-only history.
134 """
136 def __contains__(self, x):
137 return x in self._list
139 def __len__(self):
140 return len(self._list)
142 @overload
143 def __setitem__(
144 self, i: int, x: FieldTypeVar, at: Any = None, label: str = "setitem", setHistory: bool = True
145 ) -> None: ...
147 @overload
148 def __setitem__(
149 self,
150 i: slice,
151 x: Iterable[FieldTypeVar],
152 at: Any = None,
153 label: str = "setitem",
154 setHistory: bool = True,
155 ) -> None: ...
157 def __setitem__(self, i, x, at=None, label="setitem", setHistory=True):
158 if self._config._frozen:
159 raise FieldValidationError(self._field, self._config, "Cannot modify a frozen Config")
160 if isinstance(i, slice):
161 k, stop, step = i.indices(len(self))
162 for j, xj in enumerate(x):
163 xj = _autocast(xj, self._field.itemtype)
164 self.validateItem(k, xj)
165 x[j] = xj
166 k += step
167 else:
168 x = _autocast(x, self._field.itemtype)
169 self.validateItem(i, x)
171 self._list[i] = x
172 if setHistory:
173 if at is None:
174 at = getCallStack()
175 self.history.append((list(self._list), at, label))
177 @overload
178 def __getitem__(self, i: int) -> FieldTypeVar: ...
180 @overload
181 def __getitem__(self, i: slice) -> MutableSequence[FieldTypeVar]: ...
183 def __getitem__(self, i):
184 return self._list[i]
186 def __delitem__(self, i, at=None, label="delitem", setHistory=True):
187 if self._config._frozen:
188 raise FieldValidationError(self._field, self._config, "Cannot modify a frozen Config")
189 del self._list[i]
190 if setHistory:
191 if at is None:
192 at = getCallStack()
193 self.history.append((list(self._list), at, label))
195 def __iter__(self):
196 return iter(self._list)
198 def insert(self, i, x, at=None, label="insert", setHistory=True):
199 """Insert an item into the list at the given index.
201 Parameters
202 ----------
203 i : `int`
204 Index where the item is inserted.
205 x : object
206 Item that is inserted.
207 at : `list` of `~lsst.pex.config.callStack.StackFrame` or `None`,\
208 optional
209 The call stack (created by
210 `lsst.pex.config.callStack.getCallStack`).
211 label : `str`, optional
212 Event label for the history.
213 setHistory : `bool`, optional
214 Enable setting the field's history, using the value of the ``at``
215 parameter. Default is `True`.
216 """
217 if at is None:
218 at = getCallStack()
219 self.__setitem__(slice(i, i), [x], at=at, label=label, setHistory=setHistory)
221 def __repr__(self):
222 return repr(self._list)
224 def __str__(self):
225 return str(self._list)
227 def __eq__(self, other):
228 if other is None:
229 return False
230 try:
231 if len(self) != len(other):
232 return False
234 for i, j in zip(self, other, strict=True):
235 if i != j:
236 return False
237 return True
238 except AttributeError:
239 # other is not a sequence type
240 return False
242 def __ne__(self, other):
243 return not self.__eq__(other)
245 def __setattr__(self, attr, value, at=None, label="assignment"):
246 if hasattr(getattr(self.__class__, attr, None), "__set__"):
247 # This allows properties to work.
248 object.__setattr__(self, attr, value)
249 elif attr in self.__dict__ or attr in ["_field", "_config_", "_history", "_list", "__doc__"]:
250 # This allows specific private attributes to work.
251 object.__setattr__(self, attr, value)
252 else:
253 # We throw everything else.
254 msg = f"{_typeStr(self._field)} has no attribute {attr}"
255 raise FieldValidationError(self._field, self._config, msg)
257 def __reduce__(self):
258 raise UnexpectedProxyUsageError(
259 f"Proxy container for config field {self._field.name} cannot "
260 "be pickled; it should be converted to a built-in container before "
261 "being assigned to other objects or variables."
262 )
265class ListField(Field[List[FieldTypeVar]], Generic[FieldTypeVar]):
266 """A configuration field (`~lsst.pex.config.Field` subclass) that contains
267 a list of values of a specific type.
269 Parameters
270 ----------
271 doc : `str`
272 A description of the field.
273 dtype : class, optional
274 The data type of items in the list. Optional if supplied as typing
275 argument to the class.
276 default : sequence, optional
277 The default items for the field.
278 optional : `bool`, optional
279 Set whether the field is *optional*. When `False`,
280 `lsst.pex.config.Config.validate` will fail if the field's value is
281 `None`.
282 listCheck : callable, optional
283 A callable that validates the list as a whole.
284 itemCheck : callable, optional
285 A callable that validates individual items in the list.
286 length : `int`, optional
287 If set, this field must contain exactly ``length`` number of items.
288 minLength : `int`, optional
289 If set, this field must contain *at least* ``minLength`` number of
290 items.
291 maxLength : `int`, optional
292 If set, this field must contain *no more than* ``maxLength`` number of
293 items.
294 deprecated : None or `str`, optional
295 A description of why this Field is deprecated, including removal date.
296 If not None, the string is appended to the docstring for this Field.
298 See Also
299 --------
300 ChoiceField
301 ConfigChoiceField
302 ConfigDictField
303 ConfigField
304 ConfigurableField
305 DictField
306 Field
307 RangeField
308 RegistryField
309 """
311 def __init__(
312 self,
313 doc,
314 dtype=None,
315 default=None,
316 optional=False,
317 listCheck=None,
318 itemCheck=None,
319 length=None,
320 minLength=None,
321 maxLength=None,
322 deprecated=None,
323 ):
324 if dtype is None: 324 ↛ 325line 324 didn't jump to line 325 because the condition on line 324 was never true
325 raise ValueError(
326 "dtype must either be supplied as an argument or as a type argument to the class"
327 )
328 if dtype not in Field.supportedTypes: 328 ↛ 329line 328 didn't jump to line 329 because the condition on line 328 was never true
329 raise ValueError(f"Unsupported dtype {_typeStr(dtype)}")
330 if length is not None:
331 if length <= 0: 331 ↛ 332line 331 didn't jump to line 332 because the condition on line 331 was never true
332 raise ValueError(f"'length' ({length}) must be positive")
333 minLength = None
334 maxLength = None
335 else:
336 if maxLength is not None and maxLength <= 0: 336 ↛ 337line 336 didn't jump to line 337 because the condition on line 336 was never true
337 raise ValueError(f"'maxLength' ({maxLength}) must be positive")
338 if minLength is not None and maxLength is not None and minLength > maxLength: 338 ↛ 339line 338 didn't jump to line 339 because the condition on line 338 was never true
339 raise ValueError(
340 f"'maxLength' ({maxLength}) must be at least as large as 'minLength' ({minLength})"
341 )
343 if listCheck is not None and not callable(listCheck): 343 ↛ 344line 343 didn't jump to line 344 because the condition on line 343 was never true
344 raise ValueError("'listCheck' must be callable")
345 if itemCheck is not None and not callable(itemCheck): 345 ↛ 346line 345 didn't jump to line 346 because the condition on line 345 was never true
346 raise ValueError("'itemCheck' must be callable")
348 source = getStackFrame()
349 self._setup(
350 doc=doc,
351 dtype=List,
352 default=default,
353 check=None,
354 optional=optional,
355 source=source,
356 deprecated=deprecated,
357 )
359 self.listCheck = listCheck
360 """Callable used to check the list as a whole.
361 """
363 self.itemCheck = itemCheck
364 """Callable used to validate individual items as they are inserted
365 into the list.
366 """
368 self.itemtype = dtype
369 """Data type of list items.
370 """
372 self.length = length
373 """Number of items that must be present in the list (or `None` to
374 disable checking the list's length).
375 """
377 self.minLength = minLength
378 """Minimum number of items that must be present in the list (or `None`
379 to disable checking the list's minimum length).
380 """
382 self.maxLength = maxLength
383 """Maximum number of items that must be present in the list (or `None`
384 to disable checking the list's maximum length).
385 """
387 def validate(self, instance):
388 """Validate the field.
390 Parameters
391 ----------
392 instance : `lsst.pex.config.Config`
393 The config instance that contains this field.
395 Raises
396 ------
397 lsst.pex.config.FieldValidationError
398 Raised if:
400 - The field is not optional, but the value is `None`.
401 - The list itself does not meet the requirements of the ``length``,
402 ``minLength``, or ``maxLength`` attributes.
403 - The ``listCheck`` callable returns `False`.
405 Notes
406 -----
407 Individual item checks (``itemCheck``) are applied when each item is
408 set and are not re-checked by this method.
409 """
410 Field.validate(self, instance)
411 value = self.__get__(instance)
412 if value is not None:
413 lenValue = len(value)
414 if self.length is not None and not lenValue == self.length:
415 msg = f"Required list length={self.length}, got length={lenValue}"
416 raise FieldValidationError(self, instance, msg)
417 elif self.minLength is not None and lenValue < self.minLength:
418 msg = f"Minimum allowed list length={self.minLength}, got length={lenValue}"
419 raise FieldValidationError(self, instance, msg)
420 elif self.maxLength is not None and lenValue > self.maxLength:
421 msg = f"Maximum allowed list length={self.maxLength}, got length={lenValue}"
422 raise FieldValidationError(self, instance, msg)
423 elif self.listCheck is not None and not self.listCheck(value):
424 msg = f"{value} is not a valid value"
425 raise FieldValidationError(self, instance, msg)
427 def __set__(
428 self,
429 instance: Config,
430 value: Iterable[FieldTypeVar] | None,
431 at: Any = None,
432 label: str = "assignment",
433 ) -> None:
434 if instance._frozen:
435 raise FieldValidationError(self, instance, "Cannot modify a frozen Config")
437 if at is None:
438 at = getCallStack()
440 if value is not None:
441 value = List(instance, self, value, at, label)
442 else:
443 history = instance._history.setdefault(self.name, [])
444 history.append((value, at, label))
446 instance._storage[self.name] = value
448 def toDict(self, instance):
449 """Convert the value of this field to a plain `list`.
451 `lsst.pex.config.Config.toDict` is the primary user of this method.
453 Parameters
454 ----------
455 instance : `lsst.pex.config.Config`
456 The config instance that contains this field.
458 Returns
459 -------
460 `list`
461 Plain `list` of items, or `None` if the field is not set.
462 """
463 value = self.__get__(instance)
464 return list(value) if value is not None else None
466 def _compare(self, instance1, instance2, shortcut, rtol, atol, output):
467 """Compare two config instances for equality with respect to this
468 field.
470 `lsst.pex.config.config.compare` is the primary user of this method.
472 Parameters
473 ----------
474 instance1 : `lsst.pex.config.Config`
475 Left-hand-side `~lsst.pex.config.Config` instance in the
476 comparison.
477 instance2 : `lsst.pex.config.Config`
478 Right-hand-side `~lsst.pex.config.Config` instance in the
479 comparison.
480 shortcut : `bool`
481 If `True`, return as soon as an **inequality** is found.
482 rtol : `float`
483 Relative tolerance for floating point comparisons.
484 atol : `float`
485 Absolute tolerance for floating point comparisons.
486 output : callable
487 If not None, a callable that takes a `str`, used (possibly
488 repeatedly) to report inequalities.
490 Returns
491 -------
492 equal : `bool`
493 `True` if the fields are equal; `False` otherwise.
495 Notes
496 -----
497 Floating point comparisons are performed by `numpy.allclose`.
498 """
499 l1 = getattr(instance1, self.name)
500 l2 = getattr(instance2, self.name)
501 name = getComparisonName(
502 _joinNamePath(instance1._name, self.name), _joinNamePath(instance2._name, self.name)
503 )
504 if l1 is None or l2 is None:
505 return compareScalars(name, l1, l2, output=output)
506 if not compareScalars(f"{name} (len)", len(l1), len(l2), output=output):
507 return False
508 equal = True
509 for n, v1, v2 in zip(range(len(l1)), l1, l2, strict=True):
510 result = compareScalars(
511 f"{name}[{n}]", v1, v2, dtype=self.dtype, rtol=rtol, atol=atol, output=output
512 )
514 if not result and shortcut:
515 return False
516 equal = equal and result
517 return equal