Coverage for python/lsst/pex/config/configurableActions/_configurableActionStructField.py: 25%

182 statements  

« prev     ^ index     » next       coverage.py v7.4.4, created at 2024-04-06 03:53 -0700

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# (https://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 program is free software: you can redistribute it and/or modify 

10# it under the terms of the GNU General Public License as published by 

11# the Free Software Foundation, either version 3 of the License, or 

12# (at your option) any later version. 

13# 

14# This program is distributed in the hope that it will be useful, 

15# but WITHOUT ANY WARRANTY; without even the implied warranty of 

16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

17# GNU General Public License for more details. 

18# 

19# You should have received a copy of the GNU General Public License 

20# along with this program. If not, see <https://www.gnu.org/licenses/>. 

21from __future__ import annotations 

22 

23__all__ = ("ConfigurableActionStructField", "ConfigurableActionStruct") 

24 

25import weakref 

26from collections.abc import Iterable, Iterator, Mapping 

27from types import GenericAlias, SimpleNamespace 

28from typing import Any, Generic, TypeVar, overload 

29 

30from lsst.pex.config.callStack import StackFrame, getCallStack, getStackFrame 

31from lsst.pex.config.comparison import compareConfigs, compareScalars, getComparisonName 

32from lsst.pex.config.config import Config, Field, FieldValidationError, _joinNamePath, _typeStr 

33 

34from . import ActionTypeVar, ConfigurableAction 

35 

36 

37class ConfigurableActionStructUpdater: 

38 """Abstract the logic of using a dictionary to update a 

39 `ConfigurableActionStruct` through attribute assignment. 

40 

41 This is useful in the context of setting configuration through pipelines 

42 or on the command line. 

43 """ 

44 

45 def __set__( 

46 self, 

47 instance: ConfigurableActionStruct, 

48 value: Mapping[str, ConfigurableAction] | ConfigurableActionStruct, 

49 ) -> None: 

50 if isinstance(value, Mapping): 

51 pass 

52 elif isinstance(value, ConfigurableActionStruct): 

53 # If the update target is a ConfigurableActionStruct, get the 

54 # internal dictionary 

55 value = value._attrs 

56 else: 

57 raise ValueError( 

58 "Can only update a ConfigurableActionStruct with an instance of such, or a " "mapping" 

59 ) 

60 for name, action in value.items(): 

61 setattr(instance, name, action) 

62 

63 def __get__(self, instance, objtype=None) -> None: 

64 # This descriptor does not support fetching any value 

65 return None 

66 

67 

68class ConfigurableActionStructRemover: 

69 """Abstract the logic of removing an iterable of action names from a 

70 `ConfigurableActionStruct` at one time using attribute assignment. 

71 

72 This is useful in the context of setting configuration through pipelines 

73 or on the command line. 

74 

75 Raises 

76 ------ 

77 AttributeError 

78 Raised if an attribute specified for removal does not exist in the 

79 ConfigurableActionStruct 

80 """ 

81 

82 def __set__(self, instance: ConfigurableActionStruct, value: str | Iterable[str]) -> None: 

83 # strings are iterable, but not in the way that is intended. If a 

84 # single name is specified, turn it into a tuple before attempting 

85 # to remove the attribute 

86 if isinstance(value, str): 

87 value = (value,) 

88 for name in value: 

89 delattr(instance, name) 

90 

91 def __get__(self, instance, objtype=None) -> None: 

92 # This descriptor does not support fetching any value 

93 return None 

94 

95 

96class ConfigurableActionStruct(Generic[ActionTypeVar]): 

