Coverage for pyTooling/Configuration/JSON.py: 96%

197 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-22 21:29 +0000

1# ==================================================================================================================== # 

2# _____ _ _ ____ __ _ _ _ # 

3# _ __ _ |_ _|__ ___ | (_)_ __ __ _ / ___|___ _ __ / _(_) __ _ _ _ _ __ __ _| |_(_) ___ _ __ # 

4# | '_ \| | | || |/ _ \ / _ \| | | '_ \ / _` || | / _ \| '_ \| |_| |/ _` | | | | '__/ _` | __| |/ _ \| '_ \ # 

5# | |_) | |_| || | (_) | (_) | | | | | | (_| || |__| (_) | | | | _| | (_| | |_| | | | (_| | |_| | (_) | | | | # 

6# | .__/ \__, ||_|\___/ \___/|_|_|_| |_|\__, (_)____\___/|_| |_|_| |_|\__, |\__,_|_| \__,_|\__|_|\___/|_| |_| # 

7# |_| |___/ |___/ |___/ # 

8# ==================================================================================================================== # 

9# Authors: # 

10# Patrick Lehmann # 

11# # 

12# License: # 

13# ==================================================================================================================== # 

14# Copyright 2021-2026 Patrick Lehmann - Bötzingen, Germany # 

15# # 

16# Licensed under the Apache License, Version 2.0 (the "License"); # 

17# you may not use this file except in compliance with the License. # 

18# You may obtain a copy of the License at # 

19# # 

20# http://www.apache.org/licenses/LICENSE-2.0 # 

21# # 

22# Unless required by applicable law or agreed to in writing, software # 

23# distributed under the License is distributed on an "AS IS" BASIS, # 

24# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # 

25# See the License for the specific language governing permissions and # 

26# limitations under the License. # 

27# # 

28# SPDX-License-Identifier: Apache-2.0 # 

29# ==================================================================================================================== # 

30# 

31""" 

32Configuration reader for JSON files. 

33 

34.. hint:: 

35 

36 See :ref:`high-level help <CONFIG/FileFormat/JSON>` for explanations and usage examples. 

37""" 

38from __future__ import annotations 

39 

40from json import load 

41from pathlib import Path 

42from typing import Any, Union, Iterator as typing_Iterator, Self 

43 

44from pyTooling.Common import getFullyQualifiedName 

45from pyTooling.Decorators import export, InheritDocString 

46from pyTooling.MetaClasses import ExtendedType 

47from pyTooling.Configuration import ConfigurationException, KeyT, NodeT, ValueT 

48from pyTooling.Configuration import InterpolationException, KeyNotFoundException, PathExpressionException 

49from pyTooling.Configuration import UnsupportedValueTypeException 

50from pyTooling.Configuration import Node as Abstract_Node 

51from pyTooling.Configuration import Dictionary as Abstract_Dict 

52from pyTooling.Configuration import Sequence as Abstract_Seq 

53from pyTooling.Configuration import Configuration as Abstract_Configuration 

54 

55 

56@export 

57class Node(Abstract_Node): 

58 """ 

59 Node in a JSON configuration data structure. 

60 """ 

61 

62 _jsonNode: Union[dict[str, Any], list[Any]] #: Reference to the associated JSON node. 

63 _cache: dict[str, ValueT] #: Cache of already converted sub-nodes and values, by key. 

64 _key: KeyT #: Key of this node. 

65 _length: int #: Number of sub-elements. 

66 

67 def __init__( 

68 self, 

69 root: Configuration, 

70 parent: NodeT, 

71 key: KeyT, 

72 jsonNode: Union[dict[str, Any], list[Any]] 

73 ) -> None: 

74 """ 

75 Initializes a JSON node. 

76 

77 :param root: Reference to the root node. 

78 :param parent: Reference to the parent node. 

79 :param key: Key of the node within its parent. 

80 :param jsonNode: Reference to the JSON node. 

81 """ 

