Coverage for python / lsst / pex / config / configChoiceField.py: 17%

289 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-26 08:53 +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/>. 

27from __future__ import annotations 

28 

29__all__ = ["ConfigChoiceField"] 

30 

31import collections.abc 

32import copy 

33from typing import Any, ForwardRef, overload 

34 

35from .callStack import getCallStack, getStackFrame 

36from .comparison import compareConfigs, compareScalars, getComparisonName 

37from .config import Config, Field, FieldValidationError, UnexpectedProxyUsageError, _joinNamePath, _typeStr 

38 

39 

40class SelectionSet(collections.abc.MutableSet): 

41 """A mutable set class that tracks the selection of multi-select 

42 `~lsst.pex.config.ConfigChoiceField` objects. 

43 

44 Parameters 

45 ---------- 

46 dict_ : `ConfigInstanceDict` 

47 The dictionary of instantiated configs. 

48 value : `~typing.Any` 

49 The selected key. 

50 at : `list` of `~lsst.pex.config.callStack.StackFrame` or `None`, optional 

51 The call stack when the selection was made. 

52 label : `str`, optional 

53 Label for history tracking. 

54 setHistory : `bool`, optional 

55 Add this even to the history, if `True`. 

56 

57 Notes 

58 ----- 

59 This class allows a user of a multi-select 

60 `~lsst.pex.config.ConfigChoiceField` to add or discard items from the set 

61 of active configs. Each change to the selection is tracked in the field's 

62 history. 

63 """ 

64 

65 def __init__( 

66 self, 

67 dict_: ConfigInstanceDict, 

68 value: Any, 

69 at=None, 

70 label: str = "assignment", 

71 setHistory: bool = True, 

72 ): 

73 if at is None: 

74 at = getCallStack() 

75 self._dict = dict_ 

76 self._field = self._dict._field 

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

78 if value is not None: 

79 try: 

80 for v in value: 

81 if v not in self._dict: 

82 # invoke __getitem__ to ensure it's present 

83 self._dict.__getitem__(v, at=at) 

84 except TypeError as e: 

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

86 raise FieldValidationError(self._field, self._dict._config, msg) from e 

87 self._set = set(value) 

88 else: 

89 self._set = set() 

90 

91 if setHistory: 

92 self._history.append((f"Set selection to {self}", at, label)) 

93 

94 def add(self, value, at=None): 

95 """Add a value to the selected set. 

96 

97 Parameters 

98 ---------- 

99 value : `~typing.Any` 

100 The selected key. 

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

102 optional 

103 Stack frames for history recording. 

104 """ 

105 if self._dict._config._frozen: 

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

107 

108 if at is None: 

109 at = getCallStack() 

110 

111 if value not in self._dict: 

112 # invoke __getitem__ to make sure it's present 

113 self._dict.__getitem__(value, at=at) 

114 

115 self._history.append((f"added {value} to selection", at, "selection")) 

116 self._set.add(value) 

117 

118 def discard(self, value, at=None): 

119 """Discard a value from the selected set. 

120 

121 Parameters 

122 ---------- 

123 value : `~typing.Any` 

124 The selected key. 

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

126 optional 

127 Stack frames for history recording. 

128 """ 

129 if self._dict._config._frozen: 

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

131 

132 if value not in self._dict: 

133 return 

134 

135 if at is None: 

136 at = getCallStack() 

137 

138 self._history.append((f"removed {value} from selection", at, "selection")) 

139 self._set.discard(value) 

140 

141 def __len__(self): 

142 return len(self._set) 

143 

144 def __iter__(self): 

145 return iter(self._set) 

146 

147 def __contains__(self, value): 

148 return value in self._set 

149 

150 def __repr__(self): 

151 return repr(list(self._set)) 

152 

153 def __str__(self): 

154 return str(list(self._set)) 

155 

156 def __reduce__(self): 

157 raise UnexpectedProxyUsageError( 

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

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

160 "being assigned to other objects or variables." 

161 ) 

162 

163 

164class ConfigInstanceDict(collections.abc.Mapping[str, Config]): 

165 """Dictionary of instantiated configs, used to populate a 

166 `~lsst.pex.config.ConfigChoiceField`. 

167 

168 Parameters 

169 ---------- 

170 config : `lsst.pex.config.Config` 

171 A configuration instance. 

172 field : `lsst.pex.config.Field`-type 

173 A configuration field. Note that the `lsst.pex.config.Field.fieldmap` 

174 attribute must provide key-based access to configuration classes, 

175 (that is, ``typemap[name]``). 

176 """ 

