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

286 statements  

« prev     ^ index     » next       coverage.py v7.4.4, created at 2024-03-20 11:16 +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 

33import weakref 

34from typing import Any, ForwardRef, overload 

35 

36from .callStack import getCallStack, getStackFrame 

37from .comparison import compareConfigs, compareScalars, getComparisonName 

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

39 

40 

41class SelectionSet(collections.abc.MutableSet): 

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

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

44 

45 Parameters 

46 ---------- 

47 dict_ : `ConfigInstanceDict` 

48 The dictionary of instantiated configs. 

49 value : `~typing.Any` 

50 The selected key. 

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

52 The call stack when the selection was made. 

53 label : `str`, optional 

54 Label for history tracking. 

55 setHistory : `bool`, optional 

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

57 

58 Notes 

59 ----- 

60 This class allows a user of a multi-select 

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

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

63 history. 

64 """ 

65 

66 def __init__(self, dict_, value, at=None, label="assignment", setHistory=True): 

67 if at is None: 

68 at = getCallStack() 

69 self._dict = dict_ 

70 self._field = self._dict._field 

71 self._config_ = weakref.ref(self._dict._config) 

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

73 if value is not None: 

74 try: 

75 for v in value: 

76 if v not in self._dict: 

77 # invoke __getitem__ to ensure it's present 

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

79 except TypeError: 

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

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

82 self._set = set(value) 

83 else: 

84 self._set = set() 

85 

86 if setHistory: 

87 self.__history.append(("Set selection to %s" % self, at, label)) 

88 

89 @property 

90 def _config(self) -> Config: 

91 # Config Fields should never outlive their config class instance 

92 # assert that as such here 

93 assert self._config_() is not None 

94 return self._config_() 

95 

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

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

98 

99 Parameters 

100 ---------- 

101 value : `~typing.Any` 

102 The selected key. 

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

104 optional 

105 Stack frames for history recording. 

106 """ 

107 if self._config._frozen: 

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

109 

110 if at is None: 

111 at = getCallStack() 

112 

113 if value not in self._dict: 

114 # invoke __getitem__ to make sure it's present 

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

116 

117 self.__history.append(("added %s to selection" % value, at, "selection")) 

118 self._set.add(value) 

119 

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

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

122 

123 Parameters 

124 ---------- 

125 value : `~typing.Any` 

126 The selected key. 

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

128 optional 

129 Stack frames for history recording. 

130 """ 

131 if self._config._frozen: 

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

133 

134 if value not in self._dict: 

135 return 

136 

137 if at is None: 

138 at = getCallStack() 

139 

140 self.__history.append(("removed %s from selection" % value, at, "selection")) 

141 self._set.discard(value) 

142 

143 def __len__(self): 

144 return len(self._set) 

145 

146 def __iter__(self): 

147 return iter(self._set) 

148 

149 def __contains__(self, value): 

150 return value in self._set 

151 

152 def __repr__(self): 

153 return repr(list(self._set)) 

154 

155 def __str__(self): 

156 return str(list(self._set)) 

157 

158 def __reduce__(self): 

159 raise UnexpectedProxyUsageError( 

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

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

162 "being assigned to other objects or variables." 

163 ) 

164 

165 

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

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

168 `~lsst.pex.config.ConfigChoiceField`. 

169 

170 Parameters 

171 ---------- 

172 config : `lsst.pex.config.Config` 

173 A configuration instance. 

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

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

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

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

178 """ 

179 

180 def __init__(self, config, field): 

181 collections.abc.Mapping.__init__(self) 

182 self._dict = {} 

183 self._selection = None 

184 self._config = config 

185 self._field = field 

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

187 self.__doc__ = field.doc 

188 self._typemap = None 

189 

190 @property 

191 def types(self): 

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

193 

194 def __contains__(self, k): 

195 return k in self.types 

196 

197 def __len__(self): 

198 return len(self.types) 

199 

200 def __iter__(self): 

201 return iter(self.types) 

202 

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

204 if self._config._frozen: 

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

206 

207 if at is None: 

208 at = getCallStack(1) 

209 

210 if value is None: 

211 self._selection = None 

212 elif self._field.multi: 

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

214 else: 

215 if value not in self._dict: 

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

217 self._selection = value 

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

219 

220 def _getNames(self): 

221 if not self._field.multi: 

222 raise FieldValidationError( 

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

224 ) 

225 return self._selection 

226 

227 def _setNames(self, value): 

228 if not self._field.multi: 

229 raise FieldValidationError( 

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

231 ) 

