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

269 statements  

« prev     ^ index     » next       coverage.py v6.5.0, created at 2022-12-01 19:59 +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/>. 

27 

28__all__ = ["ConfigChoiceField"] 

29 

30import copy 

31import collections.abc 

32 

33from .config import Config, Field, FieldValidationError, _typeStr, _joinNamePath 

34from .comparison import getComparisonName, compareScalars, compareConfigs 

35from .callStack import getCallStack, getStackFrame 

36 

37import weakref 

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 

49 The selected key. 

50 at : `lsst.pex.config.callStack.StackFrame`, 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__(self, dict_, value, at=None, label="assignment", setHistory=True): 

66 if at is None: 

67 at = getCallStack() 

68 self._dict = dict_ 

69 self._field = self._dict._field 

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

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

72 if value is not None: 

73 try: 

74 for v in value: 

75 if v not in self._dict: 

76 # invoke __getitem__ to ensure it's present 

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

78 except TypeError: 

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

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

81 self._set = set(value) 

82 else: 

83 self._set = set() 

84 

85 if setHistory: 

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

87 

88 @property 

89 def _config(self) -> Config: 

90 # Config Fields should never outlive their config class instance 

91 # assert that as such here 

92 assert(self._config_() is not None) 

93 return self._config_() 

94 

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

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

97 """ 

98 if self._config._frozen: 

99 raise FieldValidationError(self._field, self._config, 

100 "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 """ 

115 if self._config._frozen: 

116 raise FieldValidationError(self._field, self._config, 

117 "Cannot modify a frozen Config") 

118 

119 if value not in self._dict: 

120 return 

121 

122 if at is None: 

123 at = getCallStack() 

124 

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

126 self._set.discard(value) 

127 

128 def __len__(self): 

129 return len(self._set) 

130 

131 def __iter__(self): 

132 return iter(self._set) 

133 

134 def __contains__(self, value): 

135 return value in self._set 

136 

137 def __repr__(self): 

138 return repr(list(self._set)) 

139 

140 def __str__(self): 

141 return str(list(self._set)) 

142 

143 

144class ConfigInstanceDict(collections.abc.Mapping): 

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

146 `~lsst.pex.config.ConfigChoiceField`. 

147 

148 Parameters 

149 ---------- 

150 config : `lsst.pex.config.Config` 

151 A configuration instance. 

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

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

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

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

156 """ 

157 def __init__(self, config, field): 

158 collections.abc.Mapping.__init__(self) 

159 self._dict = dict() 

160 self._selection = None 

161 self._config = config 

162 self._field = field 

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

164 self.__doc__ = field.doc 

165 self._typemap = None 

166 

167 @property 

168 def types(self): 

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

170 

171 def __contains__(self, k): 

172 return k in self.types 

173 

174 def __len__(self): 

175 return len(self.types) 

176 

177 def __iter__(self): 

178 return iter(self.types) 

179 

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

181 if self._config._frozen: 

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

183 

184 if at is None: 

185 at = getCallStack(1) 

186 

187 if value is None: 

188 self._selection = None 

189 elif self._field.multi: 

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

191 else: 

192 if value not in self._dict: 

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

194 self._selection = value 

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

196 

197 def _getNames(self): 

198 if not self._field.multi: 

199 raise FieldValidationError(self._field, self._config, 

200 "Single-selection field has no attribute 'names'") 

201 return self._selection 

202 

203 def _setNames(self, value): 

204 if not self._field.multi: 

205 raise FieldValidationError(self._field, self._config, 

206 "Single-selection field has no attribute 'names'") 

207 self._setSelection(value) 

208 

209 def _delNames(self): 

210 if not self._field.multi: 

211 raise FieldValidationError(self._field, self._config, 

212 "Single-selection field has no attribute 'names'") 

213 self._selection = None 

214 

215 def _getName(self): 

216 if self._field.multi: 

217 raise FieldValidationError(self._field, self._config, 

218 "Multi-selection field has no attribute 'name'") 

219 return self._selection 

220 

221 def _setName(self, value): 

222 if self._field.multi: 

223 raise FieldValidationError(self._field, self._config, 

224 "Multi-selection field has no attribute 'name'") 

225 self._setSelection(value) 

226 

227 def _delName(self): 

228 if self._field.multi: 

229 raise FieldValidationError(self._field, self._config, 

230 "Multi-selection field has no attribute 'name'") 

231 self._selection = None 

232 

233 names = property(_getNames, _setNames, _delNames) 

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

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

236 the `name` attribute instead. 

237 """ 