82 Abstract_Node.__init__(self, root, parent) 

83 

84 self._jsonNode = jsonNode 

85 self._cache = {} 

86 self._key = key 

87 self._length = len(jsonNode) 

88 

89 @InheritDocString(Abstract_Node) 

90 def __len__(self) -> int: 

91 return self._length 

92 

93 @InheritDocString(Abstract_Node) 

94 def __getitem__(self, key: KeyT) -> ValueT: 

95 return self._GetNodeOrValue(str(key)) 

96 

97 @property 

98 def Key(self) -> KeyT: 

99 """ 

100 Property to access the node's key. 

101 

102 :returns: Key of the node. 

103 :raises NotImplementedError: If a new key is assigned; renaming a key is not supported by this configuration 

104 implementation. 

105 """ 

106 return self._key 

107 

108 @Key.setter 

109 def Key(self, value: KeyT) -> None: 

110 raise NotImplementedError() 

111 

112 @InheritDocString(Abstract_Node) 

113 def QueryPath(self, query: str) -> ValueT: 

114 path = self._ToPath(query) 

115 return self._GetNodeOrValueByPathExpression(path) 

116 

117 @staticmethod 

118 def _ToPath(query: str) -> list[Union[str, int]]: 

119 """ 

120 Split a path expression into its elements. 

121 

122 :param query: Path expression, with its elements separated by ``:``. 

123 :returns: List of keys and indices. 

124 """ 

125 return query.split(":") 

126 

127 def _LookupKey(self, key: str) -> Any: 

128 """ 

129 Look up a key in the JSON node, trying it as string, integer and float. 

130 

131 :param key: Key or index to look up. 

132 :returns: The raw value as returned by the JSON parser. 

133 :raises KeyNotFoundException: If the key exists neither as string, nor as integer or float. 

134 """ 

135 try: 

136 return self._jsonNode[key] 

137 except (KeyError, TypeError): 

138 pass 

139 

140 for conversion in (int, float): 

141 try: 

142 convertedKey = conversion(key) 

143 except ValueError: 

144 continue 

145 

146 try: 

147 return self._jsonNode[convertedKey] 

148 except (KeyError, IndexError, TypeError): 

149 pass 

150 

151 ex = KeyNotFoundException(f"Key '{key}' not found in node '{self._key}'.") 

152 ex.add_note(self._DescribeKeys()) 

153 raise ex 

154 

155 def _DescribeKeys(self) -> str: 

156 """ 

157 Describe the keys or indices offered by this node, so it can be used as an exception note. 

158 

159 :returns: A one-line description of the node's keys or index range. 

160 """ 

161 if isinstance(self._jsonNode, dict): 

162 if self._length == 0: 

163 return f"Node '{self._key}' is an empty dictionary." 

164 

165 keys = "', '".join(str(key) for key in self._jsonNode) 

166 return f"Available keys: '{keys}'." 

167 else: 

168 if self._length == 0: 

169 return f"Node '{self._key}' is an empty sequence." 

170 

171 return f"Node '{self._key}' is a sequence with indices 0..{self._length - 1}." 

172 

173 def _GetNodeOrValue(self, key: str) -> ValueT: 

174 """ 

175 Return a sub-node or a value by key, converting it on first access. 

176 

177 The converted object is cached, so a second access returns the same node object rather than a new one. 

178 

179 :param key: Key or index to look up. 

180 :returns: A dictionary node, a sequence node, or a scalar value with its variables 

181 resolved. 

182 :raises KeyNotFoundException: If the key doesn't exist in this node. 

183 :raises UnsupportedValueTypeException: If the JSON parser returned a value that is neither a scalar, nor a 

184 node. 

185 """ 

186 try: 

187 value = self._cache[key] 

188 except KeyError: 

189 value = self._LookupKey(key) 

190 

191 if isinstance(value, str): 

