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

176 statements  

« prev     ^ index     » next       coverage.py v7.1.0, created at 2023-02-15 17:34 -0800

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 typing import Any, Generic, Mapping, Union, overload 

35 

36from .callStack import getCallStack, getStackFrame 

37from .comparison import compareConfigs, getComparisonName 

38from .config import ( 

39 Config, 

40 Field, 

41 FieldTypeVar, 

42 FieldValidationError, 

43 UnexpectedProxyUsageError, 

44 _joinNamePath, 

45 _typeStr, 

46) 

47 

48 

49class ConfigurableInstance(Generic[FieldTypeVar]): 

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

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

52 

53 Notes 

54 ----- 

55 ``ConfigurableInstance`` implements ``__getattr__`` and ``__setattr__`` 

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

57 ``ConfigurableInstance`` adds a `retarget` method. 

58 

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

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

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

62 using the ``target`` property. 

63 """ 

64 

65 def __initValue(self, at, label): 

66 """Construct value of field. 

67 

68 Notes 

69 ----- 

70 If field.default is an instance of `lsst.pex.config.ConfigClass`, 

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

72 Otherwise, call ``ConfigClass`` constructor 

73 """ 

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

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

76 storage = self._field.default._storage 

77 else: 

78 storage = {} 

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

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

81 

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

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

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

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

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

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

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

89 

90 if at is None: 

91 at = getCallStack() 

92 at += [self._field.source] 

93 self.__initValue(at, label) 

94 

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

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

97 

98 @property 

99 def _config(self) -> Config: 

100 # Config Fields should never outlive their config class instance 

101 # assert that as such here 

102 assert self._config_() is not None 

103 return self._config_() 

104 

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

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

107 """ 

108 

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

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

111 """ 

112 

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

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

115 read-only). 

116 """ 

117 

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

119 """Call the configurable. 

120 

121 Notes 

122 ----- 

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

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

125 the value of `ConfigurableInstance.value`. 

126 """ 

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

128 

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

130 """Target a new configurable and ConfigClass""" 

131 if self._config._frozen: 

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

133 

134 try: 

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

136 except BaseException as e: 

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

138 

139 if at is None: 

140 at = getCallStack() 

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

142 if ConfigClass != self.ConfigClass: 

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

144 self.__initValue(at, label) 

145 

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

147 msg = "retarget(target=%s, ConfigClass=%s)" % (_typeStr(target), _typeStr(ConfigClass)) 

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

149 

150 def __getattr__(self, name): 

151 return getattr(self._value, name) 

152 

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

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

155 

156 Attributes defined by ConfigurableInstance will shadow those defined 

157 in ConfigClass 

158 """ 

159 if self._config._frozen: 

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

161 

162 if name in self.__dict__: 

163 # attribute exists in the ConfigurableInstance wrapper 

164 object.__setattr__(self, name, value) 

165 else: 

166 if at is None: 

167 at = getCallStack() 

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

169 

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

171 """ 

172 Pretend to be an isntance of ConfigClass. 

173 Attributes defiend by ConfigurableInstance will shadow those defined 

174 in ConfigClass 

175 """ 

176 if self._config._frozen: 

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

178 

179 try: 

180 # attribute exists in the ConfigurableInstance wrapper 

181 object.__delattr__(self, name) 

182 except AttributeError: 

183 if at is None: 

184 at = getCallStack() 

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

186 

187 def __reduce__(self): 

188 raise UnexpectedProxyUsageError( 

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

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

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

192 "or variables." 

193 ) 

194 

195 

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

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

198 can be retargeted towards a different configurable (often a 

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

200 

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

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

203 

204 Parameters 

205 ---------- 

206 doc : `str` 

207 A description of the configuration field. 

208 target : configurable class 

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

210 attribute. Within the task framework, configurables are 

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

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

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

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

215 ``target.ConfigClass`` is used. 

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

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

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

219 check : callable, optional 

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

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

222 `False` otherwise). 

223 deprecated : None or `str`, optional 

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

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

226 

227 See also 

228 -------- 

229 ChoiceField 

230 ConfigChoiceField 

231 ConfigDictField 

232 ConfigField 

233 DictField 

234 Field 

235 ListField 

236 RangeField 

237 RegistryField 

238 

239 Notes 

240 ----- 

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

242 fully-configured configurable. 

243 """ 

244 

245 def validateTarget(self, target, ConfigClass): 

246 """Validate the target and configuration class. 

247 

248 Parameters 

249 ---------- 

250 target 

251 The configurable being verified. 

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

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

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

255 

256 Raises 

257 ------ 

258 AttributeError 

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

260 ``ConfigClass`` attribute. 

261 TypeError 

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

263 subclass. 

264 ValueError 

265 Raised if: 

266 

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

268 method). 

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

270 ``__module__`` or ``__name__`` attributes). 

271 """ 

