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

185 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-26 08:53 +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__ = ("ConfigurableField", "ConfigurableInstance") 

31 

32import copy 

33import weakref 

34from collections.abc import Mapping 

35from typing import Any, Generic, overload 

36 

37from .callStack import StackFrame, 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` 

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

62 label : `str` 

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: list[StackFrame] | None, label: str, setHistory: bool = True): 

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__", field.doc) 

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 def _copy(self, parent: Config) -> ConfigurableInstance: 

111 result = object.__new__(ConfigurableInstance) 

112 object.__setattr__(result, "_config_", weakref.ref(parent)) 

113 object.__setattr__(result, "_field", self._field) 

114 object.__setattr__(result, "__doc__", self.__doc__) 

115 object.__setattr__(result, "_target", self._target) 

116 object.__setattr__(result, "_ConfigClass", self._ConfigClass) 

117 object.__setattr__(result, "_value", self._value.copy()) 

118 return result 

119 

120 @property 

121 def _config(self) -> Config: 

122 # Config Fields should never outlive their config class instance 

123 # assert that as such here 

124 assert self._config_() is not None 

125 return self._config_() 

126 

127 target = property(lambda x: x._target) 

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

129 """ 

130 

131 ConfigClass = property(lambda x: x._ConfigClass) 

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

133 """ 

134 

135 value = property(lambda x: x._value) 

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

137 read-only). 

138 """ 

139 

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

141 """Call the configurable. 

142 

143 Parameters 

144 ---------- 

145 *args : `~typing.Any` 

146 Arguments to use when calling the configurable. 

147 **kw : `~typing.Any` 

148 Keyword parameters to use when calling. 

149 

150 Notes 

151 ----- 

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

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

154 the value of `ConfigurableInstance.value`. 

155 """ 

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

157 

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

159 """Target a new configurable and ConfigClass. 

160 

161 Parameters 

162 ---------- 

163 target : `type` 

164 Item to retarget. 

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

166 New config class to use. 

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

168 optional 

169 Stack for history recording. 

170 label : `str`, optional 

171 Label for history recording. 

172 """ 

173 if self._config._frozen: 

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

175 

176 try: 

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

178 except BaseException as e: 

179 raise FieldValidationError(self._field, self._config, e.message) from e 

180 

181 if at is None: 

182 at = getCallStack() 

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

184 if ConfigClass != self.ConfigClass: 

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

186 self.__initValue(at, label) 

187 

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

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

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

191 

192 def __getattr__(self, name): 

193 return getattr(self._value, name) 

194 

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

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

197 

198 Attributes defined by ConfigurableInstance will shadow those defined 

199 in ConfigClass 

200 """ 

201 if self._config._frozen: 

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

203 

204 if name in self.__dict__: 

205 # attribute exists in the ConfigurableInstance wrapper 

206 object.__setattr__(self, name, value) 

207 else: 

208 if at is None: 

209 at = getCallStack() 

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

211 

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

213 """ 

214 Pretend to be an isntance of ConfigClass. 

215 Attributes defiend by ConfigurableInstance will shadow those defined 

216 in ConfigClass. 

217 """ 

218 if self._config._frozen: 

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

220 

221 try: 

222 # attribute exists in the ConfigurableInstance wrapper 

223 object.__delattr__(self, name) 

224 except AttributeError: 

225 if at is None: 

226 at = getCallStack() 

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

228 

229 def __reduce__(self): 

230 raise UnexpectedProxyUsageError( 

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

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

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

234 "or variables." 

235 ) 

236 

237 

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

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

240 can be retargeted towards a different configurable (often a 

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

242 

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

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

245 

246 Parameters 

247 ---------- 

248 doc : `str` 

249 A description of the configuration field. 

250 target : `lsst.pipe.base.Task` or other configurable class 

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

252 attribute. Within the task framework, configurables are 

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

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

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

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

257 ``target.ConfigClass`` is used. 

258 default : `type`, optional 

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

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

261 check : `collections.abc.Callable`, optional 

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

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

264 `False` otherwise). 

265 deprecated : `None` or `str`, optional 

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

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

268 

269 See Also 

270 -------- 

271 ChoiceField 

272 ConfigChoiceField 

273 ConfigDictField 

274 ConfigField 

275 DictField 

276 Field 

277 ListField 

278 RangeField 

279 RegistryField 

280 

281 Notes 

282 ----- 

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

284 fully-configured configurable. 

285 """ 

286 

287 def validateTarget(self, target, ConfigClass): 

288 """Validate the target and configuration class. 

289 

290 Parameters 

291 ---------- 

292 target : `type` 

293 The configurable being verified. 

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

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

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

297 

298 Raises 

299 ------ 

300 AttributeError 

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

302 ``ConfigClass`` attribute. 

303 TypeError 

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

305 subclass. 

306 ValueError 

307 Raised if: 

308 

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

310 method). 

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

312 ``__module__`` or ``__name__`` attributes). 

313 """ 

314 if ConfigClass is None: 

315 try: 

316 ConfigClass = target.ConfigClass 

317 except Exception as e: 

318 raise AttributeError("'target' must define attribute 'ConfigClass'") from e 

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

320 raise TypeError( 

321 f"'ConfigClass' is of incorrect type {_typeStr(ConfigClass)}. " 

322 "'ConfigClass' must be a subclass of Config" 

323 ) 

324 if not callable(target): 324 ↛ 325line 324 didn't jump to line 325 because the condition on line 324 was never true

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

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

327 raise ValueError( 

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

329 ) 

330 return ConfigClass 

331 

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

333 ConfigClass = self.validateTarget(target, ConfigClass) 

334 

335 if default is None: 

336 default = ConfigClass 

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

338 raise TypeError( 

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

340 ) 

341 

342 source = getStackFrame() 

343 self._setup( 

344 doc=doc, 

345 dtype=ConfigurableInstance, 

346 default=default, 

347 check=check, 

348 optional=False, 

349 source=source, 

350 deprecated=deprecated, 

351 ) 

352 self.target = target 

353 self.ConfigClass = ConfigClass 

354 

355 @staticmethod 

356 def _parseTypingArgs( 

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

358 ) -> Mapping[str, Any]: 

359 return kwds 

360 

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

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

363 if value is None: 

364 if at is None: 

365 at = getCallStack(1) 

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

367 instance._storage[self.name] = value 

368 return value 

369 

370 @overload 

371 def __get__( 

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

373 ) -> ConfigurableField: ... 

374 

375 @overload 

376 def __get__( 

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

378 ) -> ConfigurableInstance[FieldTypeVar]: ... 

379 

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

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

382 return self 

383 else: 

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

385 

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

387 if instance._frozen: 

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

389 if at is None: 

390 at = getCallStack() 

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

392 

393 if isinstance(value, ConfigurableInstance): 

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

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

396 elif type(value) is oldValue._ConfigClass: 

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

398 elif value == oldValue.ConfigClass: 

399 value = oldValue.ConfigClass() 

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

401 else: 

402 msg = ( 

403 f"Value {value} is of incorrect type {_typeStr(value)}. " 

404 f"Expected {_typeStr(oldValue.ConfigClass)}" 

405 ) 

406 raise FieldValidationError(self, instance, msg) 

407 

408 def rename(self, instance): 

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

410 value = self.__getOrMake(instance) 

411 value._rename(fullname) 

412 

413 def _collectImports(self, instance, imports): 

414 value = self.__get__(instance) 

415 target = value.target 

416 imports.add(target.__module__) 

417 value.value._collectImports() 

418 imports |= value.value._imports 

419 

420 def save(self, outfile, instance): 

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

422 value = self.__getOrMake(instance) 

423 target = value.target 

424 

425 if target != self.target: 

426 # not targeting the field-default target. 

427 # save target information 

428 ConfigClass = value.ConfigClass 

429 outfile.write( 

430 f"{fullname}.retarget(target={_typeStr(target)}, ConfigClass={_typeStr(ConfigClass)})\n\n" 

431 ) 

432 # save field values 

433 value._save(outfile) 

434 

435 def freeze(self, instance): 

436 value = self.__getOrMake(instance) 

437 value.freeze() 

438 

439 def toDict(self, instance): 

440 value = self.__get__(instance) 

441 return value.toDict() 

442 

443 def _copy_storage(self, old: Config, new: Config) -> ConfigurableInstance | None: 

444 instance: ConfigurableInstance | None = old._storage.get(self.name) 

445 if instance is not None: 

446 return instance._copy(new) 

447 else: 

448 return None 

449 

450 def validate(self, instance): 

451 value = self.__get__(instance) 

452 value.validate() 

453 

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

455 msg = f"{value} is not a valid value" 

456 raise FieldValidationError(self, instance, msg) 

457 

458 def __deepcopy__(self, memo): 

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

460 original typemap. 

461 

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

463 constructor signature! 

464 """ 

465 return type(self)( 

466 doc=self.doc, 

467 target=self.target, 

468 ConfigClass=self.ConfigClass, 

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

470 ) 

471 

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

473 """Compare two fields for equality. 

474 

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

476 

477 Parameters 

478 ---------- 

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

480 Left-hand side config instance to compare. 

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

482 Right-hand side config instance to compare. 

483 shortcut : `bool` 

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

485 rtol : `float` 

486 Relative tolerance for floating point comparisons. 

487 atol : `float` 

488 Absolute tolerance for floating point comparisons. 

489 output : `collections.abc.Callable` 

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

491 report inequalities. For example: `print`. 

492 

493 Returns 

494 ------- 

495 isEqual : bool 

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

497 

498 Notes 

499 ----- 

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

501 """ 

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

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

504 name = getComparisonName( 

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

506 ) 

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