97 """A ConfigurableActionStruct is the storage backend class that supports 

98 the ConfigurableActionStructField. This class should not be created 

99 directly. 

100 

101 This class allows managing a collection of `ConfigurableAction` with a 

102 struct like interface, that is to say in an attribute like notation. 

103 

104 Parameters 

105 ---------- 

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

107 Config to use. 

108 field : `ConfigurableActionStructField` 

109 Field to use. 

110 value : `~collections.abc.Mapping` [`str`, `ConfigurableAction`] 

111 Value to assign. 

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

113 Stack frames to use for history recording. 

114 label : `str`, optional 

115 Label to use for history recording. 

116 

117 Notes 

118 ----- 

119 Attributes can be dynamically added or removed as such: 

120 

121 .. code-block:: python 

122 

123 ConfigurableActionStructInstance.variable1 = a_configurable_action 

124 del ConfigurableActionStructInstance.variable1 

125 

126 Each action is then available to be individually configured as a normal 

127 `lsst.pex.config.Config` object. 

128 

129 `ConfigurableActionStruct` supports two special convenience attributes. 

130 

131 The first is ``update``. You may assign a dict of `ConfigurableAction` or a 

132 `ConfigurableActionStruct` to this attribute which will update the 

133 `ConfigurableActionStruct` on which the attribute is invoked such that it 

134 will be updated to contain the entries specified by the structure on the 

135 right hand side of the equals sign. 

136 

137 The second convenience attribute is named ``remove``. You may assign an 

138 iterable of strings which correspond to attribute names on the 

139 `ConfigurableActionStruct`. All of the corresponding attributes will then 

140 be removed. If any attribute does not exist, an `AttributeError` will be 

141 raised. Any attributes in the Iterable prior to the name which raises will 

142 have been removed from the `ConfigurableActionStruct` 

143 """ 

144 

145 # declare attributes that are set with __setattr__ 

146 _config_: weakref.ref 

147 _attrs: dict[str, ActionTypeVar] 

148 _field: ConfigurableActionStructField 

149 _history: list[tuple] 

150 

151 # create descriptors to handle special update and remove behavior 

152 update = ConfigurableActionStructUpdater() 

153 remove = ConfigurableActionStructRemover() 

154 

155 def __init__( 

156 self, 

157 config: Config, 

158 field: ConfigurableActionStructField, 

159 value: Mapping[str, ConfigurableAction], 

160 at: Any, 

161 label: str, 

162 ): 

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

164 object.__setattr__(self, "_attrs", {}) 

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

166 object.__setattr__(self, "_history", []) 

167 

168 self.history.append(("Struct initialized", at, label)) 

169 

170 if value is not None: 

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

172 setattr(self, k, v) 

173 

174 @property 

175 def _config(self) -> Config: 

176 # Config Fields should never outlive their config class instance 

177 # assert that as such here 

178 value = self._config_() 

179 assert value is not None 

180 return value 

181 

182 @property 

183 def history(self) -> list[tuple]: 

184 return self._history 

185 

186 @property 

187 def fieldNames(self) -> Iterable[str]: 

188 return self._attrs.keys() 

189 

190 def __setattr__( 

191 self, 

192 attr: str, 

193 value: ActionTypeVar | type[ActionTypeVar], 

194 at=None, 

195 label="setattr", 

196 setHistory=False, 

197 ) -> None: 

198 if hasattr(self._config, "_frozen") and self._config._frozen: 

199 msg = "Cannot modify a frozen Config. " f"Attempting to set item {attr} to value {value}" 

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

201 

202 # verify that someone has not passed a string with a space or leading 

203 # number or something through the dict assignment update interface 

204 if not attr.isidentifier(): 

205 raise ValueError("Names used in ConfigurableStructs must be valid as python variable names") 

206 

207 if attr not in (self.__dict__.keys() | type(self).__dict__.keys()): 

208 base_name = _joinNamePath(self._config._name, self._field.name) 

209 name = _joinNamePath(base_name, attr) 

210 if at is None: 

211 at = getCallStack() 

212 if isinstance(value, ConfigurableAction): 

213 valueInst = type(value)(__name=name, __at=at, __label=label, **value._storage) 

214 else: 

215 valueInst = value(__name=name, __at=at, __label=label) 

216 self._attrs[attr] = valueInst 

217 else: 

218 super().__setattr__(attr, value) 

219 

220 def __getattr__(self, attr) -> Any: 

