Coverage for python / lsst / pex / config / listField.py: 26%

190 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-14 23:41 +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# (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/>. 

27 

28from __future__ import annotations 

29 

30__all__ = ["ListField"] 

31 

32import collections.abc 

33import weakref 

34from collections.abc import Iterable, MutableSequence, Sequence 

35from typing import Any, Generic, overload 

36 

37from .callStack import StackFrame, getCallStack, getStackFrame 

38from .comparison import compareScalars, getComparisonName 

39from .config import ( 

40 Config, 

41 Field, 

42 FieldTypeVar, 

43 FieldValidationError, 

44 UnexpectedProxyUsageError, 

45 _autocast, 

46 _joinNamePath, 

47 _typeStr, 

48) 

49 

50 

51class List(collections.abc.MutableSequence[FieldTypeVar]): 

52 """List collection used internally by `ListField`. 

53 

54 Parameters 

55 ---------- 

56 config : `lsst.pex.config.Config` 

57 Config instance that contains the ``field``. 

58 field : `ListField` 

59 Instance of the `ListField` using this ``List``. 

60 value : `collections.abc.Sequence` 

61 Sequence of values that are inserted into this ``List``. 

62 at : `list` of `~lsst.pex.config.callStack.StackFrame` 

63 The call stack (created by `lsst.pex.config.callStack.getCallStack`). 

64 label : `str` 

65 Event label for the history. 

66 setHistory : `bool`, optional 

67 Enable setting the field's history, using the value of the ``at`` 

68 parameter. Default is `True`. 

69 

70 Raises 

71 ------ 

72 FieldValidationError 

73 Raised if an item in the ``value`` parameter does not have the 

74 appropriate type for this field or does not pass the 

75 `ListField.itemCheck` method of the ``field`` parameter. 

76 """ 

77 

78 def __init__( 

79 self, 

80 config: Config, 

81 field: ListField, 

82 value: Sequence[FieldTypeVar], 

83 at: list[StackFrame] | None, 

84 label: str, 

85 setHistory: bool = True, 

86 ): 

87 self._field = field 

88 self._config_ = weakref.ref(config) 

89 self._history = self._config._history.setdefault(self._field.name, []) 

90 self._list = [] 

91 self.__doc__ = field.doc 

92 if value is not None: 

93 try: 

94 for i, x in enumerate(value): 

95 self.insert(i, x, setHistory=False) 

96 except TypeError as e: 

97 msg = f"Value {value} is of incorrect type {_typeStr(value)}. Sequence type expected" 

98 raise FieldValidationError(self._field, config, msg) from e 

99 if setHistory: 

100 self.history.append((list(self._list), at, label)) 

101 

102 @property 

103 def _config(self) -> Config: 

104 # Config Fields should never outlive their config class instance 

105 # assert that as such here 

106 value = self._config_() 

107 assert value is not None 

108 return value 

109 

110 def validateItem(self, i, x): 

111 """Validate an item to determine if it can be included in the list. 

112 

113 Parameters 

114 ---------- 

115 i : `int` 

116 Index of the item in the `list`. 

117 x : `object` 

118 Item in the `list`. 

119 

120 Raises 

121 ------ 

122 FieldValidationError 

123 Raised if an item in the ``value`` parameter does not have the 

124 appropriate type for this field or does not pass the field's 

125 `ListField.itemCheck` method. 

126 """ 

127 if not isinstance(x, self._field.itemtype) and x is not None: 

128 msg = ( 

129 f"Item at position {i} with value {x} is of incorrect type {_typeStr(x)}. " 

130 f"Expected {_typeStr(self._field.itemtype)}" 

131 ) 

132 raise FieldValidationError(self._field, self._config, msg) 

133 

134 if self._field.itemCheck is not None and not self._field.itemCheck(x): 

135 msg = f"Item at position {i} is not a valid value: {x}" 

136 raise FieldValidationError(self._field, self._config, msg) 

137 

138 def list(self): 

139 """Sequence of items contained by the `List` (`list`).""" 

140 return self._list 

141 

142 history = property(lambda x: x._history) 

143 """Read-only history. 

144 """ 

145 

146 def _copy(self, config: Config) -> List: 

147 return type(self)(config, self._field, self._list.copy(), at=None, label="copy", setHistory=False) 

148 

149 def __contains__(self, x): 

150 return x in self._list 

151 

152 def __len__(self): 

153 return len(self._list) 

154 

155 @overload 

156 def __setitem__( 

157 self, i: int, x: FieldTypeVar, at: Any = None, label: str = "setitem", setHistory: bool = True 

158 ) -> None: ... 

