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

290 statements  

« prev     ^ index     » next       coverage.py v6.5.0, created at 2023-02-04 02:44 -0800

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 sys 

34import weakref 

35from typing import Any, ForwardRef, Optional, Union, overload 

36 

37from .callStack import getCallStack, getStackFrame 

38from .comparison import compareConfigs, compareScalars, getComparisonName 

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

40 

41 

42class SelectionSet(collections.abc.MutableSet): 

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

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

45 

46 Parameters 

47 ---------- 

48 dict_ : `ConfigInstanceDict` 

49 The dictionary of instantiated configs. 

50 value 

51 The selected key. 

52 at : `lsst.pex.config.callStack.StackFrame`, optional 

53 The call stack when the selection was made. 

54 label : `str`, optional 

55 Label for history tracking. 

56 setHistory : `bool`, optional 

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

58 

59 Notes 

60 ----- 

61 This class allows a user of a multi-select 

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

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

64 history. 

65 """ 

66 

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

68 if at is None: 

69 at = getCallStack() 

70 self._dict = dict_ 

71 self._field = self._dict._field 

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

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

74 if value is not None: 

75 try: 

76 for v in value: 

77 if v not in self._dict: 

78 # invoke __getitem__ to ensure it's present 

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

80 except TypeError: 

81 msg = "Value %s is of incorrect type %s. Sequence type expected" % (value, _typeStr(value)) 

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

83 self._set = set(value) 

84 else: 

85 self._set = set() 

86 

87 if setHistory: 

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

89 

90 @property 

91 def _config(self) -> Config: 

92 # Config Fields should never outlive their config class instance 

93 # assert that as such here 

94 assert self._config_() is not None 

95 return self._config_() 

96 

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

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

99 if self._config._frozen: 

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

101 

102 if at is None: 

103 at = getCallStack() 

104 

105 if value not in self._dict: 

106 # invoke __getitem__ to make sure it's present 

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

108 

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

110 self._set.add(value) 

111 

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

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

114 if self._config._frozen: 

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

116 

117 if value not in self._dict: 

118 return 

119 

120 if at is None: 

121 at = getCallStack() 

122 

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

124 self._set.discard(value) 

125 

126 def __len__(self): 

127 return len(self._set) 

128 

129 def __iter__(self): 

130 return iter(self._set) 

131 

132 def __contains__(self, value): 

133 return value in self._set 

134 

135 def __repr__(self): 

136 return repr(list(self._set)) 

137 

138 def __str__(self): 

139 return str(list(self._set)) 

140 

141 def __reduce__(self): 

142 raise UnexpectedProxyUsageError( 

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

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

145 "being assigned to other objects or variables." 

146 ) 

147 

148 

149if int(sys.version_info.minor) < 9: 149 ↛ 150line 149 didn't jump to line 150, because the condition on line 149 was never true

150 _bases = (collections.abc.Mapping,) 

151else: 

152 _bases = (collections.abc.Mapping[str, Config],) 

153 

154 

155class ConfigInstanceDict(*_bases): 

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

157 `~lsst.pex.config.ConfigChoiceField`. 

158 

159 Parameters 

160 ---------- 

161 config : `lsst.pex.config.Config` 

162 A configuration instance. 

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

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

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

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

167 """ 

168 

169 def __init__(self, config, field): 

170 collections.abc.Mapping.__init__(self) 

171 self._dict = dict() 

172 self._selection = None 

173 self._config = config 

174 self._field = field 

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

176 self.__doc__ = field.doc 

177 self._typemap = None 

178 

179 @property 

180 def types(self): 

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

182 

183 def __contains__(self, k): 

184 return k in self.types 

185 

186 def __len__(self): 

187 return len(self.types) 

188 

189 def __iter__(self): 

190 return iter(self.types) 

191 

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

193 if self._config._frozen: 

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

195 

196 if at is None: 

197 at = getCallStack(1) 

198 

199 if value is None: 

200 self._selection = None 

201 elif self._field.multi: 

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

203 else: 

204 if value not in self._dict: 

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

206 self._selection = value 

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

208 

209 def _getNames(self): 

210 if not self._field.multi: 

211 raise FieldValidationError( 

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

213 ) 

214 return self._selection 

215 

216 def _setNames(self, value): 

217 if not self._field.multi: 

218 raise FieldValidationError( 

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

220 ) 

221 self._setSelection(value) 

222 

223 def _delNames(self): 

224 if not self._field.multi: 

225 raise FieldValidationError( 

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

227 ) 

228 self._selection = None 

229 

230 def _getName(self): 

