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

182 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2023-07-10 09:56 +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__ = ("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 Attributes can be dynamically added or removed as such: 

105 

106 .. code-block:: python 

107 

108 ConfigurableActionStructInstance.variable1 = a_configurable_action 

109 del ConfigurableActionStructInstance.variable1 

110 

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

112 `lsst.pex.config.Config` object. 

113 

114 `ConfigurableActionStruct` supports two special convenience attributes. 

115 

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

117 `ConfigurableActionStruct` to this attribute which will update the 

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

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

120 right hand side of the equals sign. 

121 

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

123 iterable of strings which correspond to attribute names on the 

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

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

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

127 have been removed from the `ConfigurableActionStruct` 

128 """ 

129 

130 # declare attributes that are set with __setattr__ 

131 _config_: weakref.ref 

132 _attrs: dict[str, ActionTypeVar] 

133 _field: ConfigurableActionStructField 

134 _history: list[tuple] 

135 

136 # create descriptors to handle special update and remove behavior 

137 update = ConfigurableActionStructUpdater() 

138 remove = ConfigurableActionStructRemover() 

139 

140 def __init__( 

141 self, 

142 config: Config, 

143 field: ConfigurableActionStructField, 

144 value: Mapping[str, ConfigurableAction], 

145 at: Any, 

146 label: str, 

147 ): 

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

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

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

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

152 

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

154 

155 if value is not None: 

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

157 setattr(self, k, v) 

158 

159 @property 

160 def _config(self) -> Config: 

161 # Config Fields should never outlive their config class instance 

162 # assert that as such here 

163 value = self._config_() 

164 assert value is not None 

165 return value 

166 

167 @property 

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

169 return self._history 

170 

171 @property 

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

173 return self._attrs.keys() 

174 

175 def __setattr__( 

176 self, 

177 attr: str, 

178 value: ActionTypeVar | type[ActionTypeVar], 

179 at=None, 

180 label="setattr", 

181 setHistory=False, 

182 ) -> None: 

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

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

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

186 

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

188 # number or something through the dict assignment update interface 

189 if not attr.isidentifier(): 

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

191 

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

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

194 name = _joinNamePath(base_name, attr) 

195 if at is None: 

196 at = getCallStack() 

197 if isinstance(value, ConfigurableAction): 

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

199 else: 

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

201 self._attrs[attr] = valueInst 

202 else: 

203 super().__setattr__(attr, value) 

204 

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

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

207 result = self._attrs[attr] 

208 result.identity = attr 

209 return result 

210 else: 

211 super().__getattribute__(attr) 

212 

213 def __delattr__(self, name): 

214 if name in self._attrs: 

215 del self._attrs[name] 

216 else: 

217 super().__delattr__(name) 

218 

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

220 for name in self.fieldNames: 

221 yield getattr(self, name) 

222 

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

224 for name in self.fieldNames: 

225 yield name, getattr(self, name) 

226 

227 def __bool__(self) -> bool: 

228 return bool(self._attrs) 

229 

230 

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

232 

233 

234class ConfigurableActionStructField(Field[ActionTypeVar]): 

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

236 that allows `ConfigurableAction`\ s to be organized in a 

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

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

239 

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

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

242 information. 

243 """ 

244 

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

246 # inheritance 

247 StructClass = ConfigurableActionStruct 

248 

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

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

251 name: str 

252 default: Mapping[str, ConfigurableAction] | None 

253 

254 def __init__( 

255 self, 

256 doc: str, 

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

258 optional: bool = False, 

259 deprecated=None, 

260 ): 

261 source = getStackFrame() 

262 self._setup( 

263 doc=doc, 

264 dtype=self.__class__, 

265 default=default, 

266 check=None, 

267 optional=optional, 

268 source=source, 

269 deprecated=deprecated, 

270 ) 

271 

272 def __class_getitem__(cls, params): 

273 return GenericAlias(cls, params) 

274 

275 def __set__( 

276 self, 

277 instance: Config, 

278 value: ( 

279 None 

280 | Mapping[str, ConfigurableAction] 

281 | SimpleNamespace 

282 | ConfigurableActionStruct 

283 | ConfigurableActionStructField 

284 | type[ConfigurableActionStructField] 

285 ), 

286 at: Iterable[StackFrame] = None, 

287 label: str = "assigment", 

288 ): 

289 if instance._frozen: 

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

291 raise FieldValidationError(self, instance, msg) 

292 

293 if at is None: 

294 at = getCallStack() 

295 

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

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

298 else: 

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

300 if isinstance(value, self.StructClass): 

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

302 # own copy that references this current field 

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

304 elif isinstance(value, SimpleNamespace): 

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

306 # a ConfigurableActionStruct initialized with this data 

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

308 

309 elif type(value) == ConfigurableActionStructField: 

310 raise ValueError( 

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

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

313 ) 

314 else: 

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

316 

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

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

319 

320 if not isinstance(value, ConfigurableActionStruct): 

321 raise FieldValidationError( 

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

323 ) 

324 instance._storage[self.name] = value 

325 

326 @overload 

327 def __get__( 

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

329 ) -> ConfigurableActionStruct[ActionTypeVar]: 

330 ... 

331 

332 @overload 

333 def __get__( 

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

335 ) -> ConfigurableActionStruct[ActionTypeVar]: 

336 ... 

337 

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

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

340 return self 

341 else: 

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

343 return field 

344 

345 def rename(self, instance: Config): 

346 actionStruct: ConfigurableActionStruct = self.__get__(instance) 

347 if actionStruct is not None: 

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

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

350 fullname = _joinNamePath(base_name, k) 

351 v._rename(fullname) 

352 

353 def validate(self, instance: Config): 

354 value = self.__get__(instance) 

355 if value is not None: 

356 for item in value: 

357 item.validate() 

358 

359 def toDict(self, instance): 

360 actionStruct = self.__get__(instance) 

361 if actionStruct is None: 

362 return None 

363 

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

365 

366 return dict_ 

367 

368 def save(self, outfile, instance): 

369 actionStruct = self.__get__(instance) 

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

371 

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

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

374 

375 if actionStruct is None: 

376 return 

377 

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

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

380 v._save(outfile) 

381 

382 def freeze(self, instance): 

383 actionStruct = self.__get__(instance) 

384 if actionStruct is not None: 

385 for v in actionStruct: 

386 v.freeze() 

387 

388 def _collectImports(self, instance, imports): 

389 # docstring inherited from Field 

390 actionStruct = self.__get__(instance) 

391 for v in actionStruct: 

392 v._collectImports() 

393 imports |= v._imports 

394 

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

396 """Compare two fields for equality. 

397 

398 Parameters 

399 ---------- 

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

401 Left-hand side config instance to compare. 

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

403 Right-hand side config instance to compare. 

404 shortcut : `bool` 

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

406 rtol : `float` 

407 Relative tolerance for floating point comparisons. 

408 atol : `float` 

409 Absolute tolerance for floating point comparisons. 

410 output : callable 

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

412 report inequalities. 

413 

414 Returns 

415 ------- 

416 isEqual : bool 

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

418 

419 Notes 

420 ----- 

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

422 """ 

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

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

425 name = getComparisonName( 

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

427 ) 

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

429 return False 

430 equal = True 

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

432 v2 = getattr(d2, k) 

433 result = compareConfigs( 

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

435 ) 

436 if not result and shortcut: 

437 return False 

438 equal = equal and result 

439 return equal