221 if attr in object.__getattribute__(self, "_attrs"): 

222 result = self._attrs[attr] 

223 result.identity = attr 

224 return result 

225 else: 

226 super().__getattribute__(attr) 

227 

228 def __delattr__(self, name): 

229 if name in self._attrs: 

230 del self._attrs[name] 

231 else: 

232 super().__delattr__(name) 

233 

234 def __iter__(self) -> Iterator[ActionTypeVar]: 

235 for name in self.fieldNames: 

236 yield getattr(self, name) 

237 

238 def items(self) -> Iterable[tuple[str, ActionTypeVar]]: 

239 for name in self.fieldNames: 

240 yield name, getattr(self, name) 

241 

242 def __bool__(self) -> bool: 

243 return bool(self._attrs) 

244 

245 

246T = TypeVar("T", bound="ConfigurableActionStructField") 

247 

248 

249class ConfigurableActionStructField(Field[ActionTypeVar]): 

250 """`ConfigurableActionStructField` is a `~lsst.pex.config.Field` subclass 

251 that allows a `ConfigurableAction` to be organized in a 

252 `~lsst.pex.config.Config` class in a manner similar to how a 

253 `~lsst.pipe.base.Struct` works. 

254 

255 This class uses a `ConfigurableActionStruct` as an intermediary object to 

256 organize the `ConfigurableAction`. See its documentation for further 

257 information. 

258 

259 Parameters 

260 ---------- 

261 doc : `str` 

262 Documentation string. 

263 default : `~collections.abc.Mapping` [ `str`, `ConfigurableAction` ] \ 

264 or `None`, optional 

265 Default value. 

266 optional : `bool`, optional 

267 If `True`, the field doesn't need to have a set value. 

268 deprecated : `bool` or `None`, optional 

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

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

271 """ 

272 

273 # specify StructClass to make this more generic for potential future 

274 # inheritance 

275 StructClass = ConfigurableActionStruct 

276 

277 # Explicitly annotate these on the class, they are present in the base 

278 # class through injection, so type systems have trouble seeing them. 

279 name: str 

280 default: Mapping[str, ConfigurableAction] | None 

281 

282 def __init__( 

283 self, 

284 doc: str, 

285 default: Mapping[str, ConfigurableAction] | None = None, 

286 optional: bool = False, 

287 deprecated=None, 

288 ): 

289 source = getStackFrame() 

290 self._setup( 

291 doc=doc, 

292 dtype=self.__class__, 

293 default=default, 

294 check=None, 

295 optional=optional, 

296 source=source, 

297 deprecated=deprecated, 

298 ) 

299 

300 def __class_getitem__(cls, params): 

301 return GenericAlias(cls, params) 

302 

303 def __set__( 

304 self, 

305 instance: Config, 

306 value: ( 

307 None 

308 | Mapping[str, ConfigurableAction] 

309 | SimpleNamespace 

310 | ConfigurableActionStruct 

311 | ConfigurableActionStructField 

312 | type[ConfigurableActionStructField] 

313 ), 

314 at: Iterable[StackFrame] = None, 

315 label: str = "assigment", 

316 ): 

317 if instance._frozen: 

318 msg = "Cannot modify a frozen Config. " "Attempting to set field to value %s" % value 

319 raise FieldValidationError(self, instance, msg) 

320 

321 if at is None: 

322 at = getCallStack() 

323 

324 if value is None or (self.default is not None and self.default == value): 

325 value = self.StructClass(instance, self, value, at=at, label=label) 

326 else: 

327 # An actual value is being assigned check for what it is 

328 if isinstance(value, self.StructClass): 

329 # If this is a ConfigurableActionStruct, we need to make our 

330 # own copy that references this current field 

331 value = self.StructClass(instance, self, value._attrs, at=at, label=label) 

332 elif isinstance(value, SimpleNamespace): 

333 # If this is a a python analogous container, we need to make 

334 # a ConfigurableActionStruct initialized with this data 

335 value = self.StructClass(instance, self, vars(value), at=at, label=label) 

