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

177 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2023-07-04 21:14 +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 Notes 

55 ----- 

56 ``ConfigurableInstance`` implements ``__getattr__`` and ``__setattr__`` 

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

58 ``ConfigurableInstance`` adds a `retarget` method. 

59 

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

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

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

63 using the ``target`` property. 

64 """ 

65 

66 def __initValue(self, at, label): 

67 """Construct value of field. 

68 

69 Notes 

70 ----- 

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

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

73 Otherwise, call ``ConfigClass`` constructor 

74 """ 

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

76 if type(self._field.default) == self.ConfigClass: 

77 storage = self._field.default._storage 

78 else: 

79 storage = {} 

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

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

82 

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

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

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

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

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

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

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

90 

91 if at is None: 

92 at = getCallStack() 

93 at += [self._field.source] 

94 self.__initValue(at, label) 

95 

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

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

98 

99 @property 

100 def _config(self) -> Config: 

101 # Config Fields should never outlive their config class instance 

102 # assert that as such here 

103 assert self._config_() is not None 

104 return self._config_() 

105 

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

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

108 """ 

109 

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

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

112 """ 

113 

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

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

116 read-only). 

117 """ 

118 

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

120 """Call the configurable. 

121 

122 Notes 

123 ----- 

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

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

126 the value of `ConfigurableInstance.value`. 

127 """ 

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

129 

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

131 """Target a new configurable and ConfigClass.""" 

132 if self._config._frozen: 

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

134 

135 try: 

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

137 except BaseException as e: 

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

139 

140 if at is None: 

141 at = getCallStack() 

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

143 if ConfigClass != self.ConfigClass: 

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

145 self.__initValue(at, label) 

146 

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

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

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

150 

151 def __getattr__(self, name): 

152 return getattr(self._value, name) 

153 

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

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

156 

157 Attributes defined by ConfigurableInstance will shadow those defined 

158 in ConfigClass 

159 """ 

160 if self._config._frozen: 

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

162 

163 if name in self.__dict__: 

164 # attribute exists in the ConfigurableInstance wrapper 

165 object.__setattr__(self, name, value) 

166 else: 

167 if at is None: 

168 at = getCallStack() 

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

170 

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

172 """ 

173 Pretend to be an isntance of ConfigClass. 

174 Attributes defiend by ConfigurableInstance will shadow those defined 

175 in ConfigClass. 

176 """ 

177 if self._config._frozen: 

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

179 

180 try: 

181 # attribute exists in the ConfigurableInstance wrapper 

182 object.__delattr__(self, name) 

183 except AttributeError: 

184 if at is None: 

185 at = getCallStack() 

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

187 

188 def __reduce__(self): 

189 raise UnexpectedProxyUsageError( 

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

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

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

193 "or variables." 

194 ) 

195 

196 

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

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

199 can be retargeted towards a different configurable (often a 

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

201 

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

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

204 

205 Parameters 

206 ---------- 

207 doc : `str` 

208 A description of the configuration field. 

209 target : configurable class 

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

211 attribute. Within the task framework, configurables are 

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

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

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

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

216 ``target.ConfigClass`` is used. 

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

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

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

220 check : callable, optional 

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

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

223 `False` otherwise). 

224 deprecated : None or `str`, optional 

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

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

227 

228 See Also 

229 -------- 

230 ChoiceField 

231 ConfigChoiceField 

232 ConfigDictField 

233 ConfigField 

234 DictField 

235 Field 

236 ListField 

237 RangeField 

238 RegistryField 

239 

240 Notes 

241 ----- 

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

243 fully-configured configurable. 

244 """ 

245 

246 def validateTarget(self, target, ConfigClass): 

247 """Validate the target and configuration class. 

248 

249 Parameters 

250 ---------- 

251 target 

252 The configurable being verified. 

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

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

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

256 

257 Raises 

258 ------ 

259 AttributeError 

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

261 ``ConfigClass`` attribute. 

262 TypeError 

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

264 subclass. 

265 ValueError 

266 Raised if: 

267 

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

269 method). 

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

271 ``__module__`` or ``__name__`` attributes). 

272 """ 