192 value = self._ResolveVariables(value) 

193 elif isinstance(value, (int, float)): 

194 value = str(value) 

195 elif isinstance(value, dict): 

196 value = self.DICT_TYPE(self, self, key, value) 

197 elif isinstance(value, list): 

198 value = self.SEQ_TYPE(self, self, key, value) 

199 else: 

200 typeName = getFullyQualifiedName(value) 

201 ex = UnsupportedValueTypeException(f"Unsupported type '{typeName}' for key '{key}' in node '{self._key}'.") 

202 ex.add_note(f"The JSON parser returned a value that is neither a scalar (str, int, float), nor a dict or list.") 

203 raise ex 

204 

205 self._cache[key] = value 

206 

207 return value 

208 

209 def _ResolveVariables(self, value: str) -> str: 

210 """ 

211 Resolve the ``${...}`` variables inside a value. 

212 

213 A variable references another node by a path expression, so a value can be composed from other values of the 

214 same configuration. 

215 

216 :param value: The raw value, possibly containing variables. 

217 :returns: The value with every variable replaced by what it references. 

218 :raises InterpolationException: If a variable is malformed - a dangling ``$`` at the end of the value, or a 

219 missing closing ``}`` for a ``${`` at some position. |br| 

220 Use ``$$`` to escape a literal dollar sign. 

221 :raises KeyNotFoundException: If a referenced key doesn't exist. 

222 """ 

223 if value == "": 223 ↛ 224line 223 didn't jump to line 224 because the condition on line 223 was never true

224 return "" 

225 elif "$" not in value: 

226 return value 

227 

228 rawValue = value 

229 result = "" 

230 

231 while (len(rawValue) > 0): 

232# print(f"_ResolveVariables: LOOP rawValue='{rawValue}'") 

233 beginPos = rawValue.find("$") 

234 if beginPos < 0: 

235 result += rawValue 

236 rawValue = "" 

237 else: 

238 result += rawValue[:beginPos] 

239 if beginPos + 1 >= len(rawValue): 

240 ex = InterpolationException(f"Dangling '$' at the end of value '{value}'.") 

241 ex.add_note(f"Use '$$' to escape a literal dollar sign.") 

242 raise ex 

243 elif rawValue[beginPos + 1] == "$": 243 ↛ 244line 243 didn't jump to line 244 because the condition on line 243 was never true

244 result += "$" 

245 rawValue = rawValue[1:] 

246 elif rawValue[beginPos + 1] == "{": 246 ↛ 231line 246 didn't jump to line 231 because the condition on line 246 was always true

247 endPos = rawValue.find("}", beginPos) 

248 nextPos = rawValue.rfind("$", beginPos, endPos) 

249 if endPos < 0: 

250 ex = InterpolationException(f"Unclosed variable reference in value '{value}'.") 

251 ex.add_note(f"Missing closing '}}' for the '${{' at position {beginPos}.") 

252 raise ex 

253 if (nextPos > 0) and (nextPos < endPos): # an embedded $-sign 

254 path = rawValue[nextPos+2:endPos] 

255# print(f"_ResolveVariables: path='{path}'") 

256 innervalue = self._GetValueByPathExpression(self._ToPath(path)) 

257# print(f"_ResolveVariables: innervalue='{innervalue}'") 

258 rawValue = rawValue[beginPos:nextPos] + str(innervalue) + rawValue[endPos + 1:] 

259# print(f"_ResolveVariables: new rawValue='{rawValue}'") 

260 else: 

261 path = rawValue[beginPos+2:endPos] 

262 rawValue = rawValue[endPos+1:] 

263 result += str(self._GetValueByPathExpression(self._ToPath(path))) 

264 

265 return result 

266 

267 def _GetValueByPathExpression(self, path: list[KeyT]) -> ValueT: 

