Coverage for python/lsst/pex/config/configurableField.py: 33%

177 statements  

« prev     ^ index     » next       coverage.py v7.4.2, created at 2024-02-23 11:29 +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 

28from __future__ import annotations 

29 

30__all__ = ("ConfigurableInstance", "ConfigurableField") 

31 

32import copy 

33import weakref 

34from collections.abc import Mapping 

35from typing import Any, Generic, overload 

36 

37from .callStack import getCallStack, getStackFrame 

38from .comparison import compareConfigs, getComparisonName 

39from .config import ( 

40 Config, 

41 Field, 

42 FieldTypeVar, 

43 FieldValidationError, 

44 UnexpectedProxyUsageError, 

45 _joinNamePath, 

46 _typeStr, 

47) 

48 

49 

50class ConfigurableInstance(Generic[FieldTypeVar]): 

51 """A retargetable configuration in a `ConfigurableField` that proxies 

52 a `~lsst.pex.config.Config`. 

53 

54 Parameters 

55 ---------- 

56 config : `~lsst.pex.config.Config` 

57 Config to proxy. 

58 field : `~lsst.pex.config.ConfigurableField` 

59 Field to use. 

60 at : `list` of `~lsst.pex.config.callStack.StackFrame` or `None`, optional 

61 Stack frame for history recording. Will be calculated if `None`. 

62 label : `str`, optional 

63 Label to use for history recording. 

64 

65 Notes 

66 ----- 

67 ``ConfigurableInstance`` implements ``__getattr__`` and ``__setattr__`` 

68 methods that forward to the `~lsst.pex.config.Config` it holds. 

69 ``ConfigurableInstance`` adds a `retarget` method. 

70 

71 The actual `~lsst.pex.config.Config` instance is accessed using the 

72 ``value`` property (e.g. to get its documentation). The associated 

73 configurable object (usually a `~lsst.pipe.base.Task`) is accessed 

74 using the ``target`` property. 

75 """ 

76 

77 def __initValue(self, at, label): 

78 """Construct value of field. 

79 

80 Notes 

81 ----- 

82 If field.default is an instance of `lsst.pex.config.Config`, 

83 custom construct ``_value`` with the correct values from default. 

84 Otherwise, call ``ConfigClass`` constructor 

85 """ 

86 name = _joinNamePath(self._config._name, self._field.name) 

87 if type(self._field.default) is self.ConfigClass: 

88 storage = self._field.default._storage 

89 else: 

90 storage = {} 

91 value = self._ConfigClass(__name=name, __at=at, __label=label, **storage) 

92 object.__setattr__(self, "_value", value) 

93 

94 def __init__(self, config, field, at=None, label="default"): 

95 object.__setattr__(self, "_config_", weakref.ref(config)) 

96 object.__setattr__(self, "_field", field) 

97 object.__setattr__(self, "__doc__", config) 

98 object.__setattr__(self, "_target", field.target) 

99 object.__setattr__(self, "_ConfigClass", field.ConfigClass) 

100 object.__setattr__(self, "_value", None) 

101 

102 if at is None: 

103 at = getCallStack() 

104 at += [self._field.source] 

105 self.__initValue(at, label) 

106 

107 history = config._history.setdefault(field.name, []) 

108 history.append(("Targeted and initialized from defaults", at, label)) 

109 

110 @property 

111 def _config(self) -> Config: 

112 # Config Fields should never outlive their config class instance 

113 # assert that as such here 

114 assert self._config_() is not None 

115 return self._config_() 

116 

117 target = property(lambda x: x._target) 117 ↛ exitline 117 didn't run the lambda on line 117

118 """The targeted configurable (read-only). 

119 """ 

120 

121 ConfigClass = property(lambda x: x._ConfigClass) 121 ↛ exitline 121 didn't run the lambda on line 121

122 """The configuration class (read-only) 

123 """ 

124 

125 value = property(lambda x: x._value) 125 ↛ exitline 125 didn't run the lambda on line 125

126 """The `ConfigClass` instance (`lsst.pex.config.Config`-type, 

127 read-only). 

128 """ 

129 

