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

169 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2023-07-06 09:49 +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__ = ["DictField"] 

31 

32import collections.abc 

33import weakref 

34from collections.abc import Iterator, Mapping 

35from typing import Any, ForwardRef, Generic, TypeVar, cast 

36 

37from .callStack import getCallStack, getStackFrame 

38from .comparison import compareScalars, getComparisonName 

39from .config import ( 

40 Config, 

41 Field, 

42 FieldValidationError, 

43 UnexpectedProxyUsageError, 

44 _autocast, 

45 _joinNamePath, 

46 _typeStr, 

47) 

48 

49KeyTypeVar = TypeVar("KeyTypeVar") 

50ItemTypeVar = TypeVar("ItemTypeVar") 

51 

52 

53class Dict(collections.abc.MutableMapping[KeyTypeVar, ItemTypeVar]): 

54 """An internal mapping container. 

55 

56 This class emulates a `dict`, but adds validation and provenance. 

57 """ 

58 

59 def __init__(self, config, field, value, at, label, setHistory=True): 

60 self._field = field 

61 self._config_ = weakref.ref(config) 

62 self._dict = {} 

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

64 self.__doc__ = field.doc 

65 if value is not None: 

66 try: 

67 for k in value: 

68 # do not set history per-item 

69 self.__setitem__(k, value[k], at=at, label=label, setHistory=False) 

70 except TypeError: 

71 msg = f"Value {value} is of incorrect type {_typeStr(value)}. Mapping type expected." 

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

73 if setHistory: 

74 self._history.append((dict(self._dict), at, label)) 

75 

76 @property 

77 def _config(self) -> Config: 

78 # Config Fields should never outlive their config class instance 

79 # assert that as such here 

80 value = self._config_() 

81 assert value is not None 

82 return value 

83 

84 history = property(lambda x: x._history) 84 ↛ exitline 84 didn't run the lambda on line 84

85 """History (read-only). 

86 """ 

87 

88 def __getitem__(self, k: KeyTypeVar) -> ItemTypeVar: 

89 return self._dict[k] 

90 

91 def __len__(self) -> int: 

92 return len(self._dict) 

93 

94 def __iter__(self) -> Iterator[KeyTypeVar]: 

95 return iter(self._dict) 

96 

97 def __contains__(self, k: Any) -> bool: 

98 return k in self._dict 

99 

100 def __setitem__( 

101 self, k: KeyTypeVar, x: ItemTypeVar, at: Any = None, label: str = "setitem", setHistory: bool = True 

102 ) -> None: 

103 if self._config._frozen: 

104 msg = f"Cannot modify a frozen Config. Attempting to set item at key {k!r} to value {x}" 

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

106 

107 # validate keytype 

108 k = _autocast(k, self._field.keytype) 

109 if type(k) != self._field.keytype: 

110 msg = f"Key {k!r} is of type {_typeStr(k)}, expected type {_typeStr(self._field.keytype)}" 

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

112 

113 # validate itemtype 

114 x = _autocast(x, self._field.itemtype) 

115 if self._field.itemtype is None: 

116 if type(x) not in self._field.supportedTypes and x is not None: 

117 msg = f"Value {x} at key {k!r} is of invalid type {_typeStr(x)}" 

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

119 else: 

120 if type(x) != self._field.itemtype and x is not None: 

121 msg = "Value {} at key {!r} is of incorrect type {}. Expected type {}".format( 

122 x, 

123 k, 

124 _typeStr(x), 

125 _typeStr(self._field.itemtype), 

126 ) 

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

128 

129 # validate item using itemcheck 

130 if self._field.itemCheck is not None and not self._field.itemCheck(x): 

131 msg = f"Item at key {k!r} is not a valid value: {x}" 

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

133 

134 if at is None: 

135 at = getCallStack() 

136 

137 self._dict[k] = x 

138 if setHistory: 

139 self._history.append((dict(self._dict), at, label)) 

140 

141 def __delitem__( 

142 self, k: KeyTypeVar, at: Any = None, label: str = "delitem", setHistory: bool = True 

143 ) -> None: 

144 if self._config._frozen: 

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

146 

147 del self._dict[k] 

148 if setHistory: 

149 if at is None: 

150 at = getCallStack() 

151 self._history.append((dict(self._dict), at, label)) 

152 

153 def __repr__(self): 

154 return repr(self._dict) 

155 

156 def __str__(self): 

157 return str(self._dict) 

158 

159 def __setattr__(self, attr, value, at=None, label="assignment"): 

160 if hasattr(getattr(self.__class__, attr, None), "__set__"): 

161 # This allows properties to work. 

162 object.__setattr__(self, attr, value) 

163 elif attr in self.__dict__ or attr in ["_field", "_config_", "_history", "_dict", "__doc__"]: 