268 """ 

269 Return the value the given path refers to. 

270 

271 :param path: Path elements, where ``..`` selects the parent node. 

272 :returns: The scalar value at that path. 

273 :raises KeyNotFoundException: If a path element doesn't exist. 

274 :raises PathExpressionException: If the path resolves to a node instead of a value. Extend the path expression 

275 to address a scalar value. 

276 """ 

277 node = self 

278 for p in path: 

279 if p == "..": 

280 node = node._parent 

281 else: 

282 node = node._GetNodeOrValue(p) 

283 

284 if isinstance(node, Dictionary): 

285 pathExpression = ":".join(str(element) for element in path) 

286 ex = PathExpressionException(f"Path expression '{pathExpression}' resolves to a dictionary, not to a value.") 

287 ex.add_note(f"Element '{p}' is a dictionary. Extend the path expression to address a scalar value.") 

288 raise ex 

289 

290 return node 

291 

292 def _GetNodeOrValueByPathExpression(self, path: list[KeyT]) -> ValueT: 

293 """ 

294 Return the node or value the given path refers to. 

295 

296 :param path: Path elements, where ``..`` selects the parent node. 

297 :returns: A node or a scalar value at that path. 

298 :raises KeyNotFoundException: If a path element doesn't exist. 

299 """ 

300 node = self 

301 for p in path: 

302 if p == "..": 302 ↛ 303line 302 didn't jump to line 303 because the condition on line 302 was never true

303 node = node._parent 

304 else: 

305 node = node._GetNodeOrValue(p) 

306 

307 return node 

308 

309 

310@export 

311class Dictionary(Node, Abstract_Dict): 

312 """A dictionary node in a JSON data file.""" 

313 

314 _keys: list[KeyT] #: List of keys in this dictionary. 

315 

316 def __init__( 

317 self, 

318 root: Configuration, 

319 parent: NodeT, 

320 key: KeyT, 

321 jsonNode: dict 

322 ) -> None: 

323 """ 

324 Initializes a JSON dictionary. 

325 

326 :param root: Reference to the root node. 

327 :param parent: Reference to the parent node. 

328 :param key: Key of the node within its parent. 

329 :param jsonNode: Reference to the JSON node. 

330 """ 

331 Node.__init__(self, root, parent, key, jsonNode) 

332 

333 self._keys = [str(k) for k in jsonNode.keys()] 

334 

335 def __contains__(self, key: KeyT) -> bool: 

336 """ 

337 Checks if the key is in this dictionary. 

338 

339 :param key: The key to check. 

340 :returns: ``True``, if the key is in the dictionary. 

341 """ 

342 return key in self._keys 

343 

344 def __iter__(self) -> typing_Iterator[ValueT]: 

345 """ 

346 Returns an iterator to iterate dictionary keys. 

347 

348 :returns: Dictionary key iterator. 

349 """ 

350 

351 class Iterator(metaclass=ExtendedType, slots=True): 

352 """Iterator to iterate dictionary items.""" 

353 

354 _iter: typing_Iterator #: Iterator over the underlying dictionary's keys. 

355 _obj: Dictionary #: The dictionary being iterated. 

356 

357 def __init__(self, obj: Dictionary) -> None: 

358 """ 

359 Initializes an iterator for a JSON dictionary node. 

360 

361 :param obj: JSON dictionary to iterate. 

362 """ 

363 self._iter = iter(obj._keys) 

364 self._obj = obj 

365 

366 def __iter__(self) -> Self: 

367 """ 

368 Return itself to fulfil the iterator protocol. 

369 

370 :returns: Itself. 

371 """ 

372 return self # pragma: no cover 

373 

374 def __next__(self) -> ValueT: 

375 """ 

376 Returns the next item in the dictionary. 

377 

378 :returns: Next item. 

379 """ 

380 key = next(self._iter) 

381 return self._obj[key] 

382 

383 return Iterator(self) 

384 

385 

386@export 