130 def apply(self, *args, **kw): 

131 """Call the configurable. 

132 

133 Parameters 

134 ---------- 

135 *args : `~typing.Any` 

136 Arguments to use when calling the configurable. 

137 **kw : `~typing.Any` 

138 Keyword parameters to use when calling. 

139 

140 Notes 

141 ----- 

142 In addition to the user-provided positional and keyword arguments, 

143 the configurable is also provided a keyword argument ``config`` with 

144 the value of `ConfigurableInstance.value`. 

145 """ 

146 return self.target(*args, config=self.value, **kw) 

147 

148 def retarget(self, target, ConfigClass=None, at=None, label="retarget"): 

149 """Target a new configurable and ConfigClass. 

150 

151 Parameters 

152 ---------- 

153 target : `type` 

154 Item to retarget. 

155 ConfigClass : `type` or `None`, optional 

156 New config class to use. 

157 at : `list` of `~lsst.pex.config.callStack.StackFrame` or `None`,\ 

158 optional 

159 Stack for history recording. 

160 label : `str`, optional 

161 Label for history recording. 

162 """ 

163 if self._config._frozen: 

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

165 

166 try: 

167 ConfigClass = self._field.validateTarget(target, ConfigClass) 

168 except BaseException as e: 

169 raise FieldValidationError(self._field, self._config, e.message) 

170 

171 if at is None: 

172 at = getCallStack() 

173 object.__setattr__(self, "_target", target) 

174 if ConfigClass != self.ConfigClass: 

175 object.__setattr__(self, "_ConfigClass", ConfigClass) 

176 self.__initValue(at, label) 

177 

178 history = self._config._history.setdefault(self._field.name, []) 

179 msg = f"retarget(target={_typeStr(target)}, ConfigClass={_typeStr(ConfigClass)})" 

180 history.append((msg, at, label)) 

181 

182 def __getattr__(self, name): 

183 return getattr(self._value, name) 

184 

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

186 """Pretend to be an instance of ConfigClass. 

187 

188 Attributes defined by ConfigurableInstance will shadow those defined 

189 in ConfigClass 

190 """ 

191 if self._config._frozen: 

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

193 

194 if name in self.__dict__: 

195 # attribute exists in the ConfigurableInstance wrapper 

196 object.__setattr__(self, name, value) 

197 else: 

198 if at is None: 

199 at = getCallStack() 

200 self._value.__setattr__(name, value, at=at, label=label) 

201 

202 def __delattr__(self, name, at=None, label="delete"): 

203 """ 

204 Pretend to be an isntance of ConfigClass. 

205 Attributes defiend by ConfigurableInstance will shadow those defined 

206 in ConfigClass. 

207 """ 

208 if self._config._frozen: 

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

210 

211 try: 

212 # attribute exists in the ConfigurableInstance wrapper 

213 object.__delattr__(self, name) 

214 except AttributeError: 

215 if at is None: 

216 at = getCallStack() 

217 self._value.__delattr__(name, at=at, label=label) 

218 

219 def __reduce__(self): 

220 raise UnexpectedProxyUsageError( 

221 f"Proxy object for config field {self._field.name} cannot " 

222 "be pickled; it should be converted to a normal `Config` instance " 

223 "via the `value` property before being assigned to other objects " 

224 "or variables." 

225 ) 

226 

227 

228class ConfigurableField(Field[ConfigurableInstance[FieldTypeVar]]): 