164 # This allows specific private attributes to work. 

165 object.__setattr__(self, attr, value) 

166 else: 

167 # We throw everything else. 

168 msg = f"{_typeStr(self._field)} has no attribute {attr}" 

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

170 

171 def __reduce__(self): 

172 raise UnexpectedProxyUsageError( 

173 f"Proxy container for config field {self._field.name} cannot " 

174 "be pickled; it should be converted to a built-in container before " 

175 "being assigned to other objects or variables." 

176 ) 

177 

178 

179class DictField(Field[Dict[KeyTypeVar, ItemTypeVar]], Generic[KeyTypeVar, ItemTypeVar]): 

180 """A configuration field (`~lsst.pex.config.Field` subclass) that maps keys 

181 and values. 

182 

183 The types of both items and keys are restricted to these builtin types: 

184 `int`, `float`, `complex`, `bool`, and `str`). All keys share the same type 

185 and all values share the same type. Keys can have a different type from 

186 values. 

187 

188 Parameters 

189 ---------- 

190 doc : `str` 

191 A documentation string that describes the configuration field. 

192 keytype : {`int`, `float`, `complex`, `bool`, `str`}, optional 

193 The type of the mapping keys. All keys must have this type. Optional 

194 if keytype and itemtype are supplied as typing arguments to the class. 

195 itemtype : {`int`, `float`, `complex`, `bool`, `str`}, optional 

196 Type of the mapping values. Optional if keytype and itemtype are 

197 supplied as typing arguments to the class. 

198 default : `dict`, optional 

199 The default mapping. 

200 optional : `bool`, optional 

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

202 dictCheck : callable 

203 A function that validates the dictionary as a whole. 

204 itemCheck : callable 

205 A function that validates individual mapping values. 

206 deprecated : None or `str`, optional 

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

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

209 

210 See Also 

211 -------- 

212 ChoiceField 

213 ConfigChoiceField 

214 ConfigDictField 

215 ConfigField 

216 ConfigurableField 

217 Field 

218 ListField 

219 RangeField 

220 RegistryField 

221 

222 Examples 

223 -------- 

224 This field maps has `str` keys and `int` values: 

225 

226 >>> from lsst.pex.config import Config, DictField 

227 >>> class MyConfig(Config): 

228 ... field = DictField( 

229 ... doc="Example string-to-int mapping field.", 

230 ... keytype=str, itemtype=int, 

231 ... default={}) 

232 ... 

233 >>> config = MyConfig() 

234 >>> config.field['myKey'] = 42 

235 >>> print(config.field) 

236 {'myKey': 42} 

237 """ 

238 

239 DictClass: type[Dict] = Dict 

240 

241 @staticmethod 

242 def _parseTypingArgs( 

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

244 ) -> Mapping[str, Any]: 

245 if len(params) != 2: 

246 raise ValueError("Only tuples of types that are length 2 are supported") 

247 resultParams = [] 

248 for typ in params: 

249 if isinstance(typ, str): 

250 _typ = ForwardRef(typ) 

251 # type ignore below because typeshed seems to be wrong. It 

252 # indicates there are only 2 args, as it was in python 3.8, but 

253 # 3.9+ takes 3 args. Attempt in old style and new style to 

254 # work with both. 

255 try: 

256 result = _typ._evaluate(globals(), locals(), set()) # type: ignore 

257 except TypeError: 

258 # python 3.8 path 

259 result = _typ._evaluate(globals(), locals()) 

260 if result is None: 

261 raise ValueError("Could not deduce type from input") 

262 typ = cast(type, result) 

263 resultParams.append(typ) 

264 keyType, itemType = resultParams 

265 results = dict(kwds) 

266 if (supplied := kwds.get("keytype")) and supplied != keyType: 

267 raise ValueError("Conflicting definition for keytype") 

268 else: 

269 results["keytype"] = keyType 

270 if (supplied := kwds.get("itemtype")) and supplied != itemType: 

271 raise ValueError("Conflicting definition for itemtype") 

272 else: 

273 results["itemtype"] = itemType 

274 return results 

275 

276 def __init__( 

277 self, 

278 doc, 

279 keytype=None, 

280 itemtype=None, 

281 default=None, 

282 optional=False, 

283 dictCheck=None, 

284 itemCheck=None, 

285 deprecated=None, 

286 ): 

287 source = getStackFrame() 

288 self._setup( 

289 doc=doc, 

290 dtype=Dict, 

291 default=default, 

292 check=None, 

293 optional=optional, 

294 source=source, 

295 deprecated=deprecated, 

296 ) 

297 if keytype is None: 297 ↛ 298line 297 didn't jump to line 298, because the condition on line 297 was never true

