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

184 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-31 07:24 +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 json import load 

39from pathlib import Path 

40from typing import Any, Dict, List, Union, Iterator as typing_Iterator, Self 

41 

42from pyTooling.Common import getFullyQualifiedName 

43from pyTooling.Decorators import export 

44from pyTooling.MetaClasses import ExtendedType 

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

46from pyTooling.Configuration import InterpolationException, KeyNotFoundException, PathExpressionException 

47from pyTooling.Configuration import UnsupportedValueTypeException 

48from pyTooling.Configuration import Node as Abstract_Node 

49from pyTooling.Configuration import Dictionary as Abstract_Dict 

50from pyTooling.Configuration import Sequence as Abstract_Seq 

51from pyTooling.Configuration import Configuration as Abstract_Configuration 

52 

53 

54@export 

55class Node(Abstract_Node): 

56 """ 

57 Node in a JSON configuration data structure. 

58 """ 

59 

60 _jsonNode: Union[Dict, List] #: Reference to the associated JSON node. 

61 _cache: Dict[str, ValueT] 

62 _key: KeyT #: Key of this node. 

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

64 

65 def __init__( 

66 self, 

67 root: "Configuration", 

68 parent: NodeT, 

69 key: KeyT, 

70 jsonNode: Union[Dict, List] 

71 ) -> None: 

72 """ 

73 Initializes a JSON node. 

74 

75 :param root: Reference to the root node. 

76 :param parent: Reference to the parent node. 

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

78 :param jsonNode: Reference to the JSON node. 

79 """ 

80 Abstract_Node.__init__(self, root, parent) 

81 

82 self._jsonNode = jsonNode 

83 self._cache = {} 

84 self._key = key 

85 self._length = len(jsonNode) 

86 

87 def __len__(self) -> int: 

88 """ 

89 Returns the number of sub-elements. 

90 

91 :returns: Number of sub-elements. 

92 """ 

93 return self._length 

94 

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

96 """ 

97 Access an element in the node by index or key. 

98 

99 :param key: Index or key of the element. 

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

101 """ 

102 return self._GetNodeOrValue(str(key)) 

103 

104 @property 

105 def Key(self) -> KeyT: 

106 """ 

107 Property to access the node's key. 

108 

109 :returns: Key of the node. 

110 """ 

111 return self._key 

112 

113 @Key.setter 

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

115 raise NotImplementedError() 

116 

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

118 """ 

119 Return a node or value based on a path description to that node or value. 

120 

121 :param query: String describing the path to the node or value. 

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

123 """ 

124 path = self._ToPath(query) 

125 return self._GetNodeOrValueByPathExpression(path) 

126 

127 @staticmethod 

128 def _ToPath(query: str) -> List[Union[str, int]]: 

129 return query.split(":") 

130 

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

132 """ 

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

134 

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

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

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

138 """ 

139 try: 

140 return self._jsonNode[key] 

141 except (KeyError, TypeError): 

142 pass 

143 

144 for conversion in (int, float): 

145 try: 

146 convertedKey = conversion(key) 

147 except ValueError: 

148 continue 

149 

150 try: 

151 return self._jsonNode[convertedKey] 

152 except (KeyError, IndexError, TypeError): 

153 pass 

154 

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

156 ex.add_note(self._DescribeKeys()) 

157 raise ex 

158 

159 def _DescribeKeys(self) -> str: 

160 """ 

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

162 

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

164 """ 

165 if isinstance(self._jsonNode, dict): 

166 if self._length == 0: 

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

168 

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

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

171 else: 

172 if self._length == 0: 

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

174 

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

176 

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

178 try: 

179 value = self._cache[key] 

180 except KeyError: 

181 value = self._LookupKey(key) 

182 

183 if isinstance(value, str): 

184 value = self._ResolveVariables(value) 

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

186 value = str(value) 

187 elif isinstance(value, dict): 

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

189 elif isinstance(value, list): 

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

191 else: 

192 typeName = getFullyQualifiedName(value) 

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

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

195 raise ex 

196 

197 self._cache[key] = value 

198 

199 return value 

200 

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

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

203 return "" 

204 elif "$" not in value: 

205 return value 

206 

207 rawValue = value 

208 result = "" 

209 

210 while (len(rawValue) > 0): 

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

212 beginPos = rawValue.find("$") 

213 if beginPos < 0: 

214 result += rawValue 

215 rawValue = "" 

216 else: 

217 result += rawValue[:beginPos] 

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

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

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

221 raise ex 

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

223 result += "$" 

224 rawValue = rawValue[1:] 

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

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

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

228 if endPos < 0: 

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

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

231 raise ex 

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

233 path = rawValue[nextPos+2:endPos] 

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

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

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

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

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

239 else: 