159 

160 @overload 

161 def __setitem__( 

162 self, 

163 i: slice, 

164 x: Iterable[FieldTypeVar], 

165 at: Any = None, 

166 label: str = "setitem", 

167 setHistory: bool = True, 

168 ) -> None: ... 

169 

170 def __setitem__(self, i, x, at=None, label="setitem", setHistory=True): 

171 if self._config._frozen: 

172 raise FieldValidationError(self._field, self._config, "Cannot modify a frozen Config") 

173 if isinstance(i, slice): 

174 k, stop, step = i.indices(len(self)) 

175 for j, xj in enumerate(x): 

176 xj = _autocast(xj, self._field.itemtype) 

177 self.validateItem(k, xj) 

178 x[j] = xj 

179 k += step 

180 else: 

181 x = _autocast(x, self._field.itemtype) 

182 self.validateItem(i, x) 

183 

184 self._list[i] = x 

185 if setHistory: 

186 if at is None: 

187 at = getCallStack() 

188 self.history.append((list(self._list), at, label)) 

189 

190 @overload 

191 def __getitem__(self, i: int) -> FieldTypeVar: ... 

192 

193 @overload 

194 def __getitem__(self, i: slice) -> MutableSequence[FieldTypeVar]: ... 

195 

196 def __getitem__(self, i): 

197 return self._list[i] 

198 

199 def __delitem__(self, i, at=None, label="delitem", setHistory=True): 

200 if self._config._frozen: 

201 raise FieldValidationError(self._field, self._config, "Cannot modify a frozen Config") 

202 del self._list[i] 

203 if setHistory: 

204 if at is None: 

205 at = getCallStack() 

206 self.history.append((list(self._list), at, label)) 

207 

208 def __iter__(self): 

209 return iter(self._list) 

210 

211 def insert(self, i, x, at=None, label="insert", setHistory=True): 

212 """Insert an item into the list at the given index. 

213 

214 Parameters 

215 ---------- 

216 i : `int` 

217 Index where the item is inserted. 

218 x : `object` 

219 Item that is inserted. 

220 at : `list` of `~lsst.pex.config.callStack.StackFrame` or `None`,\ 

221 optional 

222 The call stack (created by 

223 `lsst.pex.config.callStack.getCallStack`). 

224 label : `str`, optional 

225 Event label for the history. 

226 setHistory : `bool`, optional 

227 Enable setting the field's history, using the value of the ``at`` 

228 parameter. Default is `True`. 

229 """ 

230 if at is None: 

231 at = getCallStack() 

232 self.__setitem__(slice(i, i), [x], at=at, label=label, setHistory=setHistory) 

233 

234 def __repr__(self): 

235 return repr(self._list) 

236 

237 def __str__(self): 

238 return str(self._list) 

239 

240 def __eq__(self, other): 

241 if other is None: 

242 return False 

243 try: 

244 if len(self) != len(other): 

245 return False 

246 

247 for i, j in zip(self, other, strict=True): 

248 if i != j: 

249 return False 

250 return True 

251 except AttributeError: 

252 # other is not a sequence type 

253 return False 

254 

255 def __ne__(self, other): 

256 return not self.__eq__(other) 

257 

258 def __setattr__(self, attr, value, at=None, label="assignment"): 

259 if hasattr(getattr(self.__class__, attr, None), "__set__"): 

260 # This allows properties to work. 

261 object.__setattr__(self, attr, value) 

262 elif attr in self.__dict__ or attr in ["_field", "_config_", "_history", "_list", "__doc__"]: 

263 # This allows specific private attributes to work. 

264 object.__setattr__(self, attr, value) 

265 else: 

266 # We throw everything else. 

267 msg = f"{_typeStr(self._field)} has no attribute {attr}" 

268 raise FieldValidationError(self._field, self._config, msg) 

269 

270 def __reduce__(self): 

271 raise UnexpectedProxyUsageError( 

272 f"Proxy container for config field {self._field.name} cannot " 

273 "be pickled; it should be converted to a built-in container before " 

274 "being assigned to other objects or variables." 

275 ) 

276 

277 

278class ListField(Field[List[FieldTypeVar]], Generic[FieldTypeVar]): 