238 

239 name = property(_getName, _setName, _delName) 

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

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

242 instead. 

243 """ 

244 

245 def _getActive(self): 

246 if self._selection is None: 

247 return None 

248 

249 if self._field.multi: 

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

251 else: 

252 return self[self._selection] 

253 

254 active = property(_getActive) 

255 """The selected items. 

256 

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

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

259 """ 

260 

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

262 try: 

263 value = self._dict[k] 

264 except KeyError: 

265 try: 

266 dtype = self.types[k] 

267 except Exception: 

268 raise FieldValidationError(self._field, self._config, 

269 "Unknown key %r in Registry/ConfigChoiceField" % k) 

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

271 if at is None: 

272 at = getCallStack() 

273 at.insert(0, dtype._source) 

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

275 return value 

276 

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

278 if self._config._frozen: 

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

280 

281 try: 

282 dtype = self.types[k] 

283 except Exception: 

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

285 

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

287 msg = "Value %s at key %s is of incorrect type %s. Expected type %s" % \ 

288 (value, k, _typeStr(value), _typeStr(dtype)) 

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

290 

291 if at is None: 

292 at = getCallStack() 

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

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

295 if oldValue is None: 

296 if value == dtype: 

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

298 else: 

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

300 else: 

301 if value == dtype: 

302 value = value() 

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

304 

305 def _rename(self, fullname): 

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

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

308 

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

310 if hasattr(getattr(self.__class__, attr, None), '__set__'): 

311 # This allows properties to work. 

312 object.__setattr__(self, attr, value) 

313 elif attr in self.__dict__ or attr in ["_history", "_field", "_config", "_dict", 

314 "_selection", "__doc__", "_typemap"]: 

315 # This allows specific private attributes to work. 

316 object.__setattr__(self, attr, value) 

317 else: 

318 # We throw everything else. 

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

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

321 

322 def freeze(self): 

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

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

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

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

327 additional registry entries). 

328 """ 

329 if self._typemap is None: 

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

331 

332 

333class ConfigChoiceField(Field): 

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

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

336 

337 Parameters 

338 ---------- 

339 doc : `str` 

340 Documentation string for the field. 

341 typemap : `dict`-like 

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

343 See *Examples* for details. 

344 default : `str`, optional 

345 The default configuration name. 

346 optional : `bool`, optional 

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

348 field's value is `None`. 

349 multi : `bool`, optional 

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

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

352 field. 

353 

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

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

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

357 deprecated : None or `str`, optional 

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

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

360 

361 See also 

362 -------- 

363 ChoiceField 

364 ConfigDictField 

365 ConfigField 

366 ConfigurableField 

367 DictField 

368 Field 

369 ListField 

370 RangeField 

371 RegistryField 

372 

373 Notes 

374 ----- 

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

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

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

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

379 attribute. 

380 

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

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

383 will fail. 

384 

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

386 saved, as well as the active selection. 

387 

388 Examples 

389 -------- 

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

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

392 

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

394 

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

396 >>> class AaaConfig(Config): 

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

398 ... 

399 

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

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

402 

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

404 >>> class MyConfig(Config): 

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

406 ... 

407 

408 Creating an instance of ``MyConfig``: 

409 

410 >>> instance = MyConfig() 

411 

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

413 field: 

414 

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

416 

417 **Selecting the active configuration** 

418 

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

420 field: 

421 

422 >>> instance.choice = "AAA" 

423 

424 Alternatively, the last line can be written: 

425 

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

427 

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

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

430 

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

432 type: 

433 

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

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

