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

128 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# (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/>. 

27from __future__ import annotations 

28 

29__all__ = ["ConfigDictField"] 

30 

31from collections.abc import Mapping 

32 

33from .callStack import StackFrame, getCallStack, getStackFrame 

34from .comparison import compareConfigs, compareScalars, getComparisonName 

35from .config import Config, FieldValidationError, _autocast, _joinNamePath, _typeStr 

36from .dictField import Dict, DictField 

37 

38 

39class ConfigDict(Dict[str, Config]): 

40 """Internal representation of a dictionary of configuration classes. 

41 

42 Much like `Dict`, `ConfigDict` is a custom `MutableMapper` which tracks 

43 the history of changes to any of its items. 

44 

45 Parameters 

46 ---------- 

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

48 Config to use. 

49 field : `~lsst.pex.config.ConfigDictField` 

50 Field to use. 

51 value : `~typing.Any` 

52 Value to store in dict. 

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

54 Stack frame for history recording. Will be calculated if `None`. 

55 label : `str`, optional 

56 Label to use for history recording. 

57 setHistory : `bool`, optional 

58 Whether to append to the history record. 

59 """ 

60 

61 def __init__( 

62 self, 

63 config: Config, 

64 field: ConfigDictField, 

65 value: Mapping[str, Config] | None, 

66 *, 

67 at: list[StackFrame] | None, 

68 label: str, 

69 setHistory: bool = True, 

70 ): 

71 Dict.__init__(self, config, field, value, at=at, label=label, setHistory=False) 

72 if setHistory: 

73 self.history.append(("Dict initialized", at, label)) 

74 

75 def _copy(self, config: Config) -> Dict: 

76 return type(self)( 

77 config, 

78 self._field, 

79 {k: v.copy() for k, v in self._dict.items()}, 

80 at=None, 

81 label="copy", 

82 setHistory=False, 

83 ) 

84 

85 def __setitem__(self, k, x, at=None, label="setitem", setHistory=True): 

86 if self._config._frozen: 

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

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

89 

90 # validate keytype 

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

92 if type(k) is not self._field.keytype: 

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

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

95 

96 # validate itemtype 

97 dtype = self._field.itemtype 

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

99 msg = ( 

100 f"Value {x} at key {k!r} is of incorrect type {_typeStr(x)}. " 

101 f"Expected type {_typeStr(self._field.itemtype)}" 

102 ) 

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

104 

105 # validate key using keycheck 

106 if self._field.keyCheck is not None and not self._field.keyCheck(k): 

107 msg = f"Key {k!r} is not a valid key" 

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

109 

110 if at is None: 

111 at = getCallStack() 

112 name = _joinNamePath(self._config._name, self._field.name, k) 

113 oldValue = self._dict.get(k, None) 

114 if oldValue is None: 

115 if x == dtype: 

116 self._dict[k] = dtype(__name=name, __at=at, __label=label) 

117 else: 

118 self._dict[k] = dtype(__name=name, __at=at, __label=label, **x._storage) 

119 if setHistory: 

120 self.history.append((f"Added item at key {k}", at, label)) 

121 else: 

122 if x == dtype: 

123 x = dtype() 

124 oldValue.update(__at=at, __label=label, **x._storage) 

125 if setHistory: 

126 self.history.append((f"Modified item at key {k}", at, label)) 

127 

128 def __delitem__(self, k, at=None, label="delitem"): 

129 if at is None: 

130 at = getCallStack() 

131 Dict.__delitem__(self, k, at, label, False) 

132 self.history.append((f"Removed item at key {k}", at, label)) 

133 

134 

135class ConfigDictField(DictField): 

136 """A configuration field (`~lsst.pex.config.Field` subclass) that is a 

137 mapping of keys to `~lsst.pex.config.Config` instances. 

138 

139 ``ConfigDictField`` behaves like `DictField` except that the 

140 ``itemtype`` must be a `~lsst.pex.config.Config` subclass. 

141 

142 Parameters 

143 ---------- 

144 doc : `str` 

145 A description of the configuration field. 

146 keytype : {`int`, `float`, `complex`, `bool`, `str`} 

147 The type of the mapping keys. All keys must have this type. 

148 itemtype : `lsst.pex.config.Config`-type 

149 The type of the values in the mapping. This must be 

150 `~lsst.pex.config.Config` or a subclass. 

151 default : `typing.Any`, optional 

152 Unknown. 

153 default : ``itemtype``-dtype, optional 

154 Default value of this field. 

155 optional : `bool`, optional 

156 If `True`, this configuration `~lsst.pex.config.Field` is *optional*. 

157 Default is `True`. 

158 dictCheck : `~collections.abc.Callable` or `None`, optional 

159 Callable to check a dict. 

160 keyCheck : `~collections.abc.Callable` or `None`, optional 

161 Callable to check a key. 

162 itemCheck : `~collections.abc.Callable` or `None`, optional 

163 Callable to check an item. 

164 deprecated : `None` or `str`, optional 

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

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

167 

168 Raises 

169 ------ 

170 ValueError 

171 Raised if the inputs are invalid: 

172 

173 - ``keytype`` or ``itemtype`` arguments are not supported types 

174 (members of `Field.supportedTypes`. 

175 - ``dictCheck``, ``keyCheck`` or ``itemCheck`` is not a callable 

176 function. 

177 

178 See Also 

179 -------- 

180 ChoiceField 

181 ConfigChoiceField 

182 ConfigField 

183 ConfigurableField 

184 DictField 

185 Field 

186 ListField 

187 RangeField 

188 RegistryField 

189 

190 Notes 

191 ----- 

192 You can use ``ConfigDictField`` to create name-to-config mappings. One use 

193 case is for configuring mappings for dataset types in a Butler. In this 

194 case, the dataset type names are arbitrary and user-selected while the 

195 mapping configurations are known and fixed. 

196 """ 