336 

337 elif type(value) is ConfigurableActionStructField: 

338 raise ValueError( 

339 "ConfigurableActionStructFields can only be used in a class body declaration" 

340 f"Use a {self.StructClass}, SimpleNamespace or Struct" 

341 ) 

342 else: 

343 raise ValueError(f"Unrecognized value {value}, cannot be assigned to this field") 

344 

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

346 history.append((value, at, label)) 

347 

348 if not isinstance(value, ConfigurableActionStruct): 

349 raise FieldValidationError( 

350 self, instance, "Can only assign things that are subclasses of Configurable Action" 

351 ) 

352 instance._storage[self.name] = value 

353 

354 @overload 

355 def __get__( 

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

357 ) -> ConfigurableActionStruct[ActionTypeVar]: 

358 ... 

359 

360 @overload 

361 def __get__( 

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

363 ) -> ConfigurableActionStruct[ActionTypeVar]: 

364 ... 

365 

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

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

368 return self 

369 else: 

370 field: ConfigurableActionStruct | None = instance._storage[self.name] 

371 return field 

372 

373 def rename(self, instance: Config): 

374 actionStruct: ConfigurableActionStruct = self.__get__(instance) 

375 if actionStruct is not None: 

376 for k, v in actionStruct.items(): 

377 base_name = _joinNamePath(instance._name, self.name) 

378 fullname = _joinNamePath(base_name, k) 

379 v._rename(fullname) 

380 

381 def validate(self, instance: Config): 

382 value = self.__get__(instance) 

383 if value is not None: 

384 for item in value: 

385 item.validate() 

386 

387 def toDict(self, instance): 

388 actionStruct = self.__get__(instance) 

389 if actionStruct is None: 

390 return None 

391 

392 dict_ = {k: v.toDict() for k, v in actionStruct.items()} 

393 

394 return dict_ 

395 

396 def save(self, outfile, instance): 

397 actionStruct = self.__get__(instance) 

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

399 

400 # Ensure that a struct is always empty before assigning to it. 

401 outfile.write(f"{fullname}=None\n") 

402 

403 if actionStruct is None: 

404 return 

405 

406 for _, v in sorted(actionStruct.items()): 

407 outfile.write(f"{v._name}={_typeStr(v)}()\n") 

408 v._save(outfile) 

409 

410 def freeze(self, instance): 

411 actionStruct = self.__get__(instance) 

412 if actionStruct is not None: 

413 for v in actionStruct: 

414 v.freeze() 

415 

416 def _collectImports(self, instance, imports): 

417 # docstring inherited from Field 

418 actionStruct = self.__get__(instance) 

419 for v in actionStruct: 

420 v._collectImports() 

421 imports |= v._imports 

422 

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

424 """Compare two fields for equality. 

425 

426 Parameters 

427 ---------- 

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

429 Left-hand side config instance to compare. 

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

431 Right-hand side config instance to compare. 

432 shortcut : `bool` 

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

434 rtol : `float` 

435 Relative tolerance for floating point comparisons. 

436 atol : `float` 

437 Absolute tolerance for floating point comparisons. 

438 output : callable 

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

440 report inequalities. 

441 

442 Returns 

443 ------- 

444 isEqual : bool 

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

446 

447 Notes 

448 ----- 

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

450 """ 

451 d1: ConfigurableActionStruct = getattr(instance1, self.name) 

452 d2: ConfigurableActionStruct = getattr(instance2, self.name) 

453 name = getComparisonName( 

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

455 ) 

456 if not compareScalars(f"keys for {name}", set(d1.fieldNames), set(d2.fieldNames), output=output): 

457 return False 

458 equal = True 

459 for k, v1 in d1.items(): 

460 v2 = getattr(d2, k) 

461 result = compareConfigs( 

462 f"{name}.{k}", v1, v2, shortcut=shortcut, rtol=rtol, atol=atol, output=output 

463 ) 

464 if not result and shortcut: 

465 return False 

466 equal = equal and result 

467 return equal