177 

178 def __init__(self, config: Config, field: ConfigChoiceField): 

179 collections.abc.Mapping.__init__(self) 

180 self._dict: dict[str, Config] = {} 

181 self._selection = None 

182 self._config = config 

183 self._field = field 

184 self._history = config._history.setdefault(field.name, []) 

185 self.__doc__ = field.doc 

186 self._typemap = None 

187 

188 def _copy(self, config: Config) -> ConfigInstanceDict: 

189 result = type(self)(config, self._field) 

190 result._dict = {k: v.copy() for k, v in self._dict.items()} 

191 result._history.extend(self._history) 

192 result._typemap = self._typemap 

193 if self._selection is not None: 

194 if self._field.multi: 

195 result._selection = SelectionSet(self, self._selection._set) 

196 else: 

197 result._selection = self._selection 

198 return result 

199 

200 @property 

201 def types(self): 

202 return self._typemap if self._typemap is not None else self._field.typemap 

203 

204 def __contains__(self, k): 

205 return k in self.types 

206 

207 def __len__(self): 

208 return len(self.types) 

209 

210 def __iter__(self): 

211 return iter(self.types) 

212 

213 def _setSelection(self, value, at=None, label="assignment"): 

214 if self._config._frozen: 

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

216 

217 if at is None: 

218 at = getCallStack(1) 

219 

220 if value is None: 

221 self._selection = None 

222 elif self._field.multi: 

223 self._selection = SelectionSet(self, value, setHistory=False) 

224 else: 

225 if value not in self._dict: 

226 self.__getitem__(value, at=at) # just invoke __getitem__ to make sure it's present 

227 self._selection = value 

228 self._history.append((value, at, label)) 

229 

230 def _getNames(self): 

231 if not self._field.multi: 

232 raise FieldValidationError( 

233 self._field, self._config, "Single-selection field has no attribute 'names'" 

234 ) 

235 return self._selection 

236 

237 def _setNames(self, value): 

238 if not self._field.multi: 

239 raise FieldValidationError( 

240 self._field, self._config, "Single-selection field has no attribute 'names'" 

241 ) 

242 self._setSelection(value) 

243 

244 def _delNames(self): 

245 if not self._field.multi: 

246 raise FieldValidationError( 

247 self._field, self._config, "Single-selection field has no attribute 'names'" 

248 ) 

249 self._selection = None 

250 

251 def _getName(self): 

252 if self._field.multi: 

253 raise FieldValidationError( 

254 self._field, self._config, "Multi-selection field has no attribute 'name'" 

255 ) 

256 return self._selection 

257 

258 def _setName(self, value): 

259 if self._field.multi: 

260 raise FieldValidationError( 

261 self._field, self._config, "Multi-selection field has no attribute 'name'" 

262 ) 

263 self._setSelection(value) 

264 

265 def _delName(self): 

266 if self._field.multi: 

267 raise FieldValidationError( 

268 self._field, self._config, "Multi-selection field has no attribute 'name'" 

269 ) 

270 self._selection = None 

271 

272 names = property(_getNames, _setNames, _delNames) 

273 """List of names of active items in a multi-selection 

274 ``ConfigInstanceDict``. Disabled in a single-selection ``_Registry``; use 

275 the `name` attribute instead. 

276 """ 

277 

278 name = property(_getName, _setName, _delName) 

279 """Name of the active item in a single-selection ``ConfigInstanceDict``. 

280 Disabled in a multi-selection ``_Registry``; use the ``names`` attribute 

281 instead. 

282 """ 

283 

284 def _getActive(self): 

285 if self._selection is None: 

286 return None 

287 

288 if self._field.multi: 

289 return [self[c] for c in self._selection] 

290 else: 

291 return self[self._selection] 

292 

293 active = property(_getActive) 

294 """The selected items. 

295 

296 For multi-selection, this is equivalent to: ``[self[name] for name in 

297 self.names]``. For single-selection, this is equivalent to: ``self[name]``. 

298 """ 

299 

300 def __getitem__(self, k, at=None, label="default"): 

301 try: 

302 value = self._dict[k] 

303 except KeyError: 

304 try: 

305 dtype = self.types[k] 

306 except Exception as e: 

