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

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 

28__all__ = ["ListField"] 

29 

30import collections.abc 

31import weakref 

32from collections.abc import Iterable, MutableSequence 

33from typing import Any, Generic, overload 

34 

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) 

47 

48 

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

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

51 

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`. 

67 

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 """ 

75 

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)) 

91 

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 

99 

100 def validateItem(self, i, x): 

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

102 

103 Parameters 

104 ---------- 

105 i : `int` 

106 Index of the item in the `list`. 

107 x : object 

108 Item in the `list`. 

109 

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) 

123 

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) 

127 

128 def list(self): 

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

130 return self._list 

131 

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

133 """Read-only history. 

134 """ 

135 

136 def __contains__(self, x): 

137 return x in self._list 

138 

139 def __len__(self): 

140 return len(self._list) 

141 

142 @overload 

143 def __setitem__( 

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

145 ) -> None: ... 

146 

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: ... 

156 

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) 

170 

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)) 

176 

177 @overload 

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

179 

180 @overload 

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

182 

183 def __getitem__(self, i): 

184 return self._list[i] 

185 

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)) 

194 

195 def __iter__(self): 

196 return iter(self._list) 

197 

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

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

200 

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) 

220 

221 def __repr__(self): 

222 return repr(self._list) 

223 

224 def __str__(self): 

225 return str(self._list) 

226 

227 def __eq__(self, other): 

228 if other is None: 

229 return False 

230 try: 

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

232 return False 

233 

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 

241 

242 def __ne__(self, other): 

243 return not self.__eq__(other) 

244 

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) 

256 

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 ) 

263 

264 

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. 

268 

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. 

297 

298 See Also 

299 -------- 

300 ChoiceField 

301 ConfigChoiceField 

302 ConfigDictField 

303 ConfigField 

304 ConfigurableField 

305 DictField 

306 Field 

307 RangeField 

308 RegistryField 

309 """ 

310 

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 ) 

342 

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") 

347 

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 ) 

358 

359 self.listCheck = listCheck 

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

361 """ 

362 

363 self.itemCheck = itemCheck 

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

365 into the list. 

366 """ 

367 

368 self.itemtype = dtype 

369 """Data type of list items. 

370 """ 

371 

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 """ 

376 

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 """ 

381 

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 """ 

386 

387 def validate(self, instance): 

388 """Validate the field. 

389 

390 Parameters 

391 ---------- 

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

393 The config instance that contains this field. 

394 

395 Raises 

396 ------ 

397 lsst.pex.config.FieldValidationError 

398 Raised if: 

399 

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`. 

404 

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) 

426 

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") 

436 

437 if at is None: 

438 at = getCallStack() 

439 

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)) 

445 

446 instance._storage[self.name] = value 

447 

448 def toDict(self, instance): 

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

450 

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

452 

453 Parameters 

454 ---------- 

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

456 The config instance that contains this field. 

457 

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 

465 

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

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

468 field. 

469 

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

471 

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. 

489 

490 Returns 

491 ------- 

492 equal : `bool` 

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

494 

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 ) 

513 

514 if not result and shortcut: 

515 return False 

516 equal = equal and result 

517 return equal