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
« 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.
34.. hint::
36 See :ref:`high-level help <CONFIG/FileFormat/JSON>` for explanations and usage examples.
37"""
38from __future__ import annotations
40from json import JSONDecodeError, loads
41from pathlib import Path
42from typing import Any, Union, Iterator as typing_Iterator, Optional as Nullable, Self
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
53@export
54class Node(Abstract_Node):
55 """
56 Node in a JSON configuration data structure.
57 """
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.
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.
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)
80 self._jsonNode = jsonNode
81 self._cache = {}
82 self._key = key
84 @InheritDocString(Abstract_Node)
85 def __len__(self) -> int:
86 return len(self._jsonNode)
88 @InheritDocString(Abstract_Node)
89 def __getitem__(self, key: KeyT) -> ValueT:
90 return self._GetNodeOrValue(str(key))
92 @property
93 def Key(self) -> KeyT:
94 """
95 Property to access the node's key.
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
103 @Key.setter
104 def Key(self, value: KeyT) -> None:
105 raise NotImplementedError("Renaming a key isn't supported by this configuration implementation.")
107 @InheritDocString(Abstract_Node)
108 def QueryPath(self, query: str) -> ValueT:
109 path = self._ToPath(query)
110 return self._GetNodeOrValueByPathExpression(path)
112 @staticmethod
113 def _ToPath(query: str) -> list[Union[str, int]]:
114 """
115 Split a path expression into its elements.
117 :param query: Path expression, with its elements separated by ``:``.
118 :returns: List of keys and indices.
119 """
120 return query.split(":")
122 def _LookupKey(self, key: str) -> Any:
123 """
124 Look up a key in the JSON node, trying it as string, integer and float.
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
135 for conversion in (int, float):
136 try:
137 convertedKey = conversion(key)
138 except ValueError:
139 continue
141 try:
142 return self._jsonNode[convertedKey]
143 except (KeyError, IndexError, TypeError):
144 pass
146 ex = KeyNotFoundError(f"Key '{key}' not found in node '{self._key}'.", key=key)
147 ex.add_note(self._DescribeKeys())
148 raise ex
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.
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."
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."
166 return f"Node '{self._key}' is a sequence with indices 0..{len(self._jsonNode) - 1}."
168 def _GetNodeOrValue(self, key: str) -> ValueT:
169 """
170 Return a sub-node or a value by key, converting it on first access.
172 The converted object is cached, so a second access returns the same node object rather than a new one.
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)
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
205 self._cache[key] = value
207 return value
209 def _ResolveVariables(self, value: str) -> str:
210 """
211 Resolve the ``${...}`` variables inside a value.
213 A variable references another node by a path expression, so a value can be composed from other values of the
214 same configuration.
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
228 rawValue = value
229 result = ""
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
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)))
266 return result
268 def _GetValueByPathExpression(self, path: list[KeyT]) -> ValueT:
269 """
270 Return the value the given path refers to.
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
293 node = node._parent
294 else:
295 node = node._GetNodeOrValue(p)
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
314 return node
316 def _GetNodeOrValueByPathExpression(self, path: list[KeyT]) -> ValueT:
317 """
318 Return the node or value the given path refers to.
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
337 node = node._parent
338 else:
339 node = node._GetNodeOrValue(p)
341 return node
344@export
345class Dictionary(Node, Abstract_Dict):
346 """A dictionary node in a JSON data file."""
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.
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()]
366 Node.__init__(self, root, parent, key, jsonNode)
367 Abstract_Dict.__init__(self, keys)
369 def __iter__(self) -> typing_Iterator[ValueT]:
370 """
371 Returns an iterator to iterate dictionary keys.
373 :returns: Dictionary key iterator.
374 """
376 class Iterator(metaclass=ExtendedType, slots=True):
377 """Iterator to iterate dictionary items."""
379 _iter: typing_Iterator #: Iterator over the underlying dictionary's keys.
380 _obj: Dictionary #: The dictionary being iterated.
382 def __init__(self, obj: Dictionary) -> None:
383 """
384 Initializes an iterator for a JSON dictionary node.
386 :param obj: JSON dictionary to iterate.
387 """
388 self._iter = iter(obj._keys)
389 self._obj = obj
391 def __iter__(self) -> Self:
392 """
393 Return itself to fulfil the iterator protocol.
395 :returns: Itself.
396 """
397 return self # pragma: no cover
399 def __next__(self) -> ValueT:
400 """
401 Returns the next item in the dictionary.
403 :returns: Next item.
404 """
405 key = next(self._iter)
406 return self._obj[key]
408 return Iterator(self)
411@export
412class Sequence(Node, Abstract_Seq):
413 """A sequence node (ordered list) in a JSON data file."""
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).
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)
433 def __iter__(self) -> typing_Iterator[ValueT]:
434 """
435 Returns an iterator to iterate items in the sequence of sub-nodes.
437 :returns: Iterator to iterate items in a sequence.
438 """
440 class Iterator(metaclass=ExtendedType, slots=True):
441 """Iterator to iterate sequence items."""
443 _i: int #: internal iterator position
444 _obj: Sequence #: Sequence object to iterate
446 def __init__(self, obj: Sequence) -> None:
447 """
448 Initializes an iterator for a JSON sequence node.
450 :param obj: YAML sequence to iterate.
451 """
452 self._i = 0
453 self._obj = obj
455 def __iter__(self) -> Self:
456 """
457 Return itself to fulfil the iterator protocol.
459 :returns: Itself.
460 """
461 return self # pragma: no cover
463 def __next__(self) -> ValueT:
464 """
465 Returns the next item in the sequence.
467 :returns: Next item.
468 :raises StopIteration: If end of sequence is reached.
469 """
470 if self._i >= len(self._obj):
471 raise StopIteration
473 result = self._obj[str(self._i)]
474 self._i += 1
475 return result
477 return Iterator(self)
480setattr(Node, "DICT_TYPE", Dictionary)
481setattr(Node, "SEQ_TYPE", Sequence)
484@export
485class Configuration(Dictionary, Abstract_Configuration):
486 """A configuration read from a JSON file."""
488 _jsonConfig: dict #: The parsed JSON document this configuration is based on.
490 def __init__(self, configFile: Path) -> None:
491 """
492 Initializes a configuration instance that reads a JSON file as input.
494 All sequence items or dictionaries key-value-pairs in the JSON file are accessible via Python's dictionary syntax.
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.
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)
512 with configFile.open("r", encoding="utf-8") as file:
513 content = file.read()
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
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
532 self._jsonConfig = document
534 Dictionary.__init__(self, self, None, None, self._jsonConfig)
535 Abstract_Configuration.__init__(self, configFile)
537 def __getitem__(self, key: str) -> ValueT:
538 """
539 Access a configuration node by key.
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))
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()