197 

198 DictClass = ConfigDict 

199 

200 def __init__( 

201 self, 

202 doc, 

203 keytype, 

204 itemtype, 

205 default=None, 

206 optional=False, 

207 dictCheck=None, 

208 keyCheck=None, 

209 itemCheck=None, 

210 deprecated=None, 

211 ): 

212 source = getStackFrame() 

213 self._setup( 

214 doc=doc, 

215 dtype=ConfigDict, 

216 default=default, 

217 check=None, 

218 optional=optional, 

219 source=source, 

220 deprecated=deprecated, 

221 ) 

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

223 raise ValueError(f"'keytype' {_typeStr(keytype)} is not a supported type") 

224 elif not issubclass(itemtype, Config): 224 ↛ 225line 224 didn't jump to line 225 because the condition on line 224 was never true

225 raise ValueError(f"'itemtype' {_typeStr(itemtype)} is not a supported type") 

226 

227 check_errors = [] 

228 for name, check in (("dictCheck", dictCheck), ("keyCheck", keyCheck), ("itemCheck", itemCheck)): 

229 if check is not None and not callable(check): 229 ↛ 230line 229 didn't jump to line 230 because the condition on line 229 was never true

230 check_errors.append(name) 

231 if check_errors: 231 ↛ 232line 231 didn't jump to line 232 because the condition on line 231 was never true

232 raise ValueError(f"{', '.join(check_errors)} must be callable") 

233 

234 self.keytype = keytype 

235 self.itemtype = itemtype 

236 self.dictCheck = dictCheck 

237 self.keyCheck = keyCheck 

238 self.itemCheck = itemCheck 

239 

240 def rename(self, instance): 

241 configDict = self.__get__(instance) 

242 if configDict is not None: 

243 for k in configDict: 

244 fullname = _joinNamePath(instance._name, self.name, k) 

245 configDict[k]._rename(fullname) 

246 

247 def validate(self, instance): 

248 """Validate the field. 

249 

250 Parameters 

251 ---------- 

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

253 The config instance that contains this field. 

254 

255 Raises 

256 ------ 

257 lsst.pex.config.FieldValidationError 

258 Raised if validation fails for this field. 

259 

260 Notes 

261 ----- 

262 Individual key checks (``keyCheck``) are applied when each key is added 

263 and are not re-checked by this method. 

264 """ 

265 value = self.__get__(instance) 

266 if value is not None: 

267 for k in value: 

268 item = value[k] 

269 item.validate() 

270 if self.itemCheck is not None and not self.itemCheck(item): 

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

272 raise FieldValidationError(self, instance, msg) 

273 DictField.validate(self, instance) 

274 

275 def toDict(self, instance): 

276 configDict = self.__get__(instance) 

277 if configDict is None: 

278 return None 

279 

280 dict_ = {} 

281 for k in configDict: 

282 dict_[k] = configDict[k].toDict() 

283 

284 return dict_ 

285 

286 def _collectImports(self, instance, imports): 

287 # docstring inherited from Field 

288 configDict = self.__get__(instance) 

289 if configDict is not None: 

290 for v in configDict.values(): 

291 v._collectImports() 

292 imports |= v._imports 

293 

294 def save(self, outfile, instance): 

295 configDict = self.__get__(instance) 

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

297 if configDict is None: 

298 outfile.write(f"{fullname}={configDict!r}\n") 

299 return 

300 

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

302 for v in configDict.values(): 

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

304 v._save(outfile) 

305 

306 def freeze(self, instance): 

307 configDict = self.__get__(instance) 

308 if configDict is not None: 

309 for k in configDict: 

310 configDict[k].freeze() 

311 

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

313 """Compare two fields for equality. 

314 

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

316 

317 Parameters 

318 ---------- 

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

320 Left-hand side config instance to compare. 

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

322 Right-hand side config instance to compare. 

323 shortcut : `bool` 

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

325 rtol : `float` 

326 Relative tolerance for floating point comparisons. 

327 atol : `float` 

328 Absolute tolerance for floating point comparisons. 

329 output : `collections.abc.Callable` 

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

331 report inequalities. 

332 

333 Returns 

334 ------- 

335 isEqual : bool 

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

337 

338 Notes 

339 ----- 

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

341 """ 

342 d1 = getattr(instance1, self.name) 

343 d2 = getattr(instance2, self.name) 

344 name = getComparisonName( 

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

346 ) 

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

348 return False 

349 equal = True 

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

351 v2 = d2[k] 

352 result = compareConfigs( 

353 f"{name}[{k!r}]", v1, v2, shortcut=shortcut, rtol=rtol, atol=atol, output=output 

354 ) 

355 if not result and shortcut: 

356 return False 

357 equal = equal and result 

358 return equal