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

217 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-10-25 12:33 +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 JSONDecodeError, loads 

41from pathlib import Path 

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

43 

44from pyTooling.Common import getFullyQualifiedName 

45from pyTooling.Decorators import export, InheritDocString 

46from pyTooling.MetaClasses import ExtendedType 

47from pyTooling.Configuration import ConfigurationError, KeyT, NodeT, ValueT, InterpolationError, KeyNotFoundError, \ 

48 PathExpressionError, UnsupportedValueTypeError, Node as Abstract_Node, \ 

49 Dictionary as Abstract_Dict, Sequence as Abstract_Seq, \ 

50 Configuration as Abstract_Configuration 

51 

52 

53@export 

54class Node(Abstract_Node): 

55 """ 

56 Node in a JSON configuration data structure. 

57 """ 

58 

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

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

61 _key: KeyT #: Key of this node. 

62 

63 def __init__( 

64 self, 

65 root: Configuration, 

66 parent: Nullable[NodeT], 

67 key: KeyT, 

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

69 ) -> None: 

70 """ 

71 Initializes a JSON node. 

72 

73 :param root: Reference to the root node. 

74 :param parent: Reference to the parent node, or ``None`` for the root node. 

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

76 :param jsonNode: Reference to the JSON node. 

77 """ 

78 Abstract_Node.__init__(self, root, parent) 

79 

80 self._jsonNode = jsonNode 

81 self._cache = {} 

82 self._key = key 

83 

84 @InheritDocString(Abstract_Node) 

85 def __len__(self) -> int: 

86 return len(self._jsonNode) 

87 

88 @InheritDocString(Abstract_Node) 

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

90 return self._GetNodeOrValue(str(key)) 

91 

92 @property 

93 def Key(self) -> KeyT: 

94 """ 

95 Property to access the node's key. 

96 

97 :returns: Key of the node. 

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

99 implementation. 

100 """ 

101 return self._key 

102 

103 @Key.setter 

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

105 raise NotImplementedError("Renaming a key isn't supported by this configuration implementation.") 

106 

107 @InheritDocString(Abstract_Node) 

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

109 path = self._ToPath(query) 

110 return self._GetNodeOrValueByPathExpression(path) 

111 

112 @staticmethod 

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

114 """ 

115 Split a path expression into its elements. 

116 

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

118 :returns: List of keys and indices. 

119 """ 

120 return query.split(":") 

121 

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

123 """ 

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

125 

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

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

128 :raises KeyNotFoundError: If the key exists neither as string, nor as integer or float. 

129 """ 

130 try: 

131 return self._jsonNode[key] 

132 except (KeyError, TypeError): 

133 pass 

134 

135 for conversion in (int, float): 

136 try: 

137 convertedKey = conversion(key) 

138 except ValueError: 

139 continue 

140 

141 try: 

142 return self._jsonNode[convertedKey] 

143 except (KeyError, IndexError, TypeError): 

144 pass 

145 

146 ex = KeyNotFoundError(f"Key '{key}' not found in node '{self._key}'.", key=key) 

147 ex.add_note(self._DescribeKeys()) 

148 raise ex 

149 

150 def _DescribeKeys(self) -> str: 

151 """ 

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

153 

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

155 """ 

156 if isinstance(self._jsonNode, dict): 

157 if len(self._jsonNode) == 0: 

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

159 

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

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

162 else: 

163 if len(self._jsonNode) == 0: 

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

165 

166 return f"Node '{self._key}' is a sequence with indices 0..{len(self._jsonNode) - 1}." 

167 

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

169 """ 

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

171 

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

173 

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

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

176 resolved, or ``None`` for a null value. 

177 :raises KeyNotFoundError: If the key doesn't exist in this node. 

178 :raises UnsupportedValueTypeError: If the JSON parser returned a value that is neither a scalar, nor a 

179 node. 

180 """ 

181 try: 

182 value = self._cache[key] 

183 except KeyError: 

184 value = self._LookupKey(key) 

185 

186 if value is None: 

187 pass 

188 elif isinstance(value, str): 

189 value = self._ResolveVariables(value) 

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

191 value = str(value) 

192 elif isinstance(value, dict): 

193 value = self.DICT_TYPE(self._root, self, key, value) 

194 elif isinstance(value, list): 194 ↛ 197line 194 didn't jump to line 197 because the condition on line 194 was always true

