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

Shortcuts on this page

r m x p   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

269 statements  

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, _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 

139class ConfigInstanceDict(collections.abc.Mapping): 

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

141 `~lsst.pex.config.ConfigChoiceField`. 

142 

143 Parameters 

144 ---------- 

145 config : `lsst.pex.config.Config` 

146 A configuration instance. 

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

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

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

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

151 """ 

152 

153 def __init__(self, config, field): 

154 collections.abc.Mapping.__init__(self) 

155 self._dict = dict() 

156 self._selection = None 

157 self._config = config 

158 self._field = field 

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

160 self.__doc__ = field.doc 

161 self._typemap = None 

162 

163 @property 

164 def types(self): 

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

166 

167 def __contains__(self, k): 

168 return k in self.types 

169 

170 def __len__(self): 

171 return len(self.types) 

172 

173 def __iter__(self): 

174 return iter(self.types) 

175 

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

177 if self._config._frozen: 

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

179 

180 if at is None: 

181 at = getCallStack(1) 

182 

183 if value is None: 

184 self._selection = None 

185 elif self._field.multi: 

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

187 else: 

188 if value not in self._dict: 

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

190 self._selection = value 

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

192 

193 def _getNames(self): 

194 if not self._field.multi: 

195 raise FieldValidationError( 

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

197 ) 

198 return self._selection 

199 

200 def _setNames(self, value): 

201 if not self._field.multi: 

202 raise FieldValidationError( 

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

204 ) 

205 self._setSelection(value) 

206 

207 def _delNames(self): 

208 if not self._field.multi: 

209 raise FieldValidationError( 

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

211 ) 

212 self._selection = None 

213 

214 def _getName(self): 

215 if self._field.multi: 

216 raise FieldValidationError( 

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

218 ) 

219 return self._selection 

220 

221 def _setName(self, value): 

222 if self._field.multi: 

223 raise FieldValidationError( 

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

225 ) 

226 self._setSelection(value) 

227 

228 def _delName(self): 

229 if self._field.multi: 

230 raise FieldValidationError( 

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

232 ) 

233 self._selection = None 

234 

235 names = property(_getNames, _setNames, _delNames) 

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

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

238 the `name` attribute instead. 

239 """ 

240 

241 name = property(_getName, _setName, _delName) 

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

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

244 instead. 

245 """ 

246 

247 def _getActive(self): 

248 if self._selection is None: 

249 return None 

250 

251 if self._field.multi: 

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

253 else: 

254 return self[self._selection] 

255 

256 active = property(_getActive) 

257 """The selected items. 

258 

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

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

261 """ 

262 

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

264 try: 

265 value = self._dict[k] 

266 except KeyError: 

267 try: 

268 dtype = self.types[k] 

269 except Exception: 

270 raise FieldValidationError( 

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

272 ) 

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

274 if at is None: 

275 at = getCallStack() 

276 at.insert(0, dtype._source) 

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

278 return value 

279 

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

281 if self._config._frozen: 

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

283 

284 try: 

285 dtype = self.types[k] 

286 except Exception: 

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

288 

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

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

291 value, 

292 k, 

293 _typeStr(value), 

294 _typeStr(dtype), 

295 ) 

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

297 

298 if at is None: 

299 at = getCallStack() 

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

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

302 if oldValue is None: 

303 if value == dtype: 

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

305 else: 

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

307 else: 

308 if value == dtype: 

309 value = value() 

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

311 

312 def _rename(self, fullname): 

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

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

315 

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

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

318 # This allows properties to work. 

319 object.__setattr__(self, attr, value) 

320 elif attr in self.__dict__ or attr in [ 

321 "_history", 

322 "_field", 

323 "_config", 

324 "_dict", 

325 "_selection", 

326 "__doc__", 

327 "_typemap", 

328 ]: 

329 # This allows specific private attributes to work. 

330 object.__setattr__(self, attr, value) 

331 else: 

332 # We throw everything else. 

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

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

335 

336 def freeze(self): 

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

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

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

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

341 additional registry entries). 

342 """ 

343 if self._typemap is None: 

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

345 

346 

347class ConfigChoiceField(Field): 

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

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

350 

351 Parameters 

352 ---------- 

353 doc : `str` 

354 Documentation string for the field. 

355 typemap : `dict`-like 

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

357 See *Examples* for details. 

358 default : `str`, optional 

359 The default configuration name. 

360 optional : `bool`, optional 

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

362 field's value is `None`. 

363 multi : `bool`, optional 

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

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

366 field. 

367 

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

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

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

371 deprecated : None or `str`, optional 

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

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

374 

375 See also 

376 -------- 

377 ChoiceField 

378 ConfigDictField 

379 ConfigField 

380 ConfigurableField 

381 DictField 

382 Field 

383 ListField 

384 RangeField 

385 RegistryField 