273 if ConfigClass is None: 

274 try: 

275 ConfigClass = target.ConfigClass 

276 except Exception: 

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

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

279 raise TypeError( 

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

281 % _typeStr(ConfigClass) 

282 ) 

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

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

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

286 raise ValueError( 

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

288 ) 

289 return ConfigClass 

290 

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

292 ConfigClass = self.validateTarget(target, ConfigClass) 

293 

294 if default is None: 

295 default = ConfigClass 

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

297 raise TypeError( 

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

299 ) 

300 

301 source = getStackFrame() 

302 self._setup( 

303 doc=doc, 

304 dtype=ConfigurableInstance, 

305 default=default, 

306 check=check, 

307 optional=False, 

308 source=source, 

309 deprecated=deprecated, 

310 ) 

311 self.target = target 

312 self.ConfigClass = ConfigClass 

313 

314 @staticmethod 

315 def _parseTypingArgs( 

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

317 ) -> Mapping[str, Any]: 

318 return kwds 

319 

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

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

322 if value is None: 

323 if at is None: 

324 at = getCallStack(1) 

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

326 instance._storage[self.name] = value 

327 return value 

328 

329 @overload 

330 def __get__( 

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

332 ) -> ConfigurableField: 

333 ... 

334 

335 @overload 

336 def __get__( 

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

338 ) -> ConfigurableInstance[FieldTypeVar]: 

339 ... 

340 

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

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

343 return self 

344 else: 

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

346 

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

348 if instance._frozen: 

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

350 if at is None: 

351 at = getCallStack() 

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

353 

354 if isinstance(value, ConfigurableInstance): 

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

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

357 elif type(value) == oldValue._ConfigClass: 

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

359 elif value == oldValue.ConfigClass: 

360 value = oldValue.ConfigClass() 

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

362 else: 

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

364 value, 

365 _typeStr(value), 

366 _typeStr(oldValue.ConfigClass), 

367 ) 

368 raise FieldValidationError(self, instance, msg) 

369 

370 def rename(self, instance): 

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

372 value = self.__getOrMake(instance) 

373 value._rename(fullname) 

374 

375 def _collectImports(self, instance, imports): 

376 value = self.__get__(instance) 

377 target = value.target 

378 imports.add(target.__module__) 

379 value.value._collectImports() 

380 imports |= value.value._imports 

381 

382 def save(self, outfile, instance): 

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

384 value = self.__getOrMake(instance) 

385 target = value.target 

386 

387 if target != self.target: 

388 # not targeting the field-default target. 

389 # save target information 

390 ConfigClass = value.ConfigClass 

391 outfile.write( 

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

393 fullname, _typeStr(target), _typeStr(ConfigClass) 

394 ) 

395 ) 

396 # save field values 

397 value._save(outfile) 

398 

399 def freeze(self, instance): 

400 value = self.__getOrMake(instance) 

401 value.freeze() 

402 

403 def toDict(self, instance): 

404 value = self.__get__(instance) 

405 return value.toDict() 

406 

407 def validate(self, instance): 

408 value = self.__get__(instance) 

409 value.validate() 

410 

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

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

413 raise FieldValidationError(self, instance, msg) 

414 

415 def __deepcopy__(self, memo): 

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

417 original typemap. 

418 

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

420 constructor signature! 

421 """ 

422 return type(self)( 

423 doc=self.doc, 

424 target=self.target, 

425 ConfigClass=self.ConfigClass, 

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

427 ) 

428 

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

430 """Compare two fields for equality. 

431 

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

433 

434 Parameters 

435 ---------- 

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

437 Left-hand side config instance to compare. 

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

439 Right-hand side config instance to compare. 

440 shortcut : `bool` 

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

442 rtol : `float` 

443 Relative tolerance for floating point comparisons. 

444 atol : `float` 

445 Absolute tolerance for floating point comparisons. 

446 output : callable 

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

448 report inequalities. For example: `print`. 

449 

450 Returns 

451 ------- 

452 isEqual : bool 

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

454 

455 Notes 

456 ----- 

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

458 """ 

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

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

461 name = getComparisonName( 

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

463 ) 

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