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

276 statements  

« prev     ^ index     » next       coverage.py v6.4.1, created at 2022-06-18 02:21 -0700

1# This file is part of pex_config. 

2# 

3# Developed for the LSST Data Management System. 

4# This product includes software developed by the LSST Project 

5# (http://www.lsst.org). 

6# See the COPYRIGHT file at the top-level directory of this distribution 

7# for details of code ownership. 

8# 

9# This software is dual licensed under the GNU General Public License and also 

10# under a 3-clause BSD license. Recipients may choose which of these licenses 

11# to use; please see the files gpl-3.0.txt and/or bsd_license.txt, 

12# respectively. If you choose the GPL option then the following text applies 

13# (but note that there is still no warranty even if you opt for BSD instead): 

14# 

15# This program is free software: you can redistribute it and/or modify 

16# it under the terms of the GNU General Public License as published by 

17# the Free Software Foundation, either version 3 of the License, or 

18# (at your option) any later version. 

19# 

20# This program is distributed in the hope that it will be useful, 

21# but WITHOUT ANY WARRANTY; without even the implied warranty of 

22# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

23# GNU General Public License for more details. 

24# 

25# You should have received a copy of the GNU General Public License 

26# along with this program. If not, see <http://www.gnu.org/licenses/>. 

27 

28__all__ = ["ConfigChoiceField"] 

29 

30import collections.abc 

31import copy 

32import weakref 

33 

34from .callStack import getCallStack, getStackFrame 

35from .comparison import compareConfigs, compareScalars, getComparisonName 

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

37 

38 

39class SelectionSet(collections.abc.MutableSet): 

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

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

42 

43 Parameters 

44 ---------- 

45 dict_ : `ConfigInstanceDict` 

46 The dictionary of instantiated configs. 

47 value 

48 The selected key. 

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

50 The call stack when the selection was made. 

51 label : `str`, optional 

52 Label for history tracking. 

53 setHistory : `bool`, optional 

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

55 

56 Notes 

57 ----- 

58 This class allows a user of a multi-select 

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

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

61 history. 

62 """ 

63 

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

65 if at is None: 

66 at = getCallStack() 

67 self._dict = dict_ 

68 self._field = self._dict._field 

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

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

71 if value is not None: 

72 try: 

73 for v in value: 

74 if v not in self._dict: 

75 # invoke __getitem__ to ensure it's present 

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

77 except TypeError: 

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

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

80 self._set = set(value) 

81 else: 

82 self._set = set() 

83 

84 if setHistory: 

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

86 

87 @property 

88 def _config(self) -> Config: 

89 # Config Fields should never outlive their config class instance 

90 # assert that as such here 

91 assert self._config_() is not None 

92 return self._config_() 

93 

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

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

96 if self._config._frozen: 

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

98 

99 if at is None: 

100 at = getCallStack() 

101 

102 if value not in self._dict: 

103 # invoke __getitem__ to make sure it's present 

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

105 

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

107 self._set.add(value) 

108 

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

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

111 if self._config._frozen: 

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

113 

114 if value not in self._dict: 

115 return 

116 

117 if at is None: 

118 at = getCallStack() 

119 

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

121 self._set.discard(value) 

122 

123 def __len__(self): 

124 return len(self._set) 

125 

126 def __iter__(self): 

127 return iter(self._set) 

128 

129 def __contains__(self, value): 

130 return value in self._set 

131 

132 def __repr__(self): 

133 return repr(list(self._set)) 

134 

135 def __str__(self): 

136 return str(list(self._set)) 

137 

138 def __reduce__(self): 

139 raise UnexpectedProxyUsageError( 

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

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

142 "being assigned to other objects or variables." 

143 ) 

144 

145 

146class ConfigInstanceDict(collections.abc.Mapping): 

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

148 `~lsst.pex.config.ConfigChoiceField`. 

149 

150 Parameters 

151 ---------- 

152 config : `lsst.pex.config.Config` 

153 A configuration instance. 

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

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

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

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

158 """ 

159 

160 def __init__(self, config, field): 

161 collections.abc.Mapping.__init__(self) 

162 self._dict = dict() 

163 self._selection = None 

164 self._config = config 

165 self._field = field 

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

167 self.__doc__ = field.doc 

168 self._typemap = None 

169 

170 @property 

171 def types(self): 

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

173 

174 def __contains__(self, k): 

175 return k in self.types 

176 

177 def __len__(self): 

178 return len(self.types) 

179 

180 def __iter__(self): 

181 return iter(self.types) 