386 

387 Notes 

388 ----- 

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

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

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

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

393 attribute. 

394 

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

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

397 will fail. 

398 

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

400 saved, as well as the active selection. 

401 

402 Examples 

403 -------- 

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

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

406 

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

408 

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

410 >>> class AaaConfig(Config): 

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

412 ... 

413 

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

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

416 

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

418 >>> class MyConfig(Config): 

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

420 ... 

421 

422 Creating an instance of ``MyConfig``: 

423 

424 >>> instance = MyConfig() 

425 

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

427 field: 

428 

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

430 

431 **Selecting the active configuration** 

432 

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

434 field: 

435 

436 >>> instance.choice = "AAA" 

437 

438 Alternatively, the last line can be written: 

439 

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

441 

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

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

444 

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

446 type: 

447 

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

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

450 """ 

451 

452 instanceDictClass = ConfigInstanceDict 

453 

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

455 source = getStackFrame() 

456 self._setup( 

457 doc=doc, 

458 dtype=self.instanceDictClass, 

459 default=default, 

460 check=None, 

461 optional=optional, 

462 source=source, 

463 deprecated=deprecated, 

464 ) 

465 self.typemap = typemap 

466 self.multi = multi 

467 

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

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

470 if instanceDict is None: 

471 at = getCallStack(1) 

472 instanceDict = self.dtype(instance, self) 

473 instanceDict.__doc__ = self.doc 

474 instance._storage[self.name] = instanceDict 

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

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

477 

478 return instanceDict 

479 

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

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

482 return self 

483 else: 

484 return self._getOrMake(instance) 

485 

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

487 if instance._frozen: 

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

489 if at is None: 

490 at = getCallStack() 

491 instanceDict = self._getOrMake(instance) 

492 if isinstance(value, self.instanceDictClass): 

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

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

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

496 

497 else: 

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

499 

500 def rename(self, instance): 

501 instanceDict = self.__get__(instance) 

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

503 instanceDict._rename(fullname) 

504 

505 def validate(self, instance): 

506 instanceDict = self.__get__(instance) 

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

508 msg = "Required field cannot be None" 

509 raise FieldValidationError(self, instance, msg) 

510 elif instanceDict.active is not None: 

511 if self.multi: 

512 for a in instanceDict.active: 

513 a.validate() 

514 else: 

515 instanceDict.active.validate() 

516 

517 def toDict(self, instance): 

518 instanceDict = self.__get__(instance) 

519 

520 dict_ = {} 

521 if self.multi: 

522 dict_["names"] = instanceDict.names 

523 else: 

524 dict_["name"] = instanceDict.name 

525 

526 values = {} 

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

528 values[k] = v.toDict() 

529 dict_["values"] = values 

530 

531 return dict_ 

532 

533 def freeze(self, instance): 

534 instanceDict = self.__get__(instance) 

535 instanceDict.freeze() 

536 for v in instanceDict.values(): 

537 v.freeze() 

538 

539 def _collectImports(self, instance, imports): 

540 instanceDict = self.__get__(instance) 

541 for config in instanceDict.values(): 

542 config._collectImports() 

543 imports |= config._imports 

544 

545 def save(self, outfile, instance): 

546 instanceDict = self.__get__(instance) 

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

548 for v in instanceDict.values(): 

549 v._save(outfile) 

550 if self.multi: 

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

552 else: 

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

554 

555 def __deepcopy__(self, memo): 

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

557 original typemap. 

558 

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

560 constructor signature! 

561 """ 

562 other = type(self)( 

563 doc=self.doc, 

564 typemap=self.typemap, 

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

566 optional=self.optional, 

567 multi=self.multi, 

568 ) 

569 other.source = self.source 

570 return other 

571 

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

573 """Compare two fields for equality. 

574 

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

576 

577 Parameters 

578 ---------- 

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

580 Left-hand side config instance to compare. 

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

582 Right-hand side config instance to compare. 

583 shortcut : `bool` 

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

585 rtol : `float` 

586 Relative tolerance for floating point comparisons. 

587 atol : `float` 

588 Absolute tolerance for floating point comparisons. 

589 output : callable 

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

591 report inequalities. 

592 

593 Returns 

594 ------- 

595 isEqual : bool 

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

597 

598 Notes 

599 ----- 

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

601 others do not matter. 

602 

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

604 """ 

605 d1 = getattr(instance1, self.name) 

606 d2 = getattr(instance2, self.name) 

607 name = getComparisonName( 

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

609 ) 

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

611 return False 

612 if d1._selection is None: 

613 return True 

614 if self.multi: 

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

616 else: 

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

618 equal = True 

619 for k, c1, c2 in nested: 

620 result = compareConfigs( 

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

622 ) 

623 if not result and shortcut: 

624 return False 

625 equal = equal and result 

626 return equal