Coverage for pyTooling/Configuration/YAML.py: 95%
185 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 YAML files.
34.. hint::
36 See :ref:`high-level help <CONFIG/FileFormat/YAML>` for explanations and usage examples.
37"""
38from pathlib import Path
39from typing import Any, Dict, List, Union, Iterator as typing_Iterator, Self
41try:
42 from ruamel.yaml import YAML, CommentedMap, CommentedSeq
43except ImportError as ex: # pragma: no cover
44 raise Exception("Optional dependency 'ruamel.yaml' not installed. Either install pyTooling with extra dependencies 'pyTooling[yaml]' or install 'ruamel.yaml' directly.") from ex
46from pyTooling.Common import getFullyQualifiedName
47from pyTooling.Decorators import export
48from pyTooling.MetaClasses import ExtendedType
49from pyTooling.Configuration import ConfigurationException, KeyT, NodeT, ValueT
50from pyTooling.Configuration import InterpolationException, KeyNotFoundException, PathExpressionException
51from pyTooling.Configuration import UnsupportedValueTypeException
52from pyTooling.Configuration import Node as Abstract_Node
53from pyTooling.Configuration import Dictionary as Abstract_Dict
54from pyTooling.Configuration import Sequence as Abstract_Seq
55from pyTooling.Configuration import Configuration as Abstract_Configuration
58@export
59class Node(Abstract_Node):
60 """
61 Node in a YAML configuration data structure.
62 """
64 _yamlNode: Union[CommentedMap, CommentedSeq] #: Reference to the associated YAML node.
65 _cache: Dict[str, ValueT]
66 _key: KeyT #: Key of this node.
67 _length: int #: Number of sub-elements.
69 def __init__(
70 self,
71 root: "Configuration",
72 parent: NodeT,
73 key: KeyT,
74 yamlNode: Union[CommentedMap, CommentedSeq]
75 ) -> None:
76 """
77 Initializes a YAML node.
79 :param root: Reference to the root node.
80 :param parent: Reference to the parent node.
81 :param key: Key of the node within its parent.
82 :param yamlNode: Reference to the YAML node.
83 """
84 Abstract_Node.__init__(self, root, parent)
86 self._yamlNode = yamlNode
87 self._cache = {}
88 self._key = key
89 self._length = len(yamlNode)
91 def __len__(self) -> int:
92 """
93 Returns the number of sub-elements.
95 :returns: Number of sub-elements.
96 """
97 return self._length
99 def __getitem__(self, key: KeyT) -> ValueT:
100 """
101 Access an element in the node by index or key.
103 :param key: Index or key of the element.
104 :returns: A node (sequence or dictionary) or scalar value (int, float, str).
105 """
106 return self._GetNodeOrValue(str(key))
108 @property
109 def Key(self) -> KeyT:
110 """
111 Property to access the node's key.
113 :returns: Key of the node.
114 """
115 return self._key
117 @Key.setter
118 def Key(self, value: KeyT) -> None:
119 raise NotImplementedError()
121 def QueryPath(self, query: str) -> ValueT:
122 """
123 Return a node or value based on a path description to that node or value.
125 :param query: String describing the path to the node or value.
126 :returns: A node (sequence or dictionary) or scalar value (int, float, str).
127 """
128 path = self._ToPath(query)
129 return self._GetNodeOrValueByPathExpression(path)
131 @staticmethod
132 def _ToPath(query: str) -> List[Union[str, int]]:
133 return query.split(":")
135 def _LookupKey(self, key: str) -> Any:
136 """
137 Look up a key in the YAML node, trying it as string, integer and float.
139 :param key: Key or index to look up.
140 :returns: The raw value as returned by the YAML parser.
141 :raises KeyNotFoundException: If the key exists neither as string, nor as integer or float.
142 """
143 try:
144 return self._yamlNode[key]
145 except (KeyError, TypeError):
146 pass
148 for conversion in (int, float):
149 try:
150 convertedKey = conversion(key)
151 except ValueError:
152 continue
154 try:
155 return self._yamlNode[convertedKey]
156 except (KeyError, IndexError, TypeError):
157 pass
159 ex = KeyNotFoundException(f"Key '{key}' not found in node '{self._key}'.")
160 ex.add_note(self._DescribeKeys())
161 raise ex
163 def _DescribeKeys(self) -> str:
164 """
165 Describe the keys or indices offered by this node, so it can be used as an exception note.
167 :returns: A one-line description of the node's keys or index range.
168 """
169 if isinstance(self._yamlNode, CommentedMap):
170 if self._length == 0:
171 return f"Node '{self._key}' is an empty dictionary."
173 keys = "', '".join(str(key) for key in self._yamlNode)
174 return f"Available keys: '{keys}'."
175 else:
176 if self._length == 0:
177 return f"Node '{self._key}' is an empty sequence."
179 return f"Node '{self._key}' is a sequence with indices 0..{self._length - 1}."
181 def _GetNodeOrValue(self, key: str) -> ValueT:
182 try:
183 value = self._cache[key]
184 except KeyError:
185 value = self._LookupKey(key)
187 if isinstance(value, str):
188 value = self._ResolveVariables(value)
189 elif isinstance(value, (int, float)):
190 value = str(value)
191 elif isinstance(value, CommentedMap):
192 value = self.DICT_TYPE(self, self, key, value)
193 elif isinstance(value, CommentedSeq):
194 value = self.SEQ_TYPE(self, self, key, value)
195 else:
196 typeName = getFullyQualifiedName(value)
197 ex = UnsupportedValueTypeException(f"Unsupported type '{typeName}' for key '{key}' in node '{self._key}'.")
198 ex.add_note(f"The YAML parser returned a value that is neither a scalar (str, int, float), nor a map or sequence.")
199 raise ex
201 self._cache[key] = value
203 return value
205 def _ResolveVariables(self, value: str) -> str:
206 if value == "": 206 ↛ 207line 206 didn't jump to line 207 because the condition on line 206 was never true
207 return ""
208 elif "$" not in value:
209 return value
211 rawValue = value
212 result = ""
214 while (len(rawValue) > 0):
215# print(f"_ResolveVariables: LOOP rawValue='{rawValue}'")
216 beginPos = rawValue.find("$")
217 if beginPos < 0:
218 result += rawValue
219 rawValue = ""
220 else:
221 result += rawValue[:beginPos]
222 if beginPos + 1 >= len(rawValue):
223 ex = InterpolationException(f"Dangling '$' at the end of value '{value}'.")
224 ex.add_note(f"Use '$$' to escape a literal dollar sign.")
225 raise ex
226 elif rawValue[beginPos + 1] == "$": 226 ↛ 227line 226 didn't jump to line 227 because the condition on line 226 was never true
227 result += "$"
228 rawValue = rawValue[1:]
229 elif rawValue[beginPos + 1] == "{": 229 ↛ 214line 229 didn't jump to line 214 because the condition on line 229 was always true
230 endPos = rawValue.find("}", beginPos)
231 nextPos = rawValue.rfind("$", beginPos, endPos)
232 if endPos < 0:
233 ex = InterpolationException(f"Unclosed variable reference in value '{value}'.")
234 ex.add_note(f"Missing closing '}}' for the '${{' at position {beginPos}.")
235 raise ex
236 if (nextPos > 0) and (nextPos < endPos): # an embedded $-sign
237 path = rawValue[nextPos+2:endPos]
238# print(f"_ResolveVariables: path='{path}'")
239 innervalue = self._GetValueByPathExpression(self._ToPath(path))
240# print(f"_ResolveVariables: innervalue='{innervalue}'")
241 rawValue = rawValue[beginPos:nextPos] + str(innervalue) + rawValue[endPos + 1:]
242# print(f"_ResolveVariables: new rawValue='{rawValue}'")
243 else:
244 path = rawValue[beginPos+2:endPos]
245 rawValue = rawValue[endPos+1:]
246 result += str(self._GetValueByPathExpression(self._ToPath(path)))
248 return result
250 def _GetValueByPathExpression(self, path: List[KeyT]) -> ValueT:
251 node = self
252 for p in path:
253 if p == "..":
254 node = node._parent
255 else:
256 node = node._GetNodeOrValue(p)
258 if isinstance(node, Dictionary):
259 pathExpression = ":".join(str(element) for element in path)
260 ex = PathExpressionException(f"Path expression '{pathExpression}' resolves to a dictionary, not to a value.")
261 ex.add_note(f"Element '{p}' is a dictionary. Extend the path expression to address a scalar value.")
262 raise ex
264 return node
266 def _GetNodeOrValueByPathExpression(self, path: List[KeyT]) -> ValueT:
267 node = self
268 for p in path:
269 if p == "..": 269 ↛ 270line 269 didn't jump to line 270 because the condition on line 269 was never true
270 node = node._parent
271 else:
272 node = node._GetNodeOrValue(p)
274 return node
277@export
278class Dictionary(Node, Abstract_Dict):
279 """A dictionary node in a YAML data file."""
281 _keys: List[KeyT] #: List of keys in this dictionary.
283 def __init__(
284 self,
285 root: "Configuration",
286 parent: NodeT,
287 key: KeyT,
288 yamlNode: CommentedMap
289 ) -> None:
290 """
291 Initializes a YAML dictionary.
293 :param root: Reference to the root node.
294 :param parent: Reference to the parent node.
295 :param key: Key of the node within its parent.
296 :param yamlNode: Reference to the YAML node.
297 """
298 Node.__init__(self, root, parent, key, yamlNode)
300 self._keys = [str(k) for k in yamlNode.keys()]
302 def __contains__(self, key: KeyT) -> bool:
303 """
304 Checks if the key is in this dictionary.
306 :param key: The key to check.
307 :returns: ``True``, if the key is in the dictionary.
308 """
309 return key in self._keys
311 def __iter__(self) -> typing_Iterator[ValueT]:
312 """
313 Returns an iterator to iterate dictionary keys.
315 :returns: Dictionary key iterator.
316 """
318 class Iterator(metaclass=ExtendedType, slots=True):
319 """Iterator to iterate dictionary items."""
321 _iter: typing_Iterator[ValueT]
322 _obj: Dictionary
324 def __init__(self, obj: Dictionary) -> None:
325 """
326 Initializes an iterator for a YAML dictionary node.
328 :param obj: YAML dictionary to iterate.
329 """
330 self._iter = iter(obj._keys)
331 self._obj = obj
333 def __iter__(self) -> Self:
334 """
335 Return itself to fulfil the iterator protocol.
337 :returns: Itself.
338 """
339 return self # pragma: no cover
341 def __next__(self) -> ValueT:
342 """
343 Returns the next item in the dictionary.
345 :returns: Next item.
346 """
347 key = next(self._iter)
348 return self._obj[key]
350 return Iterator(self)
353@export
354class Sequence(Node, Abstract_Seq):
355 """A sequence node (ordered list) in a YAML data file."""
357 def __init__(
358 self,
359 root: "Configuration",
360 parent: NodeT,
361 key: KeyT,
362 yamlNode: CommentedSeq
363 ) -> None:
364 """
365 Initializes a YAML sequence (list).
367 :param root: Reference to the root node.
368 :param parent: Reference to the parent node.
369 :param key: Key of the node within its parent.
370 :param yamlNode: Reference to the YAML node.
371 """
372 Node.__init__(self, root, parent, key, yamlNode)
374 self._length = len(yamlNode)
376 def __iter__(self) -> typing_Iterator[ValueT]:
377 """
378 Returns an iterator to iterate items in the sequence of sub-nodes.
380 :returns: Iterator to iterate items in a sequence.
381 """
382 class Iterator(metaclass=ExtendedType, slots=True):
383 """Iterator to iterate sequence items."""
385 _i: int #: internal iterator position
386 _obj: Sequence #: Sequence object to iterate
388 def __init__(self, obj: Sequence) -> None:
389 """
390 Initializes an iterator for a YAML sequence node.
392 :param obj: YAML sequence to iterate.
393 """
394 self._i = 0
395 self._obj = obj
397 def __iter__(self) -> Self:
398 """
399 Return itself to fulfil the iterator protocol.
401 :returns: Itself.
402 """
403 return self # pragma: no cover
405 def __next__(self) -> ValueT:
406 """
407 Returns the next item in the sequence.
409 :returns: Next item.
410 :raises StopIteration: If end of sequence is reached.
411 """
412 if self._i >= len(self._obj):
413 raise StopIteration
415 result = self._obj[str(self._i)]
416 self._i += 1
417 return result
419 return Iterator(self)
422setattr(Node, "DICT_TYPE", Dictionary)
423setattr(Node, "SEQ_TYPE", Sequence)
426@export
427class Configuration(Dictionary, Abstract_Configuration):
428 """A configuration read from a YAML file."""
430 _yamlConfig: YAML
432 def __init__(self, configFile: Path) -> None:
433 """
434 Initializes a configuration instance that reads a YAML file as input.
436 All sequence items or dictionaries key-value-pairs in the YAML file are accessible via Python's dictionary syntax.
438 :param configFile: Configuration file to read and parse.
439 """
440 if not configFile.exists(): 440 ↛ 441line 440 didn't jump to line 441 because the condition on line 440 was never true
441 raise ConfigurationException(f"JSON configuration file '{configFile}' not found.") from FileNotFoundError(configFile)
443 with configFile.open("r", encoding="utf-8") as file:
444 self._yamlConfig = YAML().load(file)
446 Dictionary.__init__(self, self, self, None, self._yamlConfig)
447 Abstract_Configuration.__init__(self, configFile)
449 def __getitem__(self, key: str) -> ValueT:
450 """
451 Access a configuration node by key.
453 :param key: The key to look for.
454 :returns: A node (sequence or dictionary) or scalar value (int, float, str).
455 """
456 return self._GetNodeOrValue(str(key))
458 def __setitem__(self, key: str, value: ValueT) -> None:
459 raise NotImplementedError()