272 if ConfigClass is None: 

273 try: 

274 ConfigClass = target.ConfigClass 

275 except Exception: 

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

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

278 raise TypeError( 

279 "'ConfigClass' is of incorrect type %s." 

280 "'ConfigClass' must be a subclass of Config" % _typeStr(ConfigClass) 

281 ) 

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

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

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

285 raise ValueError( 

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

287 ) 

288 return ConfigClass 

289 

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

291 ConfigClass = self.validateTarget(target, ConfigClass) 

292 

293 if default is None: 

294 default = ConfigClass 

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

296 raise TypeError( 

297 "'default' is of incorrect type %s. Expected %s" % (_typeStr(default), _typeStr(ConfigClass)) 

298 ) 

299 

300 source = getStackFrame() 

301 self._setup( 

302 doc=doc, 

303 dtype=ConfigurableInstance, 

304 default=default, 

305 check=check, 

306 optional=False, 

307 source=source, 

308 deprecated=deprecated, 

309 ) 

310 self.target = target 

311 self.ConfigClass = ConfigClass 

312 

313 @staticmethod 

314 def _parseTypingArgs( 

315 params: Union[tuple[type, ...], tuple[str, ...]], kwds: Mapping[str, Any] 

316 ) -> Mapping[str, Any]: 

317 return kwds 

318 

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

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

321 if value is None: 

322 if at is None: 

323 at = getCallStack(1) 

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

325 instance._storage[self.name] = value 

326 return value 

327 

328 @overload 

329 def __get__( 

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

331 ) -> "ConfigurableField": 

332 ... 

333 

334 @overload 

335 def __get__( 

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

337 ) -> ConfigurableInstance[FieldTypeVar]: 

338 ... 

339 

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

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

342 return self 

343 else: 

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

345 

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

347 if instance._frozen: 

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

349 if at is None: 

350 at = getCallStack() 

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

352 

353 if isinstance(value, ConfigurableInstance): 

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

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

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

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

358 elif value == oldValue.ConfigClass: 

359 value = oldValue.ConfigClass() 

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

361 else: 

362 msg = "Value %s is of incorrect type %s. Expected %s" % ( 

363 value, 

364 _typeStr(value), 

365 _typeStr(oldValue.ConfigClass), 

366 ) 

367 raise FieldValidationError(self, instance, msg) 

368 

369 def rename(self, instance): 

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

371 value = self.__getOrMake(instance) 

372 value._rename(fullname) 

373 

374 def _collectImports(self, instance, imports): 

375 value = self.__get__(instance) 

376 target = value.target 

377 imports.add(target.__module__) 

378 value.value._collectImports() 

379 imports |= value.value._imports 

380 

381 def save(self, outfile, instance): 

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

383 value = self.__getOrMake(instance) 

384 target = value.target 

385 

386 if target != self.target: 

387 # not targeting the field-default target. 

388 # save target information 

389 ConfigClass = value.ConfigClass 

390 outfile.write( 

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

392 fullname, _typeStr(target), _typeStr(ConfigClass) 

393 ) 

394 ) 

395 # save field values 

396 value._save(outfile) 

397 

398 def freeze(self, instance): 

399 value = self.__getOrMake(instance) 

400 value.freeze() 

401 

402 def toDict(self, instance): 

403 value = self.__get__(instance) 

404 return value.toDict() 

405 

406 def validate(self, instance): 

407 value = self.__get__(instance) 

408 value.validate() 

409 

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

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

412 raise FieldValidationError(self, instance, msg) 

413 

414 def __deepcopy__(self, memo): 

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

416 original typemap. 

417 

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

419 constructor signature! 

420 """ 

421 return type(self)( 

422 doc=self.doc, 

423 target=self.target, 

424 ConfigClass=self.ConfigClass, 

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

426 ) 

427 

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

429 """Compare two fields for equality. 

430 

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

432 

433 Parameters 

434 ---------- 

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

436 Left-hand side config instance to compare. 

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

438 Right-hand side config instance to compare. 

439 shortcut : `bool` 

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

441 rtol : `float` 

442 Relative tolerance for floating point comparisons. 

443 atol : `float` 

444 Absolute tolerance for floating point comparisons. 

445 output : callable 

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

447 report inequalities. For example: `print`. 

448 

449 Returns 

450 ------- 

451 isEqual : bool 

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

453 

454 Notes 

455 ----- 

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

457 """ 

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

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

460 name = getComparisonName( 

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

462 ) 

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