387class Sequence(Node, Abstract_Seq): 

388 """A sequence node (ordered list) in a JSON data file.""" 

389 

390 def __init__( 

391 self, 

392 root: Configuration, 

393 parent: NodeT, 

394 key: KeyT, 

395 jsonNode: list 

396 ) -> None: 

397 """ 

398 Initializes a JSON sequence (list). 

399 

400 :param root: Reference to the root node. 

401 :param parent: Reference to the parent node. 

402 :param key: Key of the node within its parent. 

403 :param jsonNode: Reference to the JSON node. 

404 """ 

405 Node.__init__(self, root, parent, key, jsonNode) 

406 

407 self._length = len(jsonNode) 

408 

409 def __iter__(self) -> typing_Iterator[ValueT]: 

410 """ 

411 Returns an iterator to iterate items in the sequence of sub-nodes. 

412 

413 :returns: Iterator to iterate items in a sequence. 

414 """ 

415 

416 class Iterator(metaclass=ExtendedType, slots=True): 

417 """Iterator to iterate sequence items.""" 

418 

419 _i: int #: internal iterator position 

420 _obj: Sequence #: Sequence object to iterate 

421 

422 def __init__(self, obj: Sequence) -> None: 

423 """ 

424 Initializes an iterator for a JSON sequence node. 

425 

426 :param obj: YAML sequence to iterate. 

427 """ 

428 self._i = 0 

429 self._obj = obj 

430 

431 def __iter__(self) -> Self: 

432 """ 

433 Return itself to fulfil the iterator protocol. 

434 

435 :returns: Itself. 

436 """ 

437 return self # pragma: no cover 

438 

439 def __next__(self) -> ValueT: 

440 """ 

441 Returns the next item in the sequence. 

442 

443 :returns: Next item. 

444 :raises StopIteration: If end of sequence is reached. 

445 """ 

446 if self._i >= len(self._obj): 

447 raise StopIteration 

448 

449 result = self._obj[str(self._i)] 

450 self._i += 1 

451 return result 

452 

453 return Iterator(self) 

454 

455 

456setattr(Node, "DICT_TYPE", Dictionary) 

457setattr(Node, "SEQ_TYPE", Sequence) 

458 

459 

460@export 

461class Configuration(Dictionary, Abstract_Configuration): 

462 """A configuration read from a JSON file.""" 

463 

464 _jsonConfig: dict #: The parsed JSON document this configuration is based on. 

465 

466 def __init__(self, configFile: Path) -> None: 

467 """ 

468 Initializes a configuration instance that reads a JSON file as input. 

469 

470 All sequence items or dictionaries key-value-pairs in the JSON file are accessible via Python's dictionary syntax. 

471 

472 :param configFile: Configuration file to read and parse. 

473 :raises ConfigurationException: If the JSON file doesn't exist or can't be parsed. 

474 """ 

475 if not configFile.exists(): 475 ↛ 476line 475 didn't jump to line 476 because the condition on line 475 was never true

476 raise ConfigurationException(f"JSON configuration file '{configFile}' not found.") from FileNotFoundError(configFile) 

477 

478 with configFile.open("r", encoding="utf-8") as file: 

479 self._jsonConfig = load(file) 

480 

481 Dictionary.__init__(self, self, self, None, self._jsonConfig) 

482 Abstract_Configuration.__init__(self, configFile) 

483 

484 def __getitem__(self, key: str) -> ValueT: 

485 """ 

486 Access a configuration node by key. 

487 

488 :param key: The key to look for. 

489 :returns: A node (sequence or dictionary) or scalar value (int, float, str). 

490 """ 

491 return self._GetNodeOrValue(str(key)) 

492 

493 # 

494 # :param key: Key of the value to write. 

495 # :param value: The new value. 

496 # :raises NotImplementedError: Writing a configuration is not supported by this implementation. 

497 # """ 

498 # raise NotImplementedError()