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
« 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.
34.. hint::
36 See :ref:`high-level help <CONFIG/FileFormat/JSON>` for explanations and usage examples.
37"""
38from __future__ import annotations
40from json import load
41from pathlib import Path
42from typing import Any, Union, Iterator as typing_Iterator, Self
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
56@export
57class Node(Abstract_Node):
58 """
59 Node in a JSON configuration data structure.
60 """
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.
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.
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)
84 self._jsonNode = jsonNode
85 self._cache = {}
86 self._key = key
87 self._length = len(jsonNode)
89 @InheritDocString(Abstract_Node)
90 def __len__(self) -> int:
91 return self._length
93 @InheritDocString(Abstract_Node)
94 def __getitem__(self, key: KeyT) -> ValueT:
95 return self._GetNodeOrValue(str(key))
97 @property
98 def Key(self) -> KeyT:
99 """
100 Property to access the node's key.
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
108 @Key.setter
109 def Key(self, value: KeyT) -> None:
110 raise NotImplementedError()
112 @InheritDocString(Abstract_Node)
113 def QueryPath(self, query: str) -> ValueT:
114 path = self._ToPath(query)
115 return self._GetNodeOrValueByPathExpression(path)
117 @staticmethod
118 def _ToPath(query: str) -> list[Union[str, int]]:
119 """
120 Split a path expression into its elements.
122 :param query: Path expression, with its elements separated by ``:``.
123 :returns: List of keys and indices.
124 """
125 return query.split(":")
127 def _LookupKey(self, key: str) -> Any:
128 """
129 Look up a key in the JSON node, trying it as string, integer and float.
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
140 for conversion in (int, float):
141 try:
142 convertedKey = conversion(key)
143 except ValueError:
144 continue
146 try:
147 return self._jsonNode[convertedKey]
148 except (KeyError, IndexError, TypeError):
149 pass
151 ex = KeyNotFoundException(f"Key '{key}' not found in node '{self._key}'.")
152 ex.add_note(self._DescribeKeys())
153 raise ex
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.
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."
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."
171 return f"Node '{self._key}' is a sequence with indices 0..{self._length - 1}."
173 def _GetNodeOrValue(self, key: str) -> ValueT:
174 """
175 Return a sub-node or a value by key, converting it on first access.
177 The converted object is cached, so a second access returns the same node object rather than a new one.
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)
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
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 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
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 = 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)))
265 return result
267 def _GetValueByPathExpression(self, path: list[KeyT]) -> ValueT:
268 """
269 Return the value the given path refers to.
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)
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
290 return node
292 def _GetNodeOrValueByPathExpression(self, path: list[KeyT]) -> ValueT:
293 """
294 Return the node or value the given path refers to.
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)
307 return node
310@export
311class Dictionary(Node, Abstract_Dict):
312 """A dictionary node in a JSON data file."""
314 _keys: list[KeyT] #: List of keys in this dictionary.
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.
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)
333 self._keys = [str(k) for k in jsonNode.keys()]
335 def __contains__(self, key: KeyT) -> bool:
336 """
337 Checks if the key is in this dictionary.
339 :param key: The key to check.
340 :returns: ``True``, if the key is in the dictionary.
341 """
342 return key in self._keys
344 def __iter__(self) -> typing_Iterator[ValueT]:
345 """
346 Returns an iterator to iterate dictionary keys.
348 :returns: Dictionary key iterator.
349 """
351 class Iterator(metaclass=ExtendedType, slots=True):
352 """Iterator to iterate dictionary items."""
354 _iter: typing_Iterator #: Iterator over the underlying dictionary's keys.
355 _obj: Dictionary #: The dictionary being iterated.
357 def __init__(self, obj: Dictionary) -> None:
358 """
359 Initializes an iterator for a JSON dictionary node.
361 :param obj: JSON dictionary to iterate.
362 """
363 self._iter = iter(obj._keys)
364 self._obj = obj
366 def __iter__(self) -> Self:
367 """
368 Return itself to fulfil the iterator protocol.
370 :returns: Itself.
371 """
372 return self # pragma: no cover
374 def __next__(self) -> ValueT:
375 """
376 Returns the next item in the dictionary.
378 :returns: Next item.
379 """
380 key = next(self._iter)
381 return self._obj[key]
383 return Iterator(self)
386@export
387class Sequence(Node, Abstract_Seq):
388 """A sequence node (ordered list) in a JSON data file."""
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).
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)
407 self._length = len(jsonNode)
409 def __iter__(self) -> typing_Iterator[ValueT]:
410 """
411 Returns an iterator to iterate items in the sequence of sub-nodes.
413 :returns: Iterator to iterate items in a sequence.
414 """
416 class Iterator(metaclass=ExtendedType, slots=True):
417 """Iterator to iterate sequence items."""
419 _i: int #: internal iterator position
420 _obj: Sequence #: Sequence object to iterate
422 def __init__(self, obj: Sequence) -> None:
423 """
424 Initializes an iterator for a JSON sequence node.
426 :param obj: YAML sequence to iterate.
427 """
428 self._i = 0
429 self._obj = obj
431 def __iter__(self) -> Self:
432 """
433 Return itself to fulfil the iterator protocol.
435 :returns: Itself.
436 """
437 return self # pragma: no cover
439 def __next__(self) -> ValueT:
440 """
441 Returns the next item in the sequence.
443 :returns: Next item.
444 :raises StopIteration: If end of sequence is reached.
445 """
446 if self._i >= len(self._obj):
447 raise StopIteration
449 result = self._obj[str(self._i)]
450 self._i += 1
451 return result
453 return Iterator(self)
456setattr(Node, "DICT_TYPE", Dictionary)
457setattr(Node, "SEQ_TYPE", Sequence)
460@export
461class Configuration(Dictionary, Abstract_Configuration):
462 """A configuration read from a JSON file."""
464 _jsonConfig: dict #: The parsed JSON document this configuration is based on.
466 def __init__(self, configFile: Path) -> None:
467 """
468 Initializes a configuration instance that reads a JSON file as input.
470 All sequence items or dictionaries key-value-pairs in the JSON file are accessible via Python's dictionary syntax.
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)
478 with configFile.open("r", encoding="utf-8") as file:
479 self._jsonConfig = load(file)
481 Dictionary.__init__(self, self, self, None, self._jsonConfig)
482 Abstract_Configuration.__init__(self, configFile)
484 def __getitem__(self, key: str) -> ValueT:
485 """
486 Access a configuration node by key.
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))
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()