182 

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

184 if self._config._frozen: 

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

186 

187 if at is None: 

188 at = getCallStack(1) 

189 

190 if value is None: 

191 self._selection = None 

192 elif self._field.multi: 

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

194 else: 

195 if value not in self._dict: 

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

197 self._selection = value 

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

199 

200 def _getNames(self): 

201 if not self._field.multi: 

202 raise FieldValidationError( 

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

204 ) 

205 return self._selection 

206 

207 def _setNames(self, value): 

208 if not self._field.multi: 

209 raise FieldValidationError( 

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

211 ) 

212 self._setSelection(value) 

213 

214 def _delNames(self): 

215 if not self._field.multi: 

216 raise FieldValidationError( 

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

218 ) 

219 self._selection = None 

220 

221 def _getName(self): 

222 if self._field.multi: 

223 raise FieldValidationError( 

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

225 ) 

226 return self._selection 

227 

228 def _setName(self, value): 

229 if self._field.multi: 

230 raise FieldValidationError( 

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

232 ) 

233 self._setSelection(value) 

234 

235 def _delName(self): 

236 if self._field.multi: 

237 raise FieldValidationError( 

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

239 ) 

240 self._selection = None 

241 

242 names = property(_getNames, _setNames, _delNames) 

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

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

245 the `name` attribute instead. 

246 """ 

247 

248 name = property(_getName, _setName, _delName) 

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

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

251 instead. 

252 """ 

253 

254 def _getActive(self): 

255 if self._selection is None: 

256 return None 

257 

258 if self._field.multi: 

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

260 else: 

261 return self[self._selection] 

262 

263 active = property(_getActive) 

264 """The selected items. 

265 

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

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

268 """ 

269 

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

271 try: 

272 value = self._dict[k] 

273 except KeyError: 

274 try: 

275 dtype = self.types[k] 

276 except Exception: 

277 raise FieldValidationError( 

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

279 ) 

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

281 if at is None: 

282 at = getCallStack() 

283 at.insert(0, dtype._source) 

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

285 return value 

286 

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

288 if self._config._frozen: 

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

290 

291 try: 

292 dtype = self.types[k] 

293 except Exception: 

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

295 

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

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

298 value, 

299 k, 

300 _typeStr(value), 

301 _typeStr(dtype), 

302 ) 

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

304 

305 if at is None: 

306 at = getCallStack() 

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

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

309 if oldValue is None: 

310 if value == dtype: 

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

312 else: 

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

314 else: 

315 if value == dtype: 

316 value = value() 

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

318 

319 def _rename(self, fullname): 

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

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

322 

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

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

325 # This allows properties to work. 

326 object.__setattr__(self, attr, value) 

327 elif attr in self.__dict__ or attr in [ 

328 "_history", 

329 "_field", 

330 "_config", 

331 "_dict", 

332 "_selection", 

333 "__doc__", 

334 "_typemap", 

335 ]: 

336 # This allows specific private attributes to work. 

337 object.__setattr__(self, attr, value) 

338 else: 

339 # We throw everything else. 

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

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

342 

343 def freeze(self): 

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

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

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

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

348 additional registry entries). 

349 """ 

350 if self._typemap is None: 

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

352 

353 def __reduce__(self): 

354 raise UnexpectedProxyUsageError( 

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

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

357 "being assigned to other objects or variables." 

358 ) 

359 

360 

361class ConfigChoiceField(Field): 

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

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

364 

365 Parameters 

366 ---------- 

367 doc : `str` 

368 Documentation string for the field. 

369 typemap : `dict`-like 

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

371 See *Examples* for details. 

372 default : `str`, optional 

373 The default configuration name. 

374 optional : `bool`, optional 

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

376 field's value is `None`. 

377 multi : `bool`, optional 

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

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

380 field. 

381 

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

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

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

385 deprecated : None or `str`, optional 

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

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

388 

389 See also 

390 -------- 

391 ChoiceField 

392 ConfigDictField 

393 ConfigField 

394 ConfigurableField 

395 DictField 

396 Field 

397 ListField 

398 RangeField 

399 RegistryField 

400 

401 Notes 

402 ----- 

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

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

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

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

407 attribute. 

408 

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

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

411 will fail. 

412 

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

414 saved, as well as the active selection. 

415 

416 Examples 

417 -------- 

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

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

420 

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

422 

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

424 >>> class AaaConfig(Config): 

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

426 ... 

427 

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

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

430 

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

432 >>> class MyConfig(Config): 

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

434 ... 

435 

436 Creating an instance of ``MyConfig``: 

437 

438 >>> instance = MyConfig() 

439 

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

441 field: 

442 

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

444 

445 **Selecting the active configuration** 

446 

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

448 field: 

449 

450 >>> instance.choice = "AAA" 

451 

452 Alternatively, the last line can be written: 

453 

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

455 

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

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

458 

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

460 type: 

461 

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

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

464 """ 