231 if self._field.multi: 

232 raise FieldValidationError( 

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

234 ) 

235 return self._selection 

236 

237 def _setName(self, value): 

238 if self._field.multi: 

239 raise FieldValidationError( 

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

241 ) 

242 self._setSelection(value) 

243 

244 def _delName(self): 

245 if self._field.multi: 

246 raise FieldValidationError( 

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

248 ) 

249 self._selection = None 

250 

251 names = property(_getNames, _setNames, _delNames) 

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

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

254 the `name` attribute instead. 

255 """ 

256 

257 name = property(_getName, _setName, _delName) 

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

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

260 instead. 

261 """ 

262 

263 def _getActive(self): 

264 if self._selection is None: 

265 return None 

266 

267 if self._field.multi: 

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

269 else: 

270 return self[self._selection] 

271 

272 active = property(_getActive) 

273 """The selected items. 

274 

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

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

277 """ 

278 

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

280 try: 

281 value = self._dict[k] 

282 except KeyError: 

283 try: 

284 dtype = self.types[k] 

285 except Exception: 

286 raise FieldValidationError( 

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

288 ) 

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

290 if at is None: 

291 at = getCallStack() 

292 at.insert(0, dtype._source) 

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

294 return value 

295 

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

297 if self._config._frozen: 

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

299 

300 try: 

301 dtype = self.types[k] 

302 except Exception: 

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

304 

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

306 msg = "Value %s at key %s is of incorrect type %s. Expected type %s" % ( 

307 value, 

308 k, 

309 _typeStr(value), 

310 _typeStr(dtype), 

311 ) 

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

313 

314 if at is None: 

315 at = getCallStack() 

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

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

318 if oldValue is None: 

319 if value == dtype: 

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

321 else: 

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

323 else: 

324 if value == dtype: 

325 value = value() 

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

327 

328 def _rename(self, fullname): 

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

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

331 

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

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

334 # This allows properties to work. 

335 object.__setattr__(self, attr, value) 

336 elif attr in self.__dict__ or attr in [ 

337 "_history", 

338 "_field", 

339 "_config", 

340 "_dict", 

341 "_selection", 

342 "__doc__", 

343 "_typemap", 

344 ]: 

345 # This allows specific private attributes to work. 

346 object.__setattr__(self, attr, value) 

347 else: 

348 # We throw everything else. 

349 msg = "%s has no attribute %s" % (_typeStr(self._field), attr) 

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

351 

352 def freeze(self): 

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

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

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

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

357 additional registry entries). 

358 """ 

359 if self._typemap is None: 

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

361 

362 def __reduce__(self): 

363 raise UnexpectedProxyUsageError( 

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

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

366 "being assigned to other objects or variables." 

367 ) 

368 

369 

370class ConfigChoiceField(Field[ConfigInstanceDict]): 

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

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

373 

374 Parameters 

375 ---------- 

376 doc : `str` 

377 Documentation string for the field. 

378 typemap : `dict`-like 

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

380 See *Examples* for details. 

381 default : `str`, optional 

382 The default configuration name. 

383 optional : `bool`, optional 

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

385 field's value is `None`. 

386 multi : `bool`, optional 

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

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

389 field. 

390 

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

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

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

394 deprecated : None or `str`, optional 

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

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

397 

398 See also 

399 -------- 

400 ChoiceField 

401 ConfigDictField 

402 ConfigField 

403 ConfigurableField 

404 DictField 

405 Field 

406 ListField 

407 RangeField 

408 RegistryField 

409 

410 Notes 

411 ----- 

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

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

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

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

416 attribute. 

417 

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

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

420 will fail. 

421 

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

423 saved, as well as the active selection. 

424 

425 Examples 

426 -------- 

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

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

429 

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

431 

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

433 >>> class AaaConfig(Config): 

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

435 ... 

436 

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

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

439 

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

441 >>> class MyConfig(Config): 

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

443 ... 

444 

445 Creating an instance of ``MyConfig``: 

446 

447 >>> instance = MyConfig() 

448 

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

450 field: 

451 

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

453 

454 **Selecting the active configuration** 

455 

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

457 field: 

458 

459 >>> instance.choice = "AAA" 

460 

461 Alternatively, the last line can be written: 

462 

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

464 

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

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

467 

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

469 type: 

470 

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

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

473 """ 

474 

475 instanceDictClass = ConfigInstanceDict 

476 

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

478 source = getStackFrame() 

479 self._setup( 

480 doc=doc, 

481 dtype=self.instanceDictClass, 

482 default=default, 

483 check=None, 

484 optional=optional, 

485 source=source, 

486 deprecated=deprecated, 

487 ) 

