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
« 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.
34.. hint::
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
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
54@export
55class Node(Abstract_Node):
56 """
57 Node in a JSON configuration data structure.
58 """
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.
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.
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)
82 self._jsonNode = jsonNode
83 self._cache = {}
84 self._key = key
85 self._length = len(jsonNode)
87 def __len__(self) -> int:
88 """
89 Returns the number of sub-elements.
91 :returns: Number of sub-elements.
92 """
93 return self._length
95 def __getitem__(self, key: KeyT) -> ValueT:
96 """
97 Access an element in the node by index or key.
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))
104 @property
105 def Key(self) -> KeyT:
106 """
107 Property to access the node's key.
109 :returns: Key of the node.
110 """
111 return self._key
113 @Key.setter
114 def Key(self, value: KeyT) -> None:
115 raise NotImplementedError()
117 def QueryPath(self, query: str) -> ValueT:
118 """
119 Return a node or value based on a path description to that node or value.
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)
127 @staticmethod
128 def _ToPath(query: str) -> List[Union[str, int]]:
129 return query.split(":")
131 def _LookupKey(self, key: str) -> Any:
132 """
133 Look up a key in the JSON node, trying it as string, integer and float.
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
144 for conversion in (int, float):
145 try:
146 convertedKey = conversion(key)
147 except ValueError:
148 continue
150 try:
151 return self._jsonNode[convertedKey]
152 except (KeyError, IndexError, TypeError):
153 pass
155 ex = KeyNotFoundException(f"Key '{key}' not found in node '{self._key}'.")
156 ex.add_note(self._DescribeKeys())
157 raise ex
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.
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."
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."
175 return f"Node '{self._key}' is a sequence with indices 0..{self._length - 1}."
177 def _GetNodeOrValue(self, key: str) -> ValueT:
178 try:
179 value = self._cache[key]
180 except KeyError:
181 value = self._LookupKey(key)
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
197 self._cache[key] = value
199 return value
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
207 rawValue = value
208 result = ""
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)))
244 return result
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)
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
260 return node
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)
270 return node
273@export
274class Dictionary(Node, Abstract_Dict):
275 """A dictionary node in a JSON data file."""
277 _keys: List[KeyT] #: List of keys in this dictionary.
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.
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)
296 self._keys = [str(k) for k in jsonNode.keys()]
298 def __contains__(self, key: KeyT) -> bool:
299 """
300 Checks if the key is in this dictionary.
302 :param key: The key to check.
303 :returns: ``True``, if the key is in the dictionary.
304 """
305 return key in self._keys
307 def __iter__(self) -> typing_Iterator[ValueT]:
308 """
309 Returns an iterator to iterate dictionary keys.
311 :returns: Dictionary key iterator.
312 """
314 class Iterator(metaclass=ExtendedType, slots=True):
315 """Iterator to iterate dictionary items."""
317 _iter: typing_Iterator
318 _obj: Dictionary
320 def __init__(self, obj: Dictionary) -> None:
321 """
322 Initializes an iterator for a JSON dictionary node.
324 :param obj: JSON dictionary to iterate.
325 """
326 self._iter = iter(obj._keys)
327 self._obj = obj
329 def __iter__(self) -> Self:
330 """
331 Return itself to fulfil the iterator protocol.
333 :returns: Itself.
334 """
335 return self # pragma: no cover
337 def __next__(self) -> ValueT:
338 """
339 Returns the next item in the dictionary.
341 :returns: Next item.
342 """
343 key = next(self._iter)
344 return self._obj[key]
346 return Iterator(self)
349@export
350class Sequence(Node, Abstract_Seq):
351 """A sequence node (ordered list) in a JSON data file."""
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).
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)
370 self._length = len(jsonNode)
372 def __iter__(self) -> typing_Iterator[ValueT]:
373 """
374 Returns an iterator to iterate items in the sequence of sub-nodes.
376 :returns: Iterator to iterate items in a sequence.
377 """
379 class Iterator(metaclass=ExtendedType, slots=True):
380 """Iterator to iterate sequence items."""
382 _i: int #: internal iterator position
383 _obj: Sequence #: Sequence object to iterate
385 def __init__(self, obj: Sequence) -> None:
386 """
387 Initializes an iterator for a JSON sequence node.
389 :param obj: YAML sequence to iterate.
390 """
391 self._i = 0
392 self._obj = obj
394 def __iter__(self) -> Self:
395 """
396 Return itself to fulfil the iterator protocol.
398 :returns: Itself.
399 """
400 return self # pragma: no cover
402 def __next__(self) -> ValueT:
403 """
404 Returns the next item in the sequence.
406 :returns: Next item.
407 :raises StopIteration: If end of sequence is reached.
408 """
409 if self._i >= len(self._obj):
410 raise StopIteration
412 result = self._obj[str(self._i)]
413 self._i += 1
414 return result
416 return Iterator(self)
419setattr(Node, "DICT_TYPE", Dictionary)
420setattr(Node, "SEQ_TYPE", Sequence)
423@export
424class Configuration(Dictionary, Abstract_Configuration):
425 """A configuration read from a JSON file."""
427 _jsonConfig: Dict
429 def __init__(self, configFile: Path) -> None:
430 """
431 Initializes a configuration instance that reads a JSON file as input.
433 All sequence items or dictionaries key-value-pairs in the JSON file are accessible via Python's dictionary syntax.
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)
440 with configFile.open("r", encoding="utf-8") as file:
441 self._jsonConfig = load(file)
443 Dictionary.__init__(self, self, self, None, self._jsonConfig)
444 Abstract_Configuration.__init__(self, configFile)
446 def __getitem__(self, key: str) -> ValueT:
447 """
448 Access a configuration node by key.
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))
455 def __setitem__(self, key: str, value: ValueT) -> None:
456 raise NotImplementedError()