465 

466 instanceDictClass = ConfigInstanceDict 

467 

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

469 source = getStackFrame() 

470 self._setup( 

471 doc=doc, 

472 dtype=self.instanceDictClass, 

473 default=default, 

474 check=None, 

475 optional=optional, 

476 source=source, 

477 deprecated=deprecated, 

478 ) 

479 self.typemap = typemap 

480 self.multi = multi 

481 

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

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

484 if instanceDict is None: 

485 at = getCallStack(1) 

486 instanceDict = self.dtype(instance, self) 

487 instanceDict.__doc__ = self.doc 

488 instance._storage[self.name] = instanceDict 

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

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

491 

492 return instanceDict 

493 

494 def __get__(self, instance, owner=None): 

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

496 return self 

497 else: 

498 return self._getOrMake(instance) 

499 

500 def __set__(self, instance, value, at=None, label="assignment"): 

501 if instance._frozen: 

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

503 if at is None: 

504 at = getCallStack() 

505 instanceDict = self._getOrMake(instance) 

506 if isinstance(value, self.instanceDictClass): 

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

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

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

510 

511 else: 

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

513 

514 def rename(self, instance): 

515 instanceDict = self.__get__(instance) 

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

517 instanceDict._rename(fullname) 

518 

519 def validate(self, instance): 

520 instanceDict = self.__get__(instance) 

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

522 msg = "Required field cannot be None" 

523 raise FieldValidationError(self, instance, msg) 

524 elif instanceDict.active is not None: 

525 if self.multi: 

526 for a in instanceDict.active: 

527 a.validate() 

528 else: 

529 instanceDict.active.validate() 

530 

531 def toDict(self, instance): 

532 instanceDict = self.__get__(instance) 

533 

534 dict_ = {} 

535 if self.multi: 

536 dict_["names"] = instanceDict.names 

537 else: 

538 dict_["name"] = instanceDict.name 

539 

540 values = {} 

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

542 values[k] = v.toDict() 

543 dict_["values"] = values 

544 

545 return dict_ 

546 

547 def freeze(self, instance): 

548 instanceDict = self.__get__(instance) 

549 instanceDict.freeze() 

550 for v in instanceDict.values(): 

551 v.freeze() 

552 

553 def _collectImports(self, instance, imports): 

554 instanceDict = self.__get__(instance) 

555 for config in instanceDict.values(): 

556 config._collectImports() 

557 imports |= config._imports 

558 

559 def save(self, outfile, instance): 

560 instanceDict = self.__get__(instance) 

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

562 for v in instanceDict.values(): 

563 v._save(outfile) 

564 if self.multi: 

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

566 else: 

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

568 

569 def __deepcopy__(self, memo): 

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

571 original typemap. 

572 

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

574 constructor signature! 

575 """ 

576 other = type(self)( 

577 doc=self.doc, 

578 typemap=self.typemap, 

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

580 optional=self.optional, 

581 multi=self.multi, 

582 ) 

583 other.source = self.source 

584 return other 

585 

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

587 """Compare two fields for equality. 

588 

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

590 

591 Parameters 

592 ---------- 

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

594 Left-hand side config instance to compare. 

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

596 Right-hand side config instance to compare. 

597 shortcut : `bool` 

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

599 rtol : `float` 

600 Relative tolerance for floating point comparisons. 

601 atol : `float` 

602 Absolute tolerance for floating point comparisons. 

603 output : callable 

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

605 report inequalities. 

606 

607 Returns 

608 ------- 

609 isEqual : bool 

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

611 

612 Notes 

613 ----- 

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

615 others do not matter. 

616 

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

618 """ 

619 d1 = getattr(instance1, self.name) 

620 d2 = getattr(instance2, self.name) 

621 name = getComparisonName( 

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

623 ) 

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

625 return False 

626 if d1._selection is None: 

627 return True 

628 if self.multi: 

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

630 else: 

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

632 equal = True 

633 for k, c1, c2 in nested: 

634 result = compareConfigs( 

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

636 ) 

637 if not result and shortcut: 

638 return False 

639 equal = equal and result 

640 return equal