232 self._setSelection(value) 

233 

234 def _delNames(self): 

235 if not self._field.multi: 

236 raise FieldValidationError( 

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

238 ) 

239 self._selection = None 

240 

241 def _getName(self): 

242 if self._field.multi: 

243 raise FieldValidationError( 

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

245 ) 

246 return self._selection 

247 

248 def _setName(self, value): 

249 if self._field.multi: 

250 raise FieldValidationError( 

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

252 ) 

253 self._setSelection(value) 

254 

255 def _delName(self): 

256 if self._field.multi: 

257 raise FieldValidationError( 

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

259 ) 

260 self._selection = None 

261 

262 names = property(_getNames, _setNames, _delNames) 

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

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

265 the `name` attribute instead. 

266 """ 

267 

268 name = property(_getName, _setName, _delName) 

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

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

271 instead. 

272 """ 

273 

274 def _getActive(self): 

275 if self._selection is None: 

276 return None 

277 

278 if self._field.multi: 

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

280 else: 

281 return self[self._selection] 

282 

283 active = property(_getActive) 

284 """The selected items. 

285 

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

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

288 """ 

289 

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

291 try: 

292 value = self._dict[k] 

293 except KeyError: 

294 try: 

295 dtype = self.types[k] 

296 except Exception: 

297 raise FieldValidationError( 

298 self._field, self._config, "Unknown key %r in Registry/ConfigChoiceField" % k 

299 ) 

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

301 if at is None: 

302 at = getCallStack() 

303 at.insert(0, dtype._source) 

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

305 return value 

306 

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

308 if self._config._frozen: 

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

310 

311 try: 

312 dtype = self.types[k] 

313 except Exception: 

314 raise FieldValidationError(self._field, self._config, "Unknown key %r" % k) 

315 

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

317 msg = "Value {} at key {} is of incorrect type {}. Expected type {}".format( 

318 value, 

319 k, 

320 _typeStr(value), 

321 _typeStr(dtype), 

322 ) 

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

324 

325 if at is None: 

326 at = getCallStack() 

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

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

329 if oldValue is None: 

330 if value == dtype: 

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

332 else: 

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

334 else: 

335 if value == dtype: 

336 value = value() 

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

338 

339 def _rename(self, fullname): 

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

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

342 

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

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

345 # This allows properties to work. 

346 object.__setattr__(self, attr, value) 

347 elif attr in self.__dict__ or attr in [ 

348 "_history", 

349 "_field", 

350 "_config", 

351 "_dict", 

352 "_selection", 

353 "__doc__", 

354 "_typemap", 

355 ]: 

356 # This allows specific private attributes to work. 

357 object.__setattr__(self, attr, value) 

358 else: 

359 # We throw everything else. 

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

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

362 

363 def freeze(self): 

364 """Freeze the config. 

365 

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

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

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

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

370 additional registry entries). 

371 """ 

372 if self._typemap is None: 

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

374 

375 def __reduce__(self): 

376 raise UnexpectedProxyUsageError( 

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

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

379 "being assigned to other objects or variables." 

380 ) 

381 

382 

383class ConfigChoiceField(Field[ConfigInstanceDict]): 

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

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

386 

387 Parameters 

388 ---------- 

389 doc : `str` 

390 Documentation string for the field. 

391 typemap : `dict`-like 

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

393 See *Examples* for details. 

394 default : `str`, optional 

395 The default configuration name. 

396 optional : `bool`, optional 

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

398 field's value is `None`. 

399 multi : `bool`, optional 

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

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

402 field. 

403 

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

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

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

407 deprecated : None or `str`, optional 

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

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

410 

411 See Also 

412 -------- 

413 ChoiceField 

414 ConfigDictField 

415 ConfigField 

416 ConfigurableField 

417 DictField 

418 Field 

419 ListField 

420 RangeField 

421 RegistryField 

422 

423 Notes 

424 ----- 

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

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

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

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

429 attribute. 

430 

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

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

433 will fail. 

434 

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

436 saved, as well as the active selection. 

437 

438 Examples 

439 -------- 

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

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

442 

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

444 

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

446 >>> class AaaConfig(Config): 

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

448 ... 

449 

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

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

452 

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

454 >>> class MyConfig(Config): 

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

456 ... 

457 

458 Creating an instance of ``MyConfig``: 

459 

460 >>> instance = MyConfig() 

461 

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

463 field: 

464 

465 >>> instance.choice['AAA'].somefield = 5 

466 

467 **Selecting the active configuration** 

468 

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

470 field: 

471 

