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

286 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2023-07-06 09:49 +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 

50 The selected key. 

51 at : `lsst.pex.config.callStack.StackFrame`, 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 if self._config._frozen: 

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

100 

101 if at is None: 

102 at = getCallStack() 

103 

104 if value not in self._dict: 

105 # invoke __getitem__ to make sure it's present 

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

107 

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

109 self._set.add(value) 

110 

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

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

113 if self._config._frozen: 

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

115 

116 if value not in self._dict: 

117 return 

118 

119 if at is None: 

120 at = getCallStack() 

121 

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

123 self._set.discard(value) 

124 

125 def __len__(self): 

126 return len(self._set) 

127 

128 def __iter__(self): 

129 return iter(self._set) 

130 

131 def __contains__(self, value): 

132 return value in self._set 

133 

134 def __repr__(self): 

135 return repr(list(self._set)) 

136 

137 def __str__(self): 

138 return str(list(self._set)) 

139 

140 def __reduce__(self): 

141 raise UnexpectedProxyUsageError( 

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

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

144 "being assigned to other objects or variables." 

145 ) 

146 

147 

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

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

150 `~lsst.pex.config.ConfigChoiceField`. 

151 

152 Parameters 

153 ---------- 

154 config : `lsst.pex.config.Config` 

155 A configuration instance. 

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

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

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

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

160 """ 

161 

162 def __init__(self, config, field): 

163 collections.abc.Mapping.__init__(self) 

164 self._dict = {} 

165 self._selection = None 

166 self._config = config 

167 self._field = field 

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

169 self.__doc__ = field.doc 

170 self._typemap = None 

171 

172 @property 

173 def types(self): 

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

175 

176 def __contains__(self, k): 

177 return k in self.types 

178 

179 def __len__(self): 

180 return len(self.types) 

181 

182 def __iter__(self): 

183 return iter(self.types) 

184 

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

186 if self._config._frozen: 

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

188 

189 if at is None: 

190 at = getCallStack(1) 

191 

192 if value is None: 

193 self._selection = None 

194 elif self._field.multi: 

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

196 else: 

197 if value not in self._dict: 

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

199 self._selection = value 

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

201 

202 def _getNames(self): 

203 if not self._field.multi: 

204 raise FieldValidationError( 

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

206 ) 

207 return self._selection 

208 

209 def _setNames(self, value): 

210 if not self._field.multi: 

211 raise FieldValidationError( 

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

213 ) 

214 self._setSelection(value) 

215 

216 def _delNames(self): 

217 if not self._field.multi: 

218 raise FieldValidationError( 

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

220 ) 

221 self._selection = None 

222 

223 def _getName(self): 

224 if self._field.multi: 

225 raise FieldValidationError( 

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

227 ) 

228 return self._selection 

229 

230 def _setName(self, value): 

231 if self._field.multi: 

232 raise FieldValidationError( 

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

234 ) 

235 self._setSelection(value) 

236 

237 def _delName(self): 

238 if self._field.multi: 

239 raise FieldValidationError( 

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

241 ) 

242 self._selection = None 

243 

244 names = property(_getNames, _setNames, _delNames) 

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

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

247 the `name` attribute instead. 

248 """ 

249 

250 name = property(_getName, _setName, _delName) 

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

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

253 instead. 

254 """ 

255 

256 def _getActive(self): 

257 if self._selection is None: 

258 return None 

259 

260 if self._field.multi: 

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

262 else: 

263 return self[self._selection] 

264 

265 active = property(_getActive) 

266 """The selected items. 

267 

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

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

270 """ 

271 

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

273 try: 

274 value = self._dict[k] 

275 except KeyError: 

276 try: 

277 dtype = self.types[k] 

278 except Exception: 

279 raise FieldValidationError( 

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

281 ) 

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

283 if at is None: 

284 at = getCallStack() 

285 at.insert(0, dtype._source) 

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

287 return value 

288 

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

290 if self._config._frozen: 

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

292 

293 try: 

294 dtype = self.types[k] 

295 except Exception: 

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

297 

298 if value != dtype and type(value) != dtype: 

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

300 value, 

301 k, 

302 _typeStr(value), 

303 _typeStr(dtype), 

304 ) 

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

306 

307 if at is None: 

308 at = getCallStack() 

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

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

311 if oldValue is None: 

312 if value == dtype: 

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

314 else: 

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

316 else: 

317 if value == dtype: 

318 value = value() 

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

320 

321 def _rename(self, fullname): 

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

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

324 

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

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

327 # This allows properties to work. 

328 object.__setattr__(self, attr, value) 

329 elif attr in self.__dict__ or attr in [ 

330 "_history", 

331 "_field", 

332 "_config", 

333 "_dict", 

334 "_selection", 

335 "__doc__", 

336 "_typemap", 

337 ]: 

338 # This allows specific private attributes to work. 

339 object.__setattr__(self, attr, value) 

340 else: 

341 # We throw everything else. 

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

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

344 

345 def freeze(self): 

346 """Freeze the config. 

347 

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

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

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

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

352 additional registry entries). 