488 self.typemap = typemap 

489 self.multi = multi 

490 

491 def __class_getitem__(cls, params: Union[tuple[type, ...], type, ForwardRef]): 

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

493 

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

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

496 if instanceDict is None: 

497 at = getCallStack(1) 

498 instanceDict = self.dtype(instance, self) 

499 instanceDict.__doc__ = self.doc 

500 instance._storage[self.name] = instanceDict 

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

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

503 

504 return instanceDict 

505 

506 @overload 

507 def __get__( 

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

509 ) -> "ConfigChoiceField": 

510 ... 

511 

512 @overload 

513 def __get__( 

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

515 ) -> ConfigInstanceDict: 

516 ... 

517 

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

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

520 return self 

521 else: 

522 return self._getOrMake(instance) 

523 

524 def __set__( 

525 self, instance: Config, value: Optional[ConfigInstanceDict], at: Any = None, label: str = "assignment" 

526 ) -> None: 

527 if instance._frozen: 

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

529 if at is None: 

530 at = getCallStack() 

531 instanceDict = self._getOrMake(instance) 

532 if isinstance(value, self.instanceDictClass): 

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

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

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

536 

537 else: 

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

539 

540 def rename(self, instance): 

541 instanceDict = self.__get__(instance) 

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

543 instanceDict._rename(fullname) 

544 

545 def validate(self, instance): 

546 instanceDict = self.__get__(instance) 

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

548 msg = "Required field cannot be None" 

549 raise FieldValidationError(self, instance, msg) 

550 elif instanceDict.active is not None: 

551 if self.multi: 

552 for a in instanceDict.active: 

553 a.validate() 

554 else: 

555 instanceDict.active.validate() 

556 

557 def toDict(self, instance): 

558 instanceDict = self.__get__(instance) 

559 

560 dict_ = {} 

561 if self.multi: 

562 dict_["names"] = instanceDict.names 

563 else: 

564 dict_["name"] = instanceDict.name 

565 

566 values = {} 

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

568 values[k] = v.toDict() 

569 dict_["values"] = values 

570 

571 return dict_ 

572 

573 def freeze(self, instance): 

574 instanceDict = self.__get__(instance) 

575 instanceDict.freeze() 

576 for v in instanceDict.values(): 

577 v.freeze() 

578 

579 def _collectImports(self, instance, imports): 

580 instanceDict = self.__get__(instance) 

581 for config in instanceDict.values(): 

582 config._collectImports() 

583 imports |= config._imports 

584 

585 def save(self, outfile, instance): 

586 instanceDict = self.__get__(instance) 

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

588 for v in instanceDict.values(): 

589 v._save(outfile) 

590 if self.multi: 

591 outfile.write("{}.names={!r}\n".format(fullname, sorted(instanceDict.names))) 

592 else: 

593 outfile.write("{}.name={!r}\n".format(fullname, instanceDict.name)) 

594 

595 def __deepcopy__(self, memo): 

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

597 original typemap. 

598 

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

600 constructor signature! 

601 """ 

602 other = type(self)( 

603 doc=self.doc, 

604 typemap=self.typemap, 

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

606 optional=self.optional, 

607 multi=self.multi, 

608 ) 

609 other.source = self.source 

610 return other 

611 

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

613 """Compare two fields for equality. 

614 

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

616 

617 Parameters 

618 ---------- 

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

620 Left-hand side config instance to compare. 

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

622 Right-hand side config instance to compare. 

623 shortcut : `bool` 

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

625 rtol : `float` 

626 Relative tolerance for floating point comparisons. 

627 atol : `float` 

628 Absolute tolerance for floating point comparisons. 

629 output : callable 

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

631 report inequalities. 

632 

633 Returns 

634 ------- 

635 isEqual : bool 

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

637 

638 Notes 

639 ----- 

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

641 others do not matter. 

642 

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

644 """ 

645 d1 = getattr(instance1, self.name) 

646 d2 = getattr(instance2, self.name) 

647 name = getComparisonName( 

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

649 ) 

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

651 return False 

652 if d1._selection is None: 

653 return True 

654 if self.multi: 

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

656 else: 

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

658 equal = True 

659 for k, c1, c2 in nested: 

660 result = compareConfigs( 

661 "%s[%r]" % (name, k), c1, c2, shortcut=shortcut, rtol=rtol, atol=atol, output=output 

662 ) 

663 if not result and shortcut: 

664 return False 

665 equal = equal and result 

666 return equal