436 """ 

437 

438 instanceDictClass = ConfigInstanceDict 

439 

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

441 source = getStackFrame() 

442 self._setup(doc=doc, dtype=self.instanceDictClass, default=default, check=None, optional=optional, 

443 source=source, deprecated=deprecated) 

444 self.typemap = typemap 

445 self.multi = multi 

446 

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

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

449 if instanceDict is None: 

450 at = getCallStack(1) 

451 instanceDict = self.dtype(instance, self) 

452 instanceDict.__doc__ = self.doc 

453 instance._storage[self.name] = instanceDict 

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

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

456 

457 return instanceDict 

458 

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

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

461 return self 

462 else: 

463 return self._getOrMake(instance) 

464 

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

466 if instance._frozen: 

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

468 if at is None: 

469 at = getCallStack() 

470 instanceDict = self._getOrMake(instance) 

471 if isinstance(value, self.instanceDictClass): 

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

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

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

475 

476 else: 

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

478 

479 def rename(self, instance): 

480 instanceDict = self.__get__(instance) 

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

482 instanceDict._rename(fullname) 

483 

484 def validate(self, instance): 

485 instanceDict = self.__get__(instance) 

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

487 msg = "Required field cannot be None" 

488 raise FieldValidationError(self, instance, msg) 

489 elif instanceDict.active is not None: 

490 if self.multi: 

491 for a in instanceDict.active: 

492 a.validate() 

493 else: 

494 instanceDict.active.validate() 

495 

496 def toDict(self, instance): 

497 instanceDict = self.__get__(instance) 

498 

499 dict_ = {} 

500 if self.multi: 

501 dict_["names"] = instanceDict.names 

502 else: 

503 dict_["name"] = instanceDict.name 

504 

505 values = {} 

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

507 values[k] = v.toDict() 

508 dict_["values"] = values 

509 

510 return dict_ 

511 

512 def freeze(self, instance): 

513 instanceDict = self.__get__(instance) 

514 instanceDict.freeze() 

515 for v in instanceDict.values(): 

516 v.freeze() 

517 

518 def _collectImports(self, instance, imports): 

519 instanceDict = self.__get__(instance) 

520 for config in instanceDict.values(): 

521 config._collectImports() 

522 imports |= config._imports 

523 

524 def save(self, outfile, instance): 

525 instanceDict = self.__get__(instance) 

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

527 for v in instanceDict.values(): 

528 v._save(outfile) 

529 if self.multi: 

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

531 else: 

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

533 

534 def __deepcopy__(self, memo): 

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

536 original typemap. 

537 

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

539 constructor signature! 

540 """ 

541 other = type(self)(doc=self.doc, typemap=self.typemap, default=copy.deepcopy(self.default), 

542 optional=self.optional, multi=self.multi) 

543 other.source = self.source 

544 return other 

545 

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

547 """Compare two fields for equality. 

548 

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

550 

551 Parameters 

552 ---------- 

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

554 Left-hand side config instance to compare. 

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

556 Right-hand side config instance to compare. 

557 shortcut : `bool` 

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

559 rtol : `float` 

560 Relative tolerance for floating point comparisons. 

561 atol : `float` 

562 Absolute tolerance for floating point comparisons. 

563 output : callable 

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

565 report inequalities. 

566 

567 Returns 

568 ------- 

569 isEqual : bool 

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

571 

572 Notes 

573 ----- 

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

575 others do not matter. 

576 

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

578 """ 

579 d1 = getattr(instance1, self.name) 

580 d2 = getattr(instance2, self.name) 

581 name = getComparisonName( 

582 _joinNamePath(instance1._name, self.name), 

583 _joinNamePath(instance2._name, self.name) 

584 ) 

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

586 return False 

587 if d1._selection is None: 

588 return True 

589 if self.multi: 

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

591 else: 

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

593 equal = True 

594 for k, c1, c2 in nested: 

595 result = compareConfigs("%s[%r]" % (name, k), c1, c2, shortcut=shortcut, 

596 rtol=rtol, atol=atol, output=output) 

597 if not result and shortcut: 

598 return False 

599 equal = equal and result 

600 return equal