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

172 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2023-06-28 10:15 +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 sys 

34import weakref 

35from typing import Any, ForwardRef, Generic, Iterator, Mapping, Type, TypeVar, Union, 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 

53if int(sys.version_info.minor) < 9: 53 ↛ 54line 53 didn't jump to line 54, because the condition on line 53 was never true

54 _bases = (collections.abc.MutableMapping, Generic[KeyTypeVar, ItemTypeVar]) 

55else: 

56 _bases = (collections.abc.MutableMapping[KeyTypeVar, ItemTypeVar],) 

57 

58 

59class Dict(*_bases): 

60 """An internal mapping container. 

61 

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

63 """ 

64 

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

66 self._field = field 

67 self._config_ = weakref.ref(config) 

68 self._dict = {} 

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

70 self.__doc__ = field.doc 

71 if value is not None: 

72 try: 

73 for k in value: 

74 # do not set history per-item 

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

76 except TypeError: 

77 msg = "Value %s is of incorrect type %s. Mapping type expected." % (value, _typeStr(value)) 

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

79 if setHistory: 

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

81 

82 @property 

83 def _config(self) -> Config: 

84 # Config Fields should never outlive their config class instance 

85 # assert that as such here 

86 value = self._config_() 

87 assert value is not None 

88 return value 

89 

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

91 """History (read-only). 

92 """ 

93 

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

95 return self._dict[k] 

96 

97 def __len__(self) -> int: 

98 return len(self._dict) 

99 

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

101 return iter(self._dict) 

102 

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

104 return k in self._dict 

105 

106 def __setitem__( 

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

108 ) -> None: 

109 if self._config._frozen: 

110 msg = "Cannot modify a frozen Config. Attempting to set item at key %r to value %s" % (k, x) 

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

112 

113 # validate keytype 

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

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

116 msg = "Key %r is of type %s, expected type %s" % (k, _typeStr(k), _typeStr(self._field.keytype)) 

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

118 

119 # validate itemtype 

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

121 if self._field.itemtype is None: 

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

123 msg = "Value %s at key %r is of invalid type %s" % (x, k, _typeStr(x)) 

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

125 else: 

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

127 msg = "Value %s at key %r is of incorrect type %s. Expected type %s" % ( 

128 x, 

129 k, 

130 _typeStr(x), 

131 _typeStr(self._field.itemtype), 

132 ) 

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

134 

135 # validate item using itemcheck 

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

137 msg = "Item at key %r is not a valid value: %s" % (k, x) 

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

139 

140 if at is None: 

141 at = getCallStack() 

142 

143 self._dict[k] = x 

144 if setHistory: 

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

146 

147 def __delitem__( 

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

149 ) -> None: 

150 if self._config._frozen: 

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

152 

153 del self._dict[k] 

154 if setHistory: 

155 if at is None: 

156 at = getCallStack() 

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

158 

159 def __repr__(self): 

160 return repr(self._dict) 

161 

162 def __str__(self): 

163 return str(self._dict) 

164 

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

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

167 # This allows properties to work. 

168 object.__setattr__(self, attr, value) 

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

170 # This allows specific private attributes to work. 

171 object.__setattr__(self, attr, value) 

172 else: 

173 # We throw everything else. 

174 msg = "%s has no attribute %s" % (_typeStr(self._field), attr) 

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

176 

177 def __reduce__(self): 

178 raise UnexpectedProxyUsageError( 

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

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

181 "being assigned to other objects or variables." 

182 ) 

183 

184 

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

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

187 and values. 

188 

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

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

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

192 values. 

193 

194 Parameters 

195 ---------- 

196 doc : `str` 

197 A documentation string that describes the configuration field. 

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

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

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

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

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

203 supplied as typing arguments to the class. 

204 default : `dict`, optional 

205 The default mapping. 

206 optional : `bool`, optional 

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

208 dictCheck : callable 

209 A function that validates the dictionary as a whole. 

210 itemCheck : callable 

211 A function that validates individual mapping values. 

212 deprecated : None or `str`, optional 

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

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

215 

216 See also 

217 -------- 

218 ChoiceField 

219 ConfigChoiceField 

220 ConfigDictField 

221 ConfigField 

222 ConfigurableField 

223 Field 

224 ListField 

225 RangeField 

226 RegistryField 

227 

228 Examples 

229 -------- 

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

231 

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

233 >>> class MyConfig(Config): 