229 """A configuration field (`~lsst.pex.config.Field` subclass) that can be 

230 can be retargeted towards a different configurable (often a 

231 `lsst.pipe.base.Task` subclass). 

232 

233 The ``ConfigurableField`` is often used to configure subtasks, which are 

234 tasks (`~lsst.pipe.base.Task`) called by a parent task. 

235 

236 Parameters 

237 ---------- 

238 doc : `str` 

239 A description of the configuration field. 

240 target : configurable class 

241 The configurable target. Configurables have a ``ConfigClass`` 

242 attribute. Within the task framework, configurables are 

243 `lsst.pipe.base.Task` subclasses). 

244 ConfigClass : `lsst.pex.config.Config`-type, optional 

245 The subclass of `lsst.pex.config.Config` expected as the configuration 

246 class of the ``target``. If ``ConfigClass`` is unset then 

247 ``target.ConfigClass`` is used. 

248 default : ``ConfigClass``-type, optional 

249 The default configuration class. Normally this parameter is not set, 

250 and defaults to ``ConfigClass`` (or ``target.ConfigClass``). 

251 check : callable, optional 

252 Callable that takes the field's value (the ``target``) as its only 

253 positional argument, and returns `True` if the ``target`` is valid (and 

254 `False` otherwise). 

255 deprecated : None or `str`, optional 

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

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

258 

259 See Also 

260 -------- 

261 ChoiceField 

262 ConfigChoiceField 

263 ConfigDictField 

264 ConfigField 

265 DictField 

266 Field 

267 ListField 

268 RangeField 

269 RegistryField 

270 

271 Notes 

272 ----- 

273 You can use the `ConfigurableInstance.apply` method to construct a 

274 fully-configured configurable. 

275 """ 

276 

277 def validateTarget(self, target, ConfigClass): 

278 """Validate the target and configuration class. 

279 

280 Parameters 

281 ---------- 

282 target : configurable class 

283 The configurable being verified. 

284 ConfigClass : `lsst.pex.config.Config`-type or `None` 

285 The configuration class associated with the ``target``. This can 

286 be `None` if ``target`` has a ``ConfigClass`` attribute. 

287 

288 Raises 

289 ------ 

290 AttributeError 

291 Raised if ``ConfigClass`` is `None` and ``target`` does not have a 

292 ``ConfigClass`` attribute. 

293 TypeError 

294 Raised if ``ConfigClass`` is not a `~lsst.pex.config.Config` 

295 subclass. 

296 ValueError 

297 Raised if: 

298 

299 - ``target`` is not callable (callables have a ``__call__`` 

300 method). 

301 - ``target`` is not startically defined (does not have 

302 ``__module__`` or ``__name__`` attributes). 

303 """ 

304 if ConfigClass is None: 

305 try: 

306 ConfigClass = target.ConfigClass 

307 except Exception: 

308 raise AttributeError("'target' must define attribute 'ConfigClass'") 

309 if not issubclass(ConfigClass, Config): 309 ↛ 310line 309 didn't jump to line 310, because the condition on line 309 was never true

310 raise TypeError( 

311 "'ConfigClass' is of incorrect type %s.'ConfigClass' must be a subclass of Config" 

312 % _typeStr(ConfigClass) 

313 ) 

314 if not hasattr(target, "__call__"): 314 ↛ 315line 314 didn't jump to line 315, because the condition on line 314 was never true

315 raise ValueError("'target' must be callable") 

316 if not hasattr(target, "__module__") or not hasattr(target, "__name__"): 316 ↛ 317line 316 didn't jump to line 317, because the condition on line 316 was never true

317 raise ValueError( 

318 "'target' must be statically defined (must have '__module__' and '__name__' attributes)" 

319 ) 

320 return ConfigClass 

321 

322 def __init__(self, doc, target, ConfigClass=None, default=None, check=None, deprecated=None): 

323 ConfigClass = self.validateTarget(target, ConfigClass) 

324 

325 if default is None: 

326 default = ConfigClass 

327 if default != ConfigClass and type(default) is not ConfigClass: 327 ↛ 328line 327 didn't jump to line 328, because the condition on line 327 was never true

328 raise TypeError( 

329 f"'default' is of incorrect type {_typeStr(default)}. Expected {_typeStr(ConfigClass)}" 

330 ) 

331 

332 source = getStackFrame() 

333 self._setup( 

334 doc=doc, 

335 dtype=ConfigurableInstance, 

336 default=default, 

337 check=check, 

338 optional=False, 

339 source=source, 

340 deprecated=deprecated, 

341 ) 

342 self.target = target 

343 self.ConfigClass = ConfigClass 

344 

345 @staticmethod 

346 def _parseTypingArgs( 

347 params: tuple[type, ...] | tuple[str, ...], kwds: Mapping[str, Any] 

348 ) -> Mapping[str, Any]: 

