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

186 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-30 08:43 +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# (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__ = ("ConfigurableActionStruct", "ConfigurableActionStructField") 

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 if at is not None: 

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

170 

171 if value is not None: 

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

173 setattr(self, k, v) 

174 

175 def _copy(self, config: Config) -> ConfigurableActionStruct: 

176 result = ConfigurableActionStruct(config, self._field, self._attrs, at=None, label="copy") 

177 result.history.extend(self.history) 

178 return result 

179 

180 @property 

181 def _config(self) -> Config: 

182 # Config Fields should never outlive their config class instance 

183 # assert that as such here 

184 value = self._config_() 

185 assert value is not None 

186 return value 

187 

188 @property 

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

190 return self._history 

191 

192 @property 

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

194 return self._attrs.keys() 

195 

196 def __setattr__( 

197 self, 

198 attr: str, 

199 value: ActionTypeVar | type[ActionTypeVar], 

200 at=None, 

201 label="setattr", 

202 setHistory=False, 

203 ) -> None: 

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

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

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

207 

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

209 # number or something through the dict assignment update interface 

210 if not attr.isidentifier(): 

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

212 

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

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

215 name = _joinNamePath(base_name, attr) 

216 if at is None: 

217 at = getCallStack() 

218 if isinstance(value, ConfigurableAction): 

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

220 else: 

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

222 self._attrs[attr] = valueInst 

223 else: 

224 super().__setattr__(attr, value) 

225 

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

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

228 result = self._attrs[attr] 

229 result.identity = attr 

230 return result 

231 else: 

232 super().__getattribute__(attr) 

233 

234 def __delattr__(self, name): 

235 if name in self._attrs: 

236 del self._attrs[name] 

237 else: 

238 super().__delattr__(name) 

239 

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

241 for name in self.fieldNames: 

242 yield getattr(self, name) 

243 

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

245 for name in self.fieldNames: 

246 yield name, getattr(self, name) 

247 

248 def __bool__(self) -> bool: 

249 return bool(self._attrs) 

250 

251 

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

253 

254 

255class ConfigurableActionStructField(Field[ActionTypeVar]): 

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

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

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

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

260 

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

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

263 information. 

264 

265 Parameters 

266 ---------- 

267 doc : `str` 

268 Documentation string. 

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

270 or `None`, optional 

271 Default value. 

272 optional : `bool`, optional 

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

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

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

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

277 """ 

278 

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

280 # inheritance 

281 StructClass = ConfigurableActionStruct 

282 

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

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

285 name: str 

286 default: Mapping[str, ConfigurableAction] | None 

287 

288 def __init__( 

289 self, 

290 doc: str, 

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

292 optional: bool = False, 

293 deprecated=None, 

294 ): 

295 source = getStackFrame() 

296 self._setup( 

297 doc=doc, 

298 dtype=self.__class__, 

299 default=default, 

300 check=None, 

301 optional=optional, 

302 source=source, 

303 deprecated=deprecated, 

304 ) 

305 

306 def __class_getitem__(cls, params): 

307 return GenericAlias(cls, params) 

308 

309 def __set__( 

310 self, 

311 instance: Config, 

312 value: ( 

313 None 

314 | Mapping[str, ConfigurableAction] 

315 | SimpleNamespace 

316 | ConfigurableActionStruct 

317 | ConfigurableActionStructField 

318 | type[ConfigurableActionStructField] 

319 ), 

320 at: Iterable[StackFrame] = None, 

321 label: str = "assigment", 

322 ): 

323 if instance._frozen: 

324 msg = f"Cannot modify a frozen Config. Attempting to set field to value {value}" 

325 raise FieldValidationError(self, instance, msg) 

326 

327 if at is None: 

328 at = getCallStack() 

329 

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

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

332 else: 

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

334 if isinstance(value, self.StructClass): 

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

336 # own copy that references this current field 

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

338 elif isinstance(value, SimpleNamespace): 

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

340 # a ConfigurableActionStruct initialized with this data 

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

342 

343 elif type(value) is ConfigurableActionStructField: 

344 raise ValueError( 

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

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

347 ) 

348 else: 

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

350 

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

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

353 

354 if not isinstance(value, ConfigurableActionStruct): 

355 raise FieldValidationError( 

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

357 ) 

358 instance._storage[self.name] = value 

359 

360 @overload 

361 def __get__( 

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

363 ) -> ConfigurableActionStruct[ActionTypeVar]: ... 

364 

365 @overload 

366 def __get__( 

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

368 ) -> ConfigurableActionStruct[ActionTypeVar]: ... 

369 

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

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

372 return self 

373 else: 

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

375 return field 

376 

377 def rename(self, instance: Config): 

378 actionStruct: ConfigurableActionStruct = self.__get__(instance) 

379 if actionStruct is not None: 

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

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

382 fullname = _joinNamePath(base_name, k) 

383 v._rename(fullname) 

384 

385 def validate(self, instance: Config): 

386 value = self.__get__(instance) 

387 if value is not None: 

388 for item in value: 

389 item.validate() 

390 

391 def toDict(self, instance): 

392 actionStruct = self.__get__(instance) 

393 if actionStruct is None: 

394 return None 

395 

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

397 

398 return dict_ 

399 

400 def _copy_storage(self, old: Config, new: Config) -> ConfigurableActionStruct: 

401 struct: ConfigurableActionStruct | None = old._storage.get(self.name) 

402 if struct is not None: 

403 return struct._copy(new) 

404 else: 

405 return None 

406 

407 def save(self, outfile, instance): 

408 actionStruct = self.__get__(instance) 

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

410 

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

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

413 

414 if actionStruct is None: 

415 return 

416 

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

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

419 v._save(outfile) 

420 

421 def freeze(self, instance): 

422 actionStruct = self.__get__(instance) 

423 if actionStruct is not None: 

424 for v in actionStruct: 

425 v.freeze() 

426 

427 def _collectImports(self, instance, imports): 

428 # docstring inherited from Field 

429 actionStruct = self.__get__(instance) 

430 for v in actionStruct: 

431 v._collectImports() 

432 imports |= v._imports 

433 

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

435 """Compare two fields for equality. 

436 

437 Parameters 

438 ---------- 

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

440 Left-hand side config instance to compare. 

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

442 Right-hand side config instance to compare. 

443 shortcut : `bool` 

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

445 rtol : `float` 

446 Relative tolerance for floating point comparisons. 

447 atol : `float` 

448 Absolute tolerance for floating point comparisons. 

449 output : `collections.abc.Callable` 

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

451 report inequalities. 

452 

453 Returns 

454 ------- 

455 isEqual : bool 

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

457 

458 Notes 

459 ----- 

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

461 """ 

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

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

464 name = getComparisonName( 

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

466 ) 

467 if not compareScalars(f"{name} (fields)", set(d1.fieldNames), set(d2.fieldNames), output=output): 

468 return False 

469 equal = True 

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

471 v2 = getattr(d2, k) 

472 result = compareConfigs( 

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

474 ) 

475 if not result and shortcut: 

476 return False 

477 equal = equal and result 

478 return equal