234 ... field = DictField( 

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

236 ... keytype=str, itemtype=int, 

237 ... default={}) 

238 ... 

239 >>> config = MyConfig() 

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

241 >>> print(config.field) 

242 {'myKey': 42} 

243 """ 

244 

245 DictClass: Type[Dict] = Dict 

246 

247 @staticmethod 

248 def _parseTypingArgs( 

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

250 ) -> Mapping[str, Any]: 

251 if len(params) != 2: 

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

253 resultParams = [] 

254 for typ in params: 

255 if isinstance(typ, str): 

256 _typ = ForwardRef(typ) 

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

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

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

260 # work with both. 

261 try: 

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

263 except TypeError: 

264 # python 3.8 path 

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

266 if result is None: 

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

268 typ = cast(type, result) 

269 resultParams.append(typ) 

270 keyType, itemType = resultParams 

271 results = dict(kwds) 

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

273 raise ValueError("Conflicting definition for keytype") 

274 else: 

275 results["keytype"] = keyType 

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

277 raise ValueError("Conflicting definition for itemtype") 

278 else: 

279 results["itemtype"] = itemType 

280 return results 

281 

282 def __init__( 

283 self, 

284 doc, 

285 keytype=None, 

286 itemtype=None, 

287 default=None, 

288 optional=False, 

289 dictCheck=None, 

290 itemCheck=None, 

291 deprecated=None, 

292 ): 

293 source = getStackFrame() 

294 self._setup( 

295 doc=doc, 

296 dtype=Dict, 

297 default=default, 

298 check=None, 

299 optional=optional, 

300 source=source, 

301 deprecated=deprecated, 

302 ) 

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

304 raise ValueError( 

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

306 ) 

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

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

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

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

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

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

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

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

315 

316 self.keytype = keytype 

317 self.itemtype = itemtype 

318 self.dictCheck = dictCheck 

319 self.itemCheck = itemCheck 

320 

321 def validate(self, instance): 

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

323 

324 Parameters 

325 ---------- 

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

327 The configuration that contains this field. 

328 

329 Returns 

330 ------- 

331 isValid : `bool` 

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

333 *Notes*). Otherwise `False`. 

334 

335 Notes 

336 ----- 

337 This method validates values according to the following criteria: 

338 

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

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

341 user callback functon. 

342 

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

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

345 checks are not repeated by this method. 

346 """ 

347 Field.validate(self, instance) 

348 value = self.__get__(instance) 

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

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

351 raise FieldValidationError(self, instance, msg) 

352 

353 def __set__( 

354 self, 

355 instance: Config, 

356 value: Union[Mapping[KeyTypeVar, ItemTypeVar], None], 

357 at: Any = None, 

358 label: str = "assignment", 

359 ) -> None: 

360 if instance._frozen: 

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

362 raise FieldValidationError(self, instance, msg) 

363 

364 if at is None: 

365 at = getCallStack() 

366 if value is not None: 

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

368 else: 

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

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

371 

372 instance._storage[self.name] = value 

373 

374 def toDict(self, instance): 

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

376 

377 Parameters 

378 ---------- 

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

380 The configuration that contains this field. 

381 

382 Returns 

383 ------- 

384 result : `dict` or `None` 

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

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

387 regular Python `dict`. 

388 """ 

389 value = self.__get__(instance) 

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

391 

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

393 """Compare two fields for equality. 

394 

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

396 

397 Parameters 

398 ---------- 

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

400 Left-hand side config instance to compare. 

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

402 Right-hand side config instance to compare. 

403 shortcut : `bool` 

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

405 rtol : `float` 

406 Relative tolerance for floating point comparisons. 

407 atol : `float` 

408 Absolute tolerance for floating point comparisons. 

409 output : callable 

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

411 report inequalities. 

412 

413 Returns 

414 ------- 

415 isEqual : bool 

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

417 

418 Notes 

419 ----- 

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

421 """ 

422 d1 = getattr(instance1, self.name) 

423 d2 = getattr(instance2, self.name) 

424 name = getComparisonName( 

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

426 ) 

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

428 return False 

429 if d1 is None and d2 is None: 

430 return True 

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

432 return False 

433 equal = True 

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

435 v2 = d2[k] 

436 result = compareScalars( 

437 "%s[%r]" % (name, k), v1, v2, dtype=self.itemtype, rtol=rtol, atol=atol, output=output 

438 ) 

439 if not result and shortcut: 

440 return False 

441 equal = equal and result 

442 return equal