240 path = rawValue[beginPos+2:endPos] 

241 rawValue = rawValue[endPos+1:] 

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

243 

244 return result 

245 

246 def _GetValueByPathExpression(self, path: List[KeyT]) -> ValueT: 

247 node = self 

248 for p in path: 

249 if p == "..": 

250 node = node._parent 

251 else: 

252 node = node._GetNodeOrValue(p) 

253 

254 if isinstance(node, Dictionary): 

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

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

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

258 raise ex 

259 

260 return node 

261 

262 def _GetNodeOrValueByPathExpression(self, path: List[KeyT]) -> ValueT: 

263 node = self 

264 for p in path: 

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

266 node = node._parent 

267 else: 

268 node = node._GetNodeOrValue(p) 

269 

270 return node 

271 

272 

273@export 

274class Dictionary(Node, Abstract_Dict): 

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

276 

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

278 

279 def __init__( 

280 self, 

281 root: "Configuration", 

282 parent: NodeT, 

283 key: KeyT, 

284 jsonNode: Dict 

285 ) -> None: 

286 """ 

287 Initializes a JSON dictionary. 

288 

289 :param root: Reference to the root node. 

290 :param parent: Reference to the parent node. 

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

292 :param jsonNode: Reference to the JSON node. 

293 """ 

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

295 

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

297 

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

299 """ 

300 Checks if the key is in this dictionary. 

301 

302 :param key: The key to check. 

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

304 """ 

305 return key in self._keys 

306 

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

308 """ 

309 Returns an iterator to iterate dictionary keys. 

310 

311 :returns: Dictionary key iterator. 

312 """ 

313 

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

315 """Iterator to iterate dictionary items.""" 

316 

317 _iter: typing_Iterator 

318 _obj: Dictionary 

319 

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

321 """ 

322 Initializes an iterator for a JSON dictionary node. 

323 

324 :param obj: JSON dictionary to iterate. 

325 """ 

326 self._iter = iter(obj._keys) 

327 self._obj = obj 

328 

329 def __iter__(self) -> Self: 

330 """ 

331 Return itself to fulfil the iterator protocol. 

332 

333 :returns: Itself. 

334 """ 

335 return self # pragma: no cover 

336 

337 def __next__(self) -> ValueT: 

338 """ 

339 Returns the next item in the dictionary. 

340 

341 :returns: Next item. 

342 """ 

343 key = next(self._iter) 

344 return self._obj[key] 

345 

346 return Iterator(self) 

347 

348 

349@export 

350class Sequence(Node, Abstract_Seq): 

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

352 

353 def __init__( 

354 self, 

355 root: "Configuration", 

356 parent: NodeT, 

357 key: KeyT, 

358 jsonNode: List 

359 ) -> None: 

360 """ 

361 Initializes a JSON sequence (list). 

362 

363 :param root: Reference to the root node. 

364 :param parent: Reference to the parent node. 

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

366 :param jsonNode: Reference to the JSON node. 

367 """ 

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

369 

370 self._length = len(jsonNode) 

371 

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

373 """ 

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

375 

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

377 """ 

378 

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

380 """Iterator to iterate sequence items.""" 

381 

382 _i: int #: internal iterator position 

383 _obj: Sequence #: Sequence object to iterate 

384 

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

386 """ 

387 Initializes an iterator for a JSON sequence node. 

388 

389 :param obj: YAML sequence to iterate. 

390 """ 

391 self._i = 0 

392 self._obj = obj 

393 

394 def __iter__(self) -> Self: 

395 """ 

396 Return itself to fulfil the iterator protocol. 

397 

398 :returns: Itself. 

399 """ 

400 return self # pragma: no cover 

401 

402 def __next__(self) -> ValueT: 

403 """ 

404 Returns the next item in the sequence. 

405 

406 :returns: Next item. 

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

408 """ 

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

410 raise StopIteration 

411 

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

413 self._i += 1 

414 return result 

415 

416 return Iterator(self) 

417 

418 

419setattr(Node, "DICT_TYPE", Dictionary) 

420setattr(Node, "SEQ_TYPE", Sequence) 

421 

422 

423@export 

424class Configuration(Dictionary, Abstract_Configuration): 

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

426 

427 _jsonConfig: Dict 

428 

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

430 """ 

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

432 

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

434 

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

436 """ 

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

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

439 

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

441 self._jsonConfig = load(file) 

442 

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

444 Abstract_Configuration.__init__(self, configFile) 

445 

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

447 """ 

448 Access a configuration node by key. 

449 

450 :param key: The key to look for. 

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

452 """ 

453 return self._GetNodeOrValue(str(key)) 

454 

455 def __setitem__(self, key: str, value: ValueT) -> None: 

456 raise NotImplementedError()