195 value = self.SEQ_TYPE(self._root, self, key, value) 

196 else: 

197 typeName = getFullyQualifiedName(value) 

198 ex = UnsupportedValueTypeError( 

199 f"Unsupported type '{typeName}' for key '{key}' in node '{self._key}'.", 

200 value=value 

201 ) 

202 ex.add_note("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 InterpolationError: 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 KeyNotFoundError: 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 = InterpolationError(f"Dangling '$' at the end of value '{value}'.", value=value, position=beginPos) 

241 ex.add_note("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 = InterpolationError(f"Unclosed variable reference in value '{value}'.", value=value, position=beginPos) 

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

252 raise ex 

253 

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

255 path = rawValue[nextPos+2:endPos] 

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

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

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

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

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

261 else: 

262 path = rawValue[beginPos+2:endPos] 

263 rawValue = rawValue[endPos+1:] 

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

265 

266 return result 

267 

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

269 """ 

270 Return the value the given path refers to. 

271 

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

273 :returns: The scalar value at that path. 

274 :raises KeyNotFoundError: If a path element doesn't exist. 

275 :raises PathExpressionError: If the path resolves to a null value. |br| 

276 A variable can't be replaced by a value the document doesn't state. 

277 :raises PathExpressionError: If the path resolves to a node instead of a value - extend the path expression 

278 to address a scalar value - or if a ``..`` element is applied to the root node, 

279 which has no parent. 

280 """ 

281 node = self 

282 for p in path: 

283 if p == "..": 

284 if node._parent is None: 

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

286 ex = PathExpressionError( 

287 f"Path expression '{pathExpression}' navigates beyond the root node.", 

288 pathExpression=pathExpression 

289 ) 

290 ex.add_note("Element '..' was applied to the root node, which has no parent node.") 

291 raise ex 

292 

293 node = node._parent 

294 else: 

295 node = node._GetNodeOrValue(p) 

296 

297 if node is None: 

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

299 ex = PathExpressionError( 

300 f"Path expression '{pathExpression}' resolves to a null value.", 

301 pathExpression=pathExpression 

302 ) 

303 ex.add_note("A variable can't be replaced by a value the document doesn't state.") 

304 raise ex 

305 elif isinstance(node, Dictionary): 

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

307 ex = PathExpressionError( 

308 f"Path expression '{pathExpression}' resolves to a dictionary, not to a value.", 

309 pathExpression=pathExpression 

310 ) 

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

312 raise ex 

313 

314 return node 

315 

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

317 """ 

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

319 

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

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

322 :raises KeyNotFoundError: If a path element doesn't exist. 

323 :raises PathExpressionError: If a ``..`` element is applied to the root node, which has no parent. 

324 """ 

325 node = self 

326 for p in path: 

327 if p == "..": 

328 if node._parent is None: 328 ↛ 337line 328 didn't jump to line 337 because the condition on line 328 was always true

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

330 ex = PathExpressionError( 

331 f"Path expression '{pathExpression}' navigates beyond the root node.", 

332 pathExpression=pathExpression 

333 ) 

334 ex.add_note("Element '..' was applied to the root node, which has no parent node.") 

335 raise ex 

336 

337 node = node._parent 

338 else: 

339 node = node._GetNodeOrValue(p) 

340 

341 return node 

342 

343 

344@export 

345class Dictionary(Node, Abstract_Dict): 

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

347 

348 

349 def __init__( 

350 self, 

351 root: Configuration, 

352 parent: Nullable[NodeT], 

353 key: KeyT, 

354 jsonNode: dict 

355 ) -> None: 

356 """ 

357 Initializes a JSON dictionary. 

358 

359 :param root: Reference to the root node. 

360 :param parent: Reference to the parent node, or ``None`` for the root node. 

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

362 :param jsonNode: Reference to the JSON node. 

363 """ 

364 keys: list[KeyT] = [str(k) for k in jsonNode.keys()] 

365 

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

367 Abstract_Dict.__init__(self, keys) 

368 

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

370 """ 

371 Returns an iterator to iterate dictionary keys. 

372 

373 :returns: Dictionary key iterator. 

374 """ 

375 

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

377 """Iterator to iterate dictionary items.""" 

378 

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

380 _obj: Dictionary #: The dictionary being iterated. 

381 

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

383 """ 

384 Initializes an iterator for a JSON dictionary node. 

385 

386 :param obj: JSON dictionary to iterate. 

387 """ 

388 self._iter = iter(obj._keys) 

389 self._obj = obj 

390 

391 def __iter__(self) -> Self: 

392 """ 

393 Return itself to fulfil the iterator protocol. 

394 

395 :returns: Itself. 

396 """ 

397 return self # pragma: no cover 

398 

399 def __next__(self) -> ValueT: 

400 """ 

401 Returns the next item in the dictionary. 

402 

403 :returns: Next item. 

404 """ 

405 key = next(self._iter) 

406 return self._obj[key] 

407 

408 return Iterator(self) 

409 

410 

411@export 

412class Sequence(Node, Abstract_Seq): 

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

414 

415 def __init__( 

416 self, 

417 root: Configuration, 

418 parent: NodeT, 

419 key: KeyT, 

420 jsonNode: list 

421 ) -> None: 

422 """ 

423 Initializes a JSON sequence (list). 

424 

425 :param root: Reference to the root node. 

426 :param parent: Reference to the parent node. 

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

428 :param jsonNode: Reference to the JSON node. 

429 """ 

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

431 

432 

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

434 """ 

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

436 

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

438 """ 

439 

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

441 """Iterator to iterate sequence items.""" 

442 

443 _i: int #: internal iterator position 

444 _obj: Sequence #: Sequence object to iterate 

445 

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

447 """ 

448 Initializes an iterator for a JSON sequence node. 

449 

450 :param obj: YAML sequence to iterate. 

451 """ 

452 self._i = 0 

453 self._obj = obj 

454 

455 def __iter__(self) -> Self: 

456 """ 

457 Return itself to fulfil the iterator protocol. 

458 

459 :returns: Itself. 

460 """ 

461 return self # pragma: no cover 

462 

463 def __next__(self) -> ValueT: 

464 """ 

465 Returns the next item in the sequence. 

466 

467 :returns: Next item. 

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

469 """ 

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

471 raise StopIteration 

472 

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

474 self._i += 1 

475 return result 

476 

477 return Iterator(self) 

478 

479 

480setattr(Node, "DICT_TYPE", Dictionary) 

481setattr(Node, "SEQ_TYPE", Sequence) 

482 

483 

484@export 

485class Configuration(Dictionary, Abstract_Configuration): 

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

487 

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

489 

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

491 """ 

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

493 

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

495 

496 A configuration's root **is** a mapping - this class derives from :class:`Dictionary` - so a document 

497 describing anything else is rejected here rather than failing on the first access. A file with **no 

498 content** carries no settings and reads as an empty configuration; :mod:`pyTooling.Configuration.YAML` does 

499 the same, so both formats answer alike. An empty file is not valid JSON, but *the file is empty* is the same 

500 situation in either format and answering differently would be the surprise. 

501 

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

503 :raises ConfigurationError: If the JSON file doesn't exist. 

504 :raises ConfigurationError: If the JSON file can't be parsed. 

505 :raises ConfigurationError: If the JSON file's root isn't a mapping. |br| 

506 An empty file and an explicit ``null`` are the exception: both are *no settings* 

507 and read as an empty configuration. 

508 """ 

509 if not configFile.exists(): 

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

511 

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

513 content = file.read() 

514 

515 if content.strip() == "": 

516 document = {} 

517 else: 

518 try: 

519 document = loads(content) 

520 except JSONDecodeError as ex: 

521 raise ConfigurationError(f"JSON configuration file '{configFile}' can't be parsed.") from ex 

522 

523 # 'null' is JSON's way of describing nothing, which is the same statement an empty file makes. 

524 if document is None: 

525 document = {} 

526 elif not isinstance(document, dict): 

527 ex = ConfigurationError(f"JSON configuration file '{configFile}' doesn't describe a mapping.") 

528 ex.add_note(f"Got type '{getFullyQualifiedName(document)}' at the document's root.") 

529 ex.add_note("A configuration needs a mapping of keys to values at its root.") 

530 raise ex 

531 

532 self._jsonConfig = document 

533 

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

535 Abstract_Configuration.__init__(self, configFile) 

536 

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

538 """ 

539 Access a configuration node by key. 

540 

541 :param key: The key to look for. 

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

543 """ 

544 return self._GetNodeOrValue(str(key)) 

545 

546 # 

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

548 # :param value: The new value. 

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

550 # """ 

551 # raise NotImplementedError()