298 raise ValueError( 

299 "keytype must either be supplied as an argument or as a type argument to the class" 

300 ) 

301 if keytype not in self.supportedTypes: 301 ↛ 302line 301 didn't jump to line 302, because the condition on line 301 was never true

302 raise ValueError("'keytype' %s is not a supported type" % _typeStr(keytype)) 

303 elif itemtype is not None and itemtype not in self.supportedTypes: 303 ↛ 304line 303 didn't jump to line 304, because the condition on line 303 was never true

304 raise ValueError("'itemtype' %s is not a supported type" % _typeStr(itemtype)) 

305 if dictCheck is not None and not hasattr(dictCheck, "__call__"): 305 ↛ 306line 305 didn't jump to line 306, because the condition on line 305 was never true

306 raise ValueError("'dictCheck' must be callable") 

307 if itemCheck is not None and not hasattr(itemCheck, "__call__"): 307 ↛ 308line 307 didn't jump to line 308, because the condition on line 307 was never true

308 raise ValueError("'itemCheck' must be callable") 

309 

310 self.keytype = keytype 

311 self.itemtype = itemtype 

312 self.dictCheck = dictCheck 

313 self.itemCheck = itemCheck 

314 

315 def validate(self, instance): 

316 """Validate the field's value (for internal use only). 

317 

318 Parameters 

319 ---------- 

320 instance : `lsst.pex.config.Config` 

321 The configuration that contains this field. 

322 

323 Returns 

324 ------- 

325 isValid : `bool` 

326 `True` is returned if the field passes validation criteria (see 

327 *Notes*). Otherwise `False`. 

328 

329 Notes 

330 ----- 

331 This method validates values according to the following criteria: 

332 

333 - A non-optional field is not `None`. 

334 - If a value is not `None`, is must pass the `ConfigField.dictCheck` 

335 user callback functon. 

336 

337 Individual item checks by the `ConfigField.itemCheck` user callback 

338 function are done immediately when the value is set on a key. Those 

339 checks are not repeated by this method. 

340 """ 

341 Field.validate(self, instance) 

342 value = self.__get__(instance) 

343 if value is not None and self.dictCheck is not None and not self.dictCheck(value): 

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

345 raise FieldValidationError(self, instance, msg) 

346 

347 def __set__( 

348 self, 

349 instance: Config, 

350 value: Mapping[KeyTypeVar, ItemTypeVar] | None, 

351 at: Any = None, 

352 label: str = "assignment", 

353 ) -> None: 

354 if instance._frozen: 

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

356 raise FieldValidationError(self, instance, msg) 

357 

358 if at is None: 

359 at = getCallStack() 

360 if value is not None: 

361 value = self.DictClass(instance, self, value, at=at, label=label) 

362 else: 

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

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

365 

366 instance._storage[self.name] = value 

367 

368 def toDict(self, instance): 

369 """Convert this field's key-value pairs into a regular `dict`. 

370 

371 Parameters 

372 ---------- 

373 instance : `lsst.pex.config.Config` 

374 The configuration that contains this field. 

375 

376 Returns 

377 ------- 

378 result : `dict` or `None` 

379 If this field has a value of `None`, then this method returns 

380 `None`. Otherwise, this method returns the field's value as a 

381 regular Python `dict`. 

382 """ 

383 value = self.__get__(instance) 

384 return dict(value) if value is not None else None 

385 

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

387 """Compare two fields for equality. 

388 

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

390 

391 Parameters 

392 ---------- 

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

394 Left-hand side config instance to compare. 

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

396 Right-hand side config instance to compare. 

397 shortcut : `bool` 

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

399 rtol : `float` 

400 Relative tolerance for floating point comparisons. 

401 atol : `float` 

402 Absolute tolerance for floating point comparisons. 

403 output : callable 

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

405 report inequalities. 

406 

407 Returns 

408 ------- 

409 isEqual : bool 

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

411 

412 Notes 

413 ----- 

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

415 """ 

416 d1 = getattr(instance1, self.name) 

417 d2 = getattr(instance2, self.name) 

418 name = getComparisonName( 

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

420 ) 

421 if not compareScalars("isnone for %s" % name, d1 is None, d2 is None, output=output): 

422 return False 

423 if d1 is None and d2 is None: 

424 return True 

425 if not compareScalars("keys for %s" % name, set(d1.keys()), set(d2.keys()), output=output): 

426 return False 

427 equal = True 

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

429 v2 = d2[k] 

430 result = compareScalars( 

431 f"{name}[{k!r}]", v1, v2, dtype=self.itemtype, rtol=rtol, atol=atol, output=output 

432 ) 

433 if not result and shortcut: 

434 return False 

435 equal = equal and result 

436 return equal