472 >>> instance.choice = "AAA" 

473 

474 Alternatively, the last line can be written: 

475 

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

477 

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

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

480 

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

482 type: 

483 

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

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

486 """ 

487 

488 instanceDictClass = ConfigInstanceDict 

489 

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

491 source = getStackFrame() 

492 self._setup( 

493 doc=doc, 

494 dtype=self.instanceDictClass, 

495 default=default, 

496 check=None, 

497 optional=optional, 

498 source=source, 

499 deprecated=deprecated, 

500 ) 

501 self.typemap = typemap 

502 self.multi = multi 

503 

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

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

506 

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

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

509 if instanceDict is None: 

510 at = getCallStack(1) 

511 instanceDict = self.dtype(instance, self) 

512 instanceDict.__doc__ = self.doc 

513 instance._storage[self.name] = instanceDict 

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

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

516 

517 return instanceDict 

518 

519 @overload 

520 def __get__( 

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

522 ) -> ConfigChoiceField: 

523 ... 

524 

525 @overload 

526 def __get__( 

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

528 ) -> ConfigInstanceDict: 

529 ... 

530 

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

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

533 return self 

534 else: 

535 return self._getOrMake(instance) 

536 

537 def __set__( 

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

539 ) -> None: 

540 if instance._frozen: 

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

542 if at is None: 

543 at = getCallStack() 

544 instanceDict = self._getOrMake(instance) 

545 if isinstance(value, self.instanceDictClass): 

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

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

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

549 

550 else: 

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

552 

553 def rename(self, instance): 

554 instanceDict = self.__get__(instance) 

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

556 instanceDict._rename(fullname) 

557 

558 def validate(self, instance): 

559 instanceDict = self.__get__(instance) 

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

561 msg = "Required field cannot be None" 

562 raise FieldValidationError(self, instance, msg) 

563 elif instanceDict.active is not None: 

564 if self.multi: 

565 for a in instanceDict.active: 

566 a.validate() 

567 else: 

568 instanceDict.active.validate() 

569 

570 def toDict(self, instance): 

571 instanceDict = self.__get__(instance) 

572 

573 dict_ = {} 

574 if self.multi: 

575 dict_["names"] = instanceDict.names 

576 else: 

577 dict_["name"] = instanceDict.name 

578 

579 values = {} 

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

581 values[k] = v.toDict() 

582 dict_["values"] = values 

583 

584 return dict_ 

585 

586 def freeze(self, instance): 

587 instanceDict = self.__get__(instance) 

588 instanceDict.freeze() 

589 for v in instanceDict.values(): 

590 v.freeze() 

591 

592 def _collectImports(self, instance, imports): 

593 instanceDict = self.__get__(instance) 

594 for config in instanceDict.values(): 

595 config._collectImports() 

596 imports |= config._imports 

597 

598 def save(self, outfile, instance): 

599 instanceDict = self.__get__(instance) 

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

601 for v in instanceDict.values(): 

602 v._save(outfile) 

603 if self.multi: 

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

605 else: 

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

607 

608 def __deepcopy__(self, memo): 

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

610 original typemap. 

611 

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

613 constructor signature! 

614 """ 

615 other = type(self)( 

616 doc=self.doc, 

617 typemap=self.typemap, 

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

619 optional=self.optional, 

620 multi=self.multi, 

621 ) 

622 other.source = self.source 

623 return other 

624 

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

626 """Compare two fields for equality. 

627 

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

629 

630 Parameters 

631 ---------- 

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

633 Left-hand side config instance to compare. 

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

635 Right-hand side config instance to compare. 

636 shortcut : `bool` 

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

638 rtol : `float` 

639 Relative tolerance for floating point comparisons. 

640 atol : `float` 

641 Absolute tolerance for floating point comparisons. 

642 output : callable 

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

644 report inequalities. 

645 

646 Returns 

647 ------- 

648 isEqual : bool 

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

650 

651 Notes 

652 ----- 

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

654 others do not matter. 

655 

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

657 """ 

658 d1 = getattr(instance1, self.name) 

659 d2 = getattr(instance2, self.name) 

660 name = getComparisonName( 

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

662 ) 

663 if not compareScalars("selection for %s" % name, d1._selection, d2._selection, output=output): 

664 return False 

665 if d1._selection is None: 

666 return True 

667 if self.multi: 

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

669 else: 

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

671 equal = True 

672 for k, c1, c2 in nested: 

673 result = compareConfigs( 

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

675 ) 

676 if not result and shortcut: 

677 return False 

678 equal = equal and result 

679 return equal