279 """A configuration field (`~lsst.pex.config.Field` subclass) that contains 

280 a list of values of a specific type. 

281 

282 Parameters 

283 ---------- 

284 doc : `str` 

285 A description of the field. 

286 dtype : `type`, optional 

287 The data type of items in the list. Optional if supplied as typing 

288 argument to the class. 

289 default : `collections.abc.Sequence`, optional 

290 The default items for the field. 

291 optional : `bool`, optional 

292 Set whether the field is *optional*. When `False`, 

293 `lsst.pex.config.Config.validate` will fail if the field's value is 

294 `None`. 

295 listCheck : `collections.abc.Callable`, optional 

296 A callable that validates the list as a whole. 

297 itemCheck : `collections.abc.Callable`, optional 

298 A callable that validates individual items in the list. 

299 length : `int`, optional 

300 If set, this field must contain exactly ``length`` number of items. 

301 minLength : `int`, optional 

302 If set, this field must contain *at least* ``minLength`` number of 

303 items. 

304 maxLength : `int`, optional 

305 If set, this field must contain *no more than* ``maxLength`` number of 

306 items. 

307 deprecated : `None` or `str`, optional 

308 A description of why this Field is deprecated, including removal date. 

309 If not `None`, the string is appended to the docstring for this Field. 

310 

311 See Also 

312 -------- 

313 ChoiceField 

314 ConfigChoiceField 

315 ConfigDictField 

316 ConfigField 

317 ConfigurableField 

318 DictField 

319 Field 

320 RangeField 

321 RegistryField 

322 """ 

323 

324 def __init__( 

325 self, 

326 doc, 

327 dtype=None, 

328 default=None, 

329 optional=False, 

330 listCheck=None, 

331 itemCheck=None, 

332 length=None, 

333 minLength=None, 

334 maxLength=None, 

335 deprecated=None, 

336 ): 

337 if dtype is None: 337 ↛ 338line 337 didn't jump to line 338 because the condition on line 337 was never true

338 raise ValueError( 

339 "dtype must either be supplied as an argument or as a type argument to the class" 

340 ) 

341 if dtype not in Field.supportedTypes: 341 ↛ 342line 341 didn't jump to line 342 because the condition on line 341 was never true

342 raise ValueError(f"Unsupported dtype {_typeStr(dtype)}") 

343 if length is not None: 

344 if length <= 0: 344 ↛ 345line 344 didn't jump to line 345 because the condition on line 344 was never true

345 raise ValueError(f"'length' ({length}) must be positive") 

346 minLength = None 

347 maxLength = None 

348 else: 

349 if maxLength is not None and maxLength <= 0: 349 ↛ 350line 349 didn't jump to line 350 because the condition on line 349 was never true

350 raise ValueError(f"'maxLength' ({maxLength}) must be positive") 

351 if minLength is not None and maxLength is not None and minLength > maxLength: 351 ↛ 352line 351 didn't jump to line 352 because the condition on line 351 was never true

352 raise ValueError( 

353 f"'maxLength' ({maxLength}) must be at least as large as 'minLength' ({minLength})" 

354 ) 

355 

356 if listCheck is not None and not callable(listCheck): 356 ↛ 357line 356 didn't jump to line 357 because the condition on line 356 was never true

357 raise ValueError("'listCheck' must be callable") 

358 if itemCheck is not None and not callable(itemCheck): 358 ↛ 359line 358 didn't jump to line 359 because the condition on line 358 was never true

359 raise ValueError("'itemCheck' must be callable") 

360 

361 source = getStackFrame() 

362 self._setup( 

363 doc=doc, 

364 dtype=List, 

365 default=default, 

366 check=None, 

367 optional=optional, 

368 source=source, 

369 deprecated=deprecated, 

370 ) 

371 

372 self.listCheck = listCheck 

373 """Callable used to check the list as a whole. 

374 """ 

375 

376 self.itemCheck = itemCheck 

377 """Callable used to validate individual items as they are inserted 

378 into the list. 

379 """ 

380 

381 self.itemtype = dtype 

382 """Data type of list items. 

383 """ 

384 

385 self.length = length 

386 """Number of items that must be present in the list (or `None` to 

387 disable checking the list's length). 

388 """ 

389 

390 self.minLength = minLength 

391 """Minimum number of items that must be present in the list (or `None` 

392 to disable checking the list's minimum length). 

393 """ 

394 

395 self.maxLength = maxLength 

396 """Maximum number of items that must be present in the list (or `None` 

397 to disable checking the list's maximum length). 

398 """ 

399 

400 def validate(self, instance): 

401 """Validate the field. 

402 

403 Parameters 

404 ---------- 

405 instance : `lsst.pex.config.Config` 

406 The config instance that contains this field. 

407 

408 Raises 

409 ------ 

410 lsst.pex.config.FieldValidationError 

411 Raised if: 

412 

413 - The field is not optional, but the value is `None`. 

414 - The list itself does not meet the requirements of the ``length``, 

415 ``minLength``, or ``maxLength`` attributes. 

416 - The ``listCheck`` callable returns `False`. 

417 

418 Notes 

419 ----- 

420 Individual item checks (``itemCheck``) are applied when each item is 

421 set and are not re-checked by this method. 

422 """ 