353 """ 

354 if self._typemap is None: 

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

356 

357 def __reduce__(self): 

358 raise UnexpectedProxyUsageError( 

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

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

361 "being assigned to other objects or variables." 

362 ) 

363 

364 

365class ConfigChoiceField(Field[ConfigInstanceDict]): 

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

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

368 

369 Parameters 

370 ---------- 

371 doc : `str` 

372 Documentation string for the field. 

373 typemap : `dict`-like 

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

375 See *Examples* for details. 

376 default : `str`, optional 

377 The default configuration name. 

378 optional : `bool`, optional 

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

380 field's value is `None`. 

381 multi : `bool`, optional 

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

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

384 field. 

385 

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

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

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

389 deprecated : None or `str`, optional 

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

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

392 

393 See Also 

394 -------- 

395 ChoiceField 

396 ConfigDictField 

397 ConfigField 

398 ConfigurableField 

399 DictField 

400 Field 

401 ListField 

402 RangeField 

403 RegistryField 

404 

405 Notes 

406 ----- 

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

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

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

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

411 attribute. 

412 

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

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

415 will fail. 

416 

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

418 saved, as well as the active selection. 

419 

420 Examples 

421 -------- 

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

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

424 

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

426 

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

428 >>> class AaaConfig(Config): 

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

430 ... 

431 

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

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

434 

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

436 >>> class MyConfig(Config): 

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

438 ... 

439 

440 Creating an instance of ``MyConfig``: 

441 

442 >>> instance = MyConfig() 

443 

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

445 field: 

446 

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

448 

449 **Selecting the active configuration** 

450 

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

452 field: 

453 

454 >>> instance.choice = "AAA" 

455 

456 Alternatively, the last line can be written: 

457 

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

459 

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

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

462 

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

464 type: 

465 

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

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

468 """ 

469 

470 instanceDictClass = ConfigInstanceDict 

471 

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

473 source = getStackFrame() 

474 self._setup( 

475 doc=doc, 

476 dtype=self.instanceDictClass, 

477 default=default, 

478 check=None, 

479 optional=optional, 

480 source=source, 

481 deprecated=deprecated, 

482 ) 

483 self.typemap = typemap 

484 self.multi = multi 

485 

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

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

488 

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

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

491 if instanceDict is None: 

492 at = getCallStack(1) 

493 instanceDict = self.dtype(instance, self) 

494 instanceDict.__doc__ = self.doc 

495 instance._storage[self.name] = instanceDict 

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

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

498 

499 return instanceDict 

500 

501 @overload 

502 def __get__( 

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

504 ) -> ConfigChoiceField: 

505 ... 

506 

507 @overload 

508 def __get__( 

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

510 ) -> ConfigInstanceDict: 

511 ... 

512 

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

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

515 return self 

516 else: 

517 return self._getOrMake(instance) 

518 

519 def __set__( 

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

521 ) -> None: 

522 if instance._frozen: 

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

524 if at is None: 

525 at = getCallStack() 

526 instanceDict = self._getOrMake(instance) 

527 if isinstance(value, self.instanceDictClass): 

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

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

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

531 

532 else: 

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

534 

535 def rename(self, instance): 

536 instanceDict = self.__get__(instance) 

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

538 instanceDict._rename(fullname) 

539 

540 def validate(self, instance): 

541 instanceDict = self.__get__(instance) 

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

543 msg = "Required field cannot be None" 

544 raise FieldValidationError(self, instance, msg) 

545 elif instanceDict.active is not None: 

546 if self.multi: 

547 for a in instanceDict.active: 

548 a.validate() 

549 else: 

550 instanceDict.active.validate() 

551 

552 def toDict(self, instance): 

553 instanceDict = self.__get__(instance) 

554 

555 dict_ = {} 

556 if self.multi: 

557 dict_["names"] = instanceDict.names 

558 else: 

559 dict_["name"] = instanceDict.name 

560 

561 values = {} 

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

563 values[k] = v.toDict() 

564 dict_["values"] = values 

565 

566 return dict_ 

567 

568 def freeze(self, instance): 

569 instanceDict = self.__get__(instance) 

570 instanceDict.freeze() 

571 for v in instanceDict.values(): 

572 v.freeze() 

573 

574 def _collectImports(self, instance, imports): 

575 instanceDict = self.__get__(instance) 

576 for config in instanceDict.values(): 

577 config._collectImports() 

578 imports |= config._imports 

579 

580 def save(self, outfile, instance): 

581 instanceDict = self.__get__(instance) 

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

583 for v in instanceDict.values(): 

584 v._save(outfile) 

585 if self.multi: 

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

587 else: 

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

589 

590 def __deepcopy__(self, memo): 

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

592 original typemap. 

593 

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

595 constructor signature! 

596 """ 

597 other = type(self)( 

598 doc=self.doc, 

599 typemap=self.typemap, 

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

601 optional=self.optional, 

602 multi=self.multi, 

603 ) 

604 other.source = self.source 

605 return other 

606 

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

608 """Compare two fields for equality. 

609 

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

611 

612 Parameters 

613 ---------- 

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

615 Left-hand side config instance to compare. 

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

617 Right-hand side config instance to compare. 

618 shortcut : `bool` 

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

620 rtol : `float` 

621 Relative tolerance for floating point comparisons. 

622 atol : `float` 

623 Absolute tolerance for floating point comparisons. 

624 output : callable 

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

626 report inequalities. 

627 

628 Returns 

629 ------- 

630 isEqual : bool 

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

632 

633 Notes 

634 ----- 

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

636 others do not matter. 

637 

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

639 """ 

640 d1 = getattr(instance1, self.name) 

641 d2 = getattr(instance2, self.name) 

642 name = getComparisonName( 

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

644 ) 

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

646 return False 

647 if d1._selection is None: 

648 return True 

649 if self.multi: 

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

651 else: 

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

653 equal = True 

654 for k, c1, c2 in nested: 

655 result = compareConfigs( 

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

657 ) 

658 if not result and shortcut: 

659 return False 

660 equal = equal and result 

661 return equal