349 return kwds 

350 

351 def __getOrMake(self, instance, at=None, label="default"): 

352 value = instance._storage.get(self.name, None) 

353 if value is None: 

354 if at is None: 

355 at = getCallStack(1) 

356 value = ConfigurableInstance(instance, self, at=at, label=label) 

357 instance._storage[self.name] = value 

358 return value 

359 

360 @overload 

361 def __get__( 

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

363 ) -> ConfigurableField: 

364 ... 

365 

366 @overload 

367 def __get__( 

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

369 ) -> ConfigurableInstance[FieldTypeVar]: 

370 ... 

371 

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

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

374 return self 

375 else: 

376 return self.__getOrMake(instance, at=at, label=label) 

377 

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

379 if instance._frozen: 

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

381 if at is None: 

382 at = getCallStack() 

383 oldValue = self.__getOrMake(instance, at=at) 

384 

385 if isinstance(value, ConfigurableInstance): 

386 oldValue.retarget(value.target, value.ConfigClass, at, label) 

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

388 elif type(value) is oldValue._ConfigClass: 

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

390 elif value == oldValue.ConfigClass: 

391 value = oldValue.ConfigClass() 

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

393 else: 

394 msg = "Value {} is of incorrect type {}. Expected {}".format( 

395 value, 

396 _typeStr(value), 

397 _typeStr(oldValue.ConfigClass), 

398 ) 

399 raise FieldValidationError(self, instance, msg) 

400 

401 def rename(self, instance): 

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

403 value = self.__getOrMake(instance) 

404 value._rename(fullname) 

405 

406 def _collectImports(self, instance, imports): 

407 value = self.__get__(instance) 

408 target = value.target 

409 imports.add(target.__module__) 

410 value.value._collectImports() 

411 imports |= value.value._imports 

412 

413 def save(self, outfile, instance): 

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

415 value = self.__getOrMake(instance) 

416 target = value.target 

417 

418 if target != self.target: 

419 # not targeting the field-default target. 

420 # save target information 

421 ConfigClass = value.ConfigClass 

422 outfile.write( 

423 "{}.retarget(target={}, ConfigClass={})\n\n".format( 

424 fullname, _typeStr(target), _typeStr(ConfigClass) 

425 ) 

426 ) 

427 # save field values 

428 value._save(outfile) 

429 

430 def freeze(self, instance): 

431 value = self.__getOrMake(instance) 

432 value.freeze() 

433 

434 def toDict(self, instance): 

435 value = self.__get__(instance) 

436 return value.toDict() 

437 

438 def validate(self, instance): 

439 value = self.__get__(instance) 

440 value.validate() 

441 

442 if self.check is not None and not self.check(value): 

443 msg = "%s is not a valid value" % str(value) 

444 raise FieldValidationError(self, instance, msg) 

445 

446 def __deepcopy__(self, memo): 

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

448 original typemap. 

449 

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

451 constructor signature! 

452 """ 

453 return type(self)( 

454 doc=self.doc, 

455 target=self.target, 

456 ConfigClass=self.ConfigClass, 

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

458 ) 

459 

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

461 """Compare two fields for equality. 

462 

463 Used by `lsst.pex.ConfigDictField.compare`. 

464 

465 Parameters 

466 ---------- 

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

468 Left-hand side config instance to compare. 

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

470 Right-hand side config instance to compare. 

471 shortcut : `bool` 

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

473 rtol : `float` 

474 Relative tolerance for floating point comparisons. 

475 atol : `float` 

476 Absolute tolerance for floating point comparisons. 

477 output : callable 

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

479 report inequalities. For example: `print`. 

480 

481 Returns 

482 ------- 

483 isEqual : bool 

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

485 

486 Notes 

487 ----- 

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

489 """ 

490 c1 = getattr(instance1, self.name)._value 

491 c2 = getattr(instance2, self.name)._value 

492 name = getComparisonName( 

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

494 ) 

495 return compareConfigs(name, c1, c2, shortcut=shortcut, rtol=rtol, atol=atol, output=output)