423 Field.validate(self, instance) 

424 value = self.__get__(instance) 

425 if value is not None: 

426 lenValue = len(value) 

427 if self.length is not None and not lenValue == self.length: 

428 msg = f"Required list length={self.length}, got length={lenValue}" 

429 raise FieldValidationError(self, instance, msg) 

430 elif self.minLength is not None and lenValue < self.minLength: 

431 msg = f"Minimum allowed list length={self.minLength}, got length={lenValue}" 

432 raise FieldValidationError(self, instance, msg) 

433 elif self.maxLength is not None and lenValue > self.maxLength: 

434 msg = f"Maximum allowed list length={self.maxLength}, got length={lenValue}" 

435 raise FieldValidationError(self, instance, msg) 

436 elif self.listCheck is not None and not self.listCheck(value): 

437 msg = f"{value} is not a valid value" 

438 raise FieldValidationError(self, instance, msg) 

439 

440 def __set__( 

441 self, 

442 instance: Config, 

443 value: Iterable[FieldTypeVar] | None, 

444 at: Any = None, 

445 label: str = "assignment", 

446 ) -> None: 

447 if instance._frozen: 

448 raise FieldValidationError(self, instance, "Cannot modify a frozen Config") 

449 

450 if at is None: 

451 at = getCallStack() 

452 

453 if value is not None: 

454 value = List(instance, self, value, at, label) 

455 else: 

456 history = instance._history.setdefault(self.name, []) 

457 history.append((value, at, label)) 

458 

459 instance._storage[self.name] = value 

460 

461 def toDict(self, instance): 

462 """Convert the value of this field to a plain `list`. 

463 

464 `lsst.pex.config.Config.toDict` is the primary user of this method. 

465 

466 Parameters 

467 ---------- 

468 instance : `lsst.pex.config.Config` 

469 The config instance that contains this field. 

470 

471 Returns 

472 ------- 

473 `list` 

474 Plain `list` of items, or `None` if the field is not set. 

475 """ 

476 value = self.__get__(instance) 

477 return list(value) if value is not None else None 

478 

479 def _copy_storage(self, old: Config, new: Config) -> List[FieldTypeVar] | None: 

480 value: List[FieldTypeVar] | None = old._storage[self.name] 

481 if value is not None: 

482 return value._copy(new) 

483 else: 

484 return None 

485 

486 def _compare(self, instance1, instance2, shortcut, rtol, atol, output): 

487 """Compare two config instances for equality with respect to this 

488 field. 

489 

490 `lsst.pex.config.config.compare` is the primary user of this method. 

491 

492 Parameters 

493 ---------- 

494 instance1 : `lsst.pex.config.Config` 

495 Left-hand-side `~lsst.pex.config.Config` instance in the 

496 comparison. 

497 instance2 : `lsst.pex.config.Config` 

498 Right-hand-side `~lsst.pex.config.Config` instance in the 

499 comparison. 

500 shortcut : `bool` 

501 If `True`, return as soon as an **inequality** is found. 

502 rtol : `float` 

503 Relative tolerance for floating point comparisons. 

504 atol : `float` 

505 Absolute tolerance for floating point comparisons. 

506 output : `collections.abc.Callable` 

507 If not None, a callable that takes a `str`, used (possibly 

508 repeatedly) to report inequalities. 

509 

510 Returns 

511 ------- 

512 equal : `bool` 

513 `True` if the fields are equal; `False` otherwise. 

514 

515 Notes 

516 ----- 

517 Floating point comparisons are performed by `numpy.allclose`. 

518 """ 

519 l1 = getattr(instance1, self.name) 

520 l2 = getattr(instance2, self.name) 

521 name = getComparisonName( 

522 _joinNamePath(instance1._name, self.name), _joinNamePath(instance2._name, self.name) 

523 ) 

524 if l1 is None or l2 is None: 

525 return compareScalars(name, l1, l2, output=output) 

526 if not compareScalars(f"{name} (len)", len(l1), len(l2), output=output): 

527 return False 

528 equal = True 

529 for n, v1, v2 in zip(range(len(l1)), l1, l2, strict=True): 

530 result = compareScalars( 

531 f"{name}[{n}]", v1, v2, dtype=self.dtype, rtol=rtol, atol=atol, output=output 

532 ) 

533 

534 if not result and shortcut: 

535 return False 

536 equal = equal and result 

537 return equal