307 raise FieldValidationError( 

308 self._field, self._config, f"Unknown key {k!r} in Registry/ConfigChoiceField" 

309 ) from e 

310 name = _joinNamePath(self._config._name, self._field.name, k) 

311 if at is None: 

312 at = getCallStack() 

313 at.insert(0, dtype._source) 

314 value = self._dict.setdefault(k, dtype(__name=name, __at=at, __label=label)) 

315 return value 

316 

317 def __setitem__(self, k, value, at=None, label="assignment"): 

318 if self._config._frozen: 

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

320 

321 try: 

322 dtype = self.types[k] 

323 except Exception as e: 

324 raise FieldValidationError(self._field, self._config, f"Unknown key {k!r}") from e 

325 

326 if value != dtype and type(value) is not dtype: 

327 msg = ( 

328 f"Value {value} at key {k} is of incorrect type {_typeStr(value)}. " 

329 f"Expected type {_typeStr(dtype)}" 

330 ) 

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

332 

333 if at is None: 

334 at = getCallStack() 

335 name = _joinNamePath(self._config._name, self._field.name, k) 

336 oldValue = self._dict.get(k, None) 

337 if oldValue is None: 

338 if value == dtype: 

339 self._dict[k] = value(__name=name, __at=at, __label=label) 

340 else: 

341 self._dict[k] = dtype(__name=name, __at=at, __label=label, **value._storage) 

342 else: 

343 if value == dtype: 

344 value = value() 

345 oldValue.update(__at=at, __label=label, **value._storage) 

346 

347 def _rename(self, fullname): 

348 for k, v in self._dict.items(): 

349 v._rename(_joinNamePath(name=fullname, index=k)) 

350 

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

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

353 # This allows properties to work. 

354 object.__setattr__(self, attr, value) 

355 elif attr in self.__dict__ or attr in [ 

356 "_history", 

357 "_field", 

358 "_config", 

359 "_dict", 

360 "_selection", 

361 "__doc__", 

362 "_typemap", 

363 ]: 

364 # This allows specific private attributes to work. 

365 object.__setattr__(self, attr, value) 

366 else: 

367 # We throw everything else. 

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

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

370 

371 def freeze(self): 

372 """Freeze the config. 

373 

374 Invoking this freeze method will create a local copy of the field 

375 attribute's typemap. This decouples this instance dict from the 

376 underlying objects type map ensuring that and subsequent changes to the 

377 typemap will not be reflected in this instance (i.e imports adding 

378 additional registry entries). 

379 """ 

380 if self._typemap is None: 

381 self._typemap = copy.deepcopy(self.types) 

382 

383 def __reduce__(self): 

384 raise UnexpectedProxyUsageError( 

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

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

387 "being assigned to other objects or variables." 

388 ) 

389 

390 

391class ConfigChoiceField(Field[ConfigInstanceDict]): 

392 """A configuration field (`~lsst.pex.config.Field` subclass) that allows a 

393 user to choose from a set of `~lsst.pex.config.Config` types. 

394 

395 Parameters 

396 ---------- 

397 doc : `str` 

398 Documentation string for the field. 

399 typemap : `dict`-like 

400 A mapping between keys and `~lsst.pex.config.Config`-types as values. 

401 See *Examples* for details. 

402 default : `str`, optional 

403 The default configuration name. 

404 optional : `bool`, optional 

405 When `False`, `lsst.pex.config.Config.validate` will fail if the 

406 field's value is `None`. 

407 multi : `bool`, optional 

408 If `True`, the field allows multiple selections. In this case, set the 

409 selections by assigning a sequence to the ``names`` attribute of the 

410 field. 

411 

412 If `False`, the field allows only a single selection. In this case, 

413 set the active config by assigning the config's key from the 

414 ``typemap`` to the field's ``name`` attribute (see *Examples*). 

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

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

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

418 

419 See Also 

420 -------- 

421 ChoiceField 

422 ConfigDictField 

423 ConfigField 

424 ConfigurableField 

425 DictField 

426 Field 

427 ListField 

428 RangeField 

429 RegistryField 

430 

431 Notes 

432 ----- 

433 ``ConfigChoiceField`` instances can allow either single selections or 

434 multiple selections, depending on the ``multi`` parameter. For 

435 single-selection fields, set the selection with the ``name`` attribute. 

436 For multi-selection fields, set the selection though the ``names`` 

437 attribute. 

438 

439 This field is validated only against the active selection. If the 

440 ``active`` attribute is `None` and the field is not optional, validation 

441 will fail. 

442 

443 When saving a configuration with a ``ConfigChoiceField``, the entire set is 

444 saved, as well as the active selection. 

445 

446 Examples 

447 -------- 

448 While the ``typemap`` is shared by all instances of the field, each 

449 instance of the field has its own instance of a particular sub-config type. 

450 

451 For example, ``AaaConfig`` is a config object 

452 

453 >>> from lsst.pex.config import Config, ConfigChoiceField, Field 

454 >>> class AaaConfig(Config): 

455 ... somefield = Field("doc", int) 

456 

457 The ``MyConfig`` config has a ``ConfigChoiceField`` field called ``choice`` 

458 that maps the ``AaaConfig`` type to the ``"AAA"`` key: 

459 

460 >>> TYPEMAP = {"AAA", AaaConfig} 

461 >>> class MyConfig(Config): 

462 ... choice = ConfigChoiceField("doc for choice", TYPEMAP) 

463 

464 Creating an instance of ``MyConfig``: 

465 

466 >>> instance = MyConfig() 

467 

468 Setting value of the field ``somefield`` on the "AAA" key of the ``choice`` 

469 field: 

470 

471 >>> instance.choice["AAA"].somefield = 5 

472 

473 **Selecting the active configuration** 

474 

475 Make the ``"AAA"`` key the active configuration value for the ``choice`` 

476 field: 

477 

478 >>> instance.choice = "AAA" 

479 

480 Alternatively, the last line can be written: 

481 

482 >>> instance.choice.name = "AAA" 

483 

484 (If the config instance allows multiple selections, you'd assign a sequence 

485 to the ``names`` attribute instead.) 

486 

487 ``ConfigChoiceField`` instances also allow multiple values of the same 

488 type: 

489 

490 >>> TYPEMAP["CCC"] = AaaConfig 

491 >>> TYPEMAP["BBB"] = AaaConfig 

492 """ 

493 

494 instanceDictClass = ConfigInstanceDict 

495 

496 def __init__(self, doc, typemap, default=None, optional=False, multi=False, deprecated=None): 

497 source = getStackFrame() 

498 self._setup( 

499 doc=doc, 

500 dtype=self.instanceDictClass, 

501 default=default, 

502 check=None, 

503 optional=optional, 

504 source=source, 

505 deprecated=deprecated, 

506 ) 

507 self.typemap = typemap 

508 self.multi = multi 

509 

510 def __class_getitem__(cls, params: tuple[type, ...] | type | ForwardRef): 

511 raise ValueError("ConfigChoiceField does not support typing argument") 

512 

513 def _getOrMake(self, instance, label="default"): 

514 instanceDict = instance._storage.get(self.name) 

515 if instanceDict is None: 

516 at = getCallStack(1) 

517 instanceDict = self.dtype(instance, self) 

518 instanceDict.__doc__ = self.doc 

519 instance._storage[self.name] = instanceDict 

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

521 history.append(("Initialized from defaults", at, label)) 

522 

523 return instanceDict 

524 

525 @overload 

526 def __get__( 

527 self, instance: None, owner: Any = None, at: Any = None, label: str = "default" 

528 ) -> ConfigChoiceField: ... 

529 

530 @overload 

531 def __get__( 

532 self, instance: Config, owner: Any = None, at: Any = None, label: str = "default" 

533 ) -> ConfigInstanceDict: ... 

534 

535 def __get__(self, instance, owner=None, at=None, label="default"): 

536 if instance is None or not isinstance(instance, Config): 

537 return self 

538 else: 

539 return self._getOrMake(instance) 

540 

541 def __set__( 

542 self, instance: Config, value: ConfigInstanceDict | None, at: Any = None, label: str = "assignment" 

543 ) -> None: 

544 if instance._frozen: 

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

546 if at is None: 

547 at = getCallStack() 

548 instanceDict = self._getOrMake(instance) 

549 if isinstance(value, self.instanceDictClass): 

550 for k, v in value.items(): 

551 instanceDict.__setitem__(k, v, at=at, label=label) 

552 instanceDict._setSelection(value._selection, at=at, label=label) 

553 

554 else: 

555 instanceDict._setSelection(value, at=at, label=label) 

556 

557 def _copy_storage(self, old: Config, new: Config) -> Any: 

558 instance_dict: ConfigInstanceDict | None = old._storage.get(self.name) 

559 if instance_dict is not None: 

560 return instance_dict._copy(new) 

561 else: 

562 return None 

563 

564 def rename(self, instance): 

565 instanceDict = self.__get__(instance) 

566 fullname = _joinNamePath(instance._name, self.name) 

567 instanceDict._rename(fullname) 

568 

569 def validate(self, instance): 

570 instanceDict = self.__get__(instance) 

571 if instanceDict.active is None and not self.optional: 

572 msg = "Required field cannot be None" 

573 raise FieldValidationError(self, instance, msg) 

574 elif instanceDict.active is not None: 

575 if self.multi: 

576 for a in instanceDict.active: 

577 a.validate() 

578 else: 

579 instanceDict.active.validate() 

580 

581 def toDict(self, instance): 

582 instanceDict = self.__get__(instance) 

583 

584 dict_ = {} 

585 if self.multi: 

586 dict_["names"] = instanceDict.names 

587 else: 

588 dict_["name"] = instanceDict.name 

589 

590 values = {} 

591 for k, v in instanceDict.items(): 

592 values[k] = v.toDict() 

593 dict_["values"] = values 

594 

595 return dict_ 

596 

597 def freeze(self, instance): 

598 instanceDict = self.__get__(instance) 

599 instanceDict.freeze() 

600 for v in instanceDict.values(): 

601 v.freeze() 

602 

603 def _collectImports(self, instance, imports): 

604 instanceDict = self.__get__(instance) 

605 for config in instanceDict.values(): 

606 config._collectImports() 

607 imports |= config._imports 

608 

609 def save(self, outfile, instance): 

610 instanceDict = self.__get__(instance) 

611 fullname = _joinNamePath(instance._name, self.name) 

612 for v in instanceDict.values(): 

613 v._save(outfile) 

614 if self.multi: 

615 outfile.write(f"{fullname}.names={sorted(instanceDict.names)!r}\n") 

616 else: 

617 outfile.write(f"{fullname}.name={instanceDict.name!r}\n") 

618 

619 def __deepcopy__(self, memo): 

620 """Customize deep-copying, because we always want a reference to the 

621 original typemap. 

622 

623 WARNING: this must be overridden by subclasses if they change the 

624 constructor signature! 

625 """ 

626 other = type(self)( 

627 doc=self.doc, 

628 typemap=self.typemap, 

629 default=copy.deepcopy(self.default), 

630 optional=self.optional, 

631 multi=self.multi, 

632 ) 

633 other.source = self.source 

634 return other 

635 

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

637 """Compare two fields for equality. 

638 

639 Used by `lsst.pex.ConfigChoiceField.compare`. 

640 

641 Parameters 

642 ---------- 

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

644 Left-hand side config instance to compare. 

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

646 Right-hand side config instance to compare. 

647 shortcut : `bool` 

648 If `True`, this function returns as soon as an inequality if found. 

649 rtol : `float` 

650 Relative tolerance for floating point comparisons. 

651 atol : `float` 

652 Absolute tolerance for floating point comparisons. 

653 output : `collections.abc.Callable` 

654 A callable that takes a string, used (possibly repeatedly) to 

655 report inequalities. 

656 

657 Returns 

658 ------- 

659 isEqual : bool 

660 `True` if the fields are equal, `False` otherwise. 

661 

662 Notes 

663 ----- 

664 Only the selected configurations are compared, as the parameters of any 

665 others do not matter. 

666 

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

668 """ 

669 d1 = getattr(instance1, self.name) 

670 d2 = getattr(instance2, self.name) 

671 name = getComparisonName( 

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

673 ) 

674 if not compareScalars(f"selection for {name}", d1._selection, d2._selection, output=output): 

675 return False 

676 if d1._selection is None: 

677 return True 

678 if self.multi: 

679 nested = [(k, d1[k], d2[k]) for k in d1._selection] 

680 else: 

681 nested = [(d1._selection, d1[d1._selection], d2[d1._selection])] 

682 equal = True 

683 for k, c1, c2 in nested: 

684 result = compareConfigs( 

685 f"{name}[{k!r}]", c1, c2, shortcut=shortcut, rtol=rtol, atol=atol, output=output 

686 ) 

687 if not result and shortcut: 

688 return False 

689 equal = equal and result 

690 return equal