Coverage for pyTooling/Configuration/YAML.py: 97%
216 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 YAML files.
34:raises MissingDependencyError: If the 'yaml' extra isn't installed.
36.. hint::
38 See :ref:`high-level help <CONFIG/FileFormat/YAML>` for explanations and usage examples.
39"""
40from __future__ import annotations
42from pathlib import Path
43from datetime import date, datetime
44from typing import Any, Union, Iterator as typing_Iterator, Optional as Nullable, Self
46from pyTooling.Exceptions import MissingDependencyError
48try:
49 from ruamel.yaml import YAML, CommentedMap, CommentedSeq
50except ImportError as ex: # pragma: no cover
51 raise MissingDependencyError(dependency="ruamel.yaml", extra="yaml") from ex
53from pyTooling.Common import getFullyQualifiedName
54from pyTooling.Decorators import export, InheritDocString
55from pyTooling.MetaClasses import ExtendedType
56from pyTooling.Configuration import ConfigurationError, KeyT, NodeT, ValueT, InterpolationError, KeyNotFoundError, \
57 PathExpressionError, UnsupportedValueTypeError, Node as Abstract_Node, \
58 Dictionary as Abstract_Dict, Sequence as Abstract_Seq, \
59 Configuration as Abstract_Configuration
62@export
63class Node(Abstract_Node):
64 """
65 Node in a YAML configuration data structure.
66 """
68 _yamlNode: Union[CommentedMap, CommentedSeq] #: Reference to the associated YAML node.
69 _cache: dict[str, ValueT] #: Cache of already converted sub-nodes and values, by key.
70 _key: KeyT #: Key of this node.
72 def __init__(
73 self,
74 root: Configuration,
75 parent: Nullable[NodeT],
76 key: KeyT,
77 yamlNode: Union[CommentedMap, CommentedSeq]
78 ) -> None:
79 """
80 Initializes a YAML node.
82 :param root: Reference to the root node.
83 :param parent: Reference to the parent node, or ``None`` for the root node.
84 :param key: Key of the node within its parent.
85 :param yamlNode: Reference to the YAML node.
86 """
87 Abstract_Node.__init__(self, root, parent)
89 self._yamlNode = yamlNode
90 self._cache = {}
91 self._key = key
93 @InheritDocString(Abstract_Node)
94 def __len__(self) -> int:
95 return len(self._yamlNode)
97 @InheritDocString(Abstract_Node)
98 def __getitem__(self, key: KeyT) -> ValueT:
99 return self._GetNodeOrValue(str(key))
101 @property
102 def Key(self) -> KeyT:
103 """
104 Property to access the node's key.
106 :returns: Key of the node.
107 :raises NotImplementedError: If a new key is assigned; renaming a key is not supported by this configuration
108 implementation.
109 """
110 return self._key
112 @Key.setter
113 def Key(self, value: KeyT) -> None:
114 raise NotImplementedError("Renaming a key isn't supported by this configuration implementation.")
116 @InheritDocString(Abstract_Node)
117 def QueryPath(self, query: str) -> ValueT:
118 path = self._ToPath(query)
119 return self._GetNodeOrValueByPathExpression(path)
121 @staticmethod
122 def _ToPath(query: str) -> list[Union[str, int]]:
123 """
124 Split a path expression into its elements.
126 :param query: Path expression, with its elements separated by ``:``.
127 :returns: List of keys and indices.
128 """
129 return query.split(":")
131 def _LookupKey(self, key: str) -> Any:
132 """
133 Look up a key in the YAML 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 YAML parser.
137 :raises KeyNotFoundError: If the key exists neither as string, nor as integer or float.
138 """
139 try:
140 return self._yamlNode[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._yamlNode[convertedKey]
152 except (KeyError, IndexError, TypeError):
153 pass
155 ex = KeyNotFoundError(f"Key '{key}' not found in node '{self._key}'.", key=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._yamlNode, CommentedMap):
166 if len(self._yamlNode) == 0:
167 return f"Node '{self._key}' is an empty dictionary."
169 keys = "', '".join(str(key) for key in self._yamlNode)
170 return f"Available keys: '{keys}'."
171 else:
172 if len(self._yamlNode) == 0:
173 return f"Node '{self._key}' is an empty sequence."
175 return f"Node '{self._key}' is a sequence with indices 0..{len(self._yamlNode) - 1}."
177 def _GetNodeOrValue(self, key: str) -> ValueT:
178 """
179 Return a sub-node or a value by key, converting it on first access.
181 The converted object is cached, so a second access returns the same node object rather than a new one.
183 A date or a datetime is a scalar too - YAML reads ``2026-09-02`` and ``2026-09-02T10:30:00`` as those - and is
184 returned in ISO-8601 spelling. Like every other scalar it comes back as a :class:`str`, so
185 :meth:`datetime.date.fromisoformat` turns it back into a date where a caller wants one.
187 :param key: Key or index to look up.
188 :returns: A dictionary node, a sequence node, a scalar value with its variables
189 resolved, or ``None`` for a null value.
190 :raises KeyNotFoundError: If the key doesn't exist in this node.
191 :raises UnsupportedValueTypeError: If the YAML parser returned a value that is neither a scalar, nor a
192 node.
193 """
194 try:
195 value = self._cache[key]
196 except KeyError:
197 value = self._LookupKey(key)
199 if value is None:
200 pass
201 elif isinstance(value, str):
202 value = self._ResolveVariables(value)
203 elif isinstance(value, (int, float)):
204 value = str(value)
205 # YAML reads an unquoted '2026-09-02' as a date and '2026-09-02T10:30:00' as a datetime, which no other
206 # scalar type covers. 'isoformat' rather than 'str', because 'str' writes a datetime with a space where
207 # ISO-8601 writes a 'T'. JSON has no date type, so its backend needs no such branch.
208 elif isinstance(value, (date, datetime)):
209 value = value.isoformat()
210 elif isinstance(value, CommentedMap):
211 value = self.DICT_TYPE(self._root, self, key, value)
212 elif isinstance(value, CommentedSeq):
213 value = self.SEQ_TYPE(self._root, self, key, value)
214 else:
215 typeName = getFullyQualifiedName(value)
216 ex = UnsupportedValueTypeError(
217 f"Unsupported type '{typeName}' for key '{key}' in node '{self._key}'.",
218 value=value
219 )
220 ex.add_note("The YAML parser returned a value that is neither a scalar (str, int, float, date, "
221 "datetime), nor a map or sequence.")
222 raise ex
224 self._cache[key] = value
226 return value
228 def _ResolveVariables(self, value: str) -> str:
229 """
230 Resolve the ``${...}`` variables inside a value.
232 A variable references another node by a path expression, so a value can be composed from other values of the
233 same configuration.
235 :param value: The raw value, possibly containing variables.
236 :returns: The value with every variable replaced by what it references.
237 :raises InterpolationError: If a variable is malformed - a dangling ``$`` at the end of the value, or a
238 missing closing ``}`` for a ``${`` at some position. |br|
239 Use ``$$`` to escape a literal dollar sign.
240 :raises KeyNotFoundError: If a referenced key doesn't exist.
241 """
242 if value == "": 242 ↛ 243line 242 didn't jump to line 243 because the condition on line 242 was never true
243 return ""
244 elif "$" not in value:
245 return value
247 rawValue = value
248 result = ""
250 while (len(rawValue) > 0):
251# print(f"_ResolveVariables: LOOP rawValue='{rawValue}'")
252 beginPos = rawValue.find("$")
253 if beginPos < 0:
254 result += rawValue
255 rawValue = ""
256 else:
257 result += rawValue[:beginPos]
258 if beginPos + 1 >= len(rawValue):
259 ex = InterpolationError(f"Dangling '$' at the end of value '{value}'.", value=value, position=beginPos)
260 ex.add_note("Use '$$' to escape a literal dollar sign.")
261 raise ex
262 elif rawValue[beginPos + 1] == "$": 262 ↛ 263line 262 didn't jump to line 263 because the condition on line 262 was never true
263 result += "$"
264 rawValue = rawValue[1:]
265 elif rawValue[beginPos + 1] == "{": 265 ↛ 250line 265 didn't jump to line 250 because the condition on line 265 was always true
266 endPos = rawValue.find("}", beginPos)
267 nextPos = rawValue.rfind("$", beginPos, endPos)
268 if endPos < 0:
269 ex = InterpolationError(f"Unclosed variable reference in value '{value}'.", value=value, position=beginPos)
270 ex.add_note(f"Missing closing '}}' for the '${{' at position {beginPos}.")
271 raise ex
273 if (nextPos > 0) and (nextPos < endPos): # an embedded $-sign
274 path = rawValue[nextPos+2:endPos]
275# print(f"_ResolveVariables: path='{path}'")
276 innervalue = self._GetValueByPathExpression(self._ToPath(path))
277# print(f"_ResolveVariables: innervalue='{innervalue}'")
278 rawValue = rawValue[beginPos:nextPos] + str(innervalue) + rawValue[endPos + 1:]
279# print(f"_ResolveVariables: new rawValue='{rawValue}'")
280 else:
281 path = rawValue[beginPos+2:endPos]
282 rawValue = rawValue[endPos+1:]
283 result += str(self._GetValueByPathExpression(self._ToPath(path)))
285 return result
287 def _GetValueByPathExpression(self, path: list[KeyT]) -> ValueT:
288 """
289 Return the value the given path refers to.
291 :param path: Path elements, where ``..`` selects the parent node.
292 :returns: The scalar value at that path.
293 :raises KeyNotFoundError: If a path element doesn't exist.
294 :raises PathExpressionError: If the path resolves to a null value. |br|
295 A variable can't be replaced by a value the document doesn't state.
296 :raises PathExpressionError: If the path resolves to a node instead of a value - extend the path expression
297 to address a scalar value - or if a ``..`` element is applied to the root node,
298 which has no parent.
299 """
300 node = self
301 for p in path:
302 if p == "..":
303 if node._parent is None:
304 pathExpression = ":".join(str(element) for element in path)
305 ex = PathExpressionError(
306 f"Path expression '{pathExpression}' navigates beyond the root node.",
307 pathExpression=pathExpression
308 )
309 ex.add_note("Element '..' was applied to the root node, which has no parent node.")
310 raise ex
312 node = node._parent
313 else:
314 node = node._GetNodeOrValue(p)
316 if node is None:
317 pathExpression = ":".join(str(element) for element in path)
318 ex = PathExpressionError(
319 f"Path expression '{pathExpression}' resolves to a null value.",
320 pathExpression=pathExpression
321 )
322 ex.add_note("A variable can't be replaced by a value the document doesn't state.")
323 raise ex
324 elif isinstance(node, Dictionary):
325 pathExpression = ":".join(str(element) for element in path)
326 ex = PathExpressionError(
327 f"Path expression '{pathExpression}' resolves to a dictionary, not to a value.",
328 pathExpression=pathExpression
329 )
330 ex.add_note(f"Element '{p}' is a dictionary. Extend the path expression to address a scalar value.")
331 raise ex
333 return node
335 def _GetNodeOrValueByPathExpression(self, path: list[KeyT]) -> ValueT:
336 """
337 Return the node or value the given path refers to.
339 :param path: Path elements, where ``..`` selects the parent node.
340 :returns: A node or a scalar value at that path.
341 :raises KeyNotFoundError: If a path element doesn't exist.
342 :raises PathExpressionError: If a ``..`` element is applied to the root node, which has no parent.
343 """
344 node = self
345 for p in path:
346 if p == "..":
347 if node._parent is None: 347 ↛ 356line 347 didn't jump to line 356 because the condition on line 347 was always true
348 pathExpression = ":".join(str(element) for element in path)
349 ex = PathExpressionError(
350 f"Path expression '{pathExpression}' navigates beyond the root node.",
351 pathExpression=pathExpression
352 )
353 ex.add_note("Element '..' was applied to the root node, which has no parent node.")
354 raise ex
356 node = node._parent
357 else:
358 node = node._GetNodeOrValue(p)
360 return node
363@export
364class Dictionary(Node, Abstract_Dict):
365 """A dictionary node in a YAML data file."""
368 def __init__(
369 self,
370 root: Configuration,
371 parent: Nullable[NodeT],
372 key: KeyT,
373 yamlNode: CommentedMap
374 ) -> None:
375 """
376 Initializes a YAML dictionary.
378 :param root: Reference to the root node.
379 :param parent: Reference to the parent node, or ``None`` for the root node.
380 :param key: Key of the node within its parent.
381 :param yamlNode: Reference to the YAML node.
382 """
383 keys: list[KeyT] = [str(k) for k in yamlNode.keys()]
385 Node.__init__(self, root, parent, key, yamlNode)
386 Abstract_Dict.__init__(self, keys)
388 def __iter__(self) -> typing_Iterator[ValueT]:
389 """
390 Returns an iterator to iterate dictionary keys.
392 :returns: Dictionary key iterator.
393 """
395 class Iterator(metaclass=ExtendedType, slots=True):
396 """Iterator to iterate dictionary items."""
398 _iter: typing_Iterator[ValueT] #: Iterator over the underlying dictionary's keys.
399 _obj: Dictionary #: The dictionary being iterated.
401 def __init__(self, obj: Dictionary) -> None:
402 """
403 Initializes an iterator for a YAML dictionary node.
405 :param obj: YAML dictionary to iterate.
406 """
407 self._iter = iter(obj._keys)
408 self._obj = obj
410 def __iter__(self) -> Self:
411 """
412 Return itself to fulfil the iterator protocol.
414 :returns: Itself.
415 """
416 return self # pragma: no cover
418 def __next__(self) -> ValueT:
419 """
420 Returns the next item in the dictionary.
422 :returns: Next item.
423 """
424 key = next(self._iter)
425 return self._obj[key]
427 return Iterator(self)
430@export
431class Sequence(Node, Abstract_Seq):
432 """A sequence node (ordered list) in a YAML data file."""
434 def __init__(
435 self,
436 root: Configuration,
437 parent: NodeT,
438 key: KeyT,
439 yamlNode: CommentedSeq
440 ) -> None:
441 """
442 Initializes a YAML sequence (list).
444 :param root: Reference to the root node.
445 :param parent: Reference to the parent node.
446 :param key: Key of the node within its parent.
447 :param yamlNode: Reference to the YAML node.
448 """
449 Node.__init__(self, root, parent, key, yamlNode)
452 def __iter__(self) -> typing_Iterator[ValueT]:
453 """
454 Returns an iterator to iterate items in the sequence of sub-nodes.
456 :returns: Iterator to iterate items in a sequence.
457 """
458 class Iterator(metaclass=ExtendedType, slots=True):
459 """Iterator to iterate sequence items."""
461 _i: int #: internal iterator position
462 _obj: Sequence #: Sequence object to iterate
464 def __init__(self, obj: Sequence) -> None:
465 """
466 Initializes an iterator for a YAML sequence node.
468 :param obj: YAML sequence to iterate.
469 """
470 self._i = 0
471 self._obj = obj
473 def __iter__(self) -> Self:
474 """
475 Return itself to fulfil the iterator protocol.
477 :returns: Itself.
478 """
479 return self # pragma: no cover
481 def __next__(self) -> ValueT:
482 """
483 Returns the next item in the sequence.
485 :returns: Next item.
486 :raises StopIteration: If end of sequence is reached.
487 """
488 if self._i >= len(self._obj):
489 raise StopIteration
491 result = self._obj[str(self._i)]
492 self._i += 1
493 return result
495 return Iterator(self)
498setattr(Node, "DICT_TYPE", Dictionary)
499setattr(Node, "SEQ_TYPE", Sequence)
502@export
503class Configuration(Dictionary, Abstract_Configuration):
504 """A configuration read from a YAML file."""
506 _yamlConfig: YAML #: The parsed YAML document this configuration is based on.
508 def __init__(self, configFile: Path) -> None:
509 """
510 Initializes a configuration instance that reads a YAML file as input.
512 All sequence items or dictionaries key-value-pairs in the YAML file are accessible via Python's dictionary syntax.
514 A configuration's root **is** a mapping - this class derives from :class:`Dictionary` - so a document
515 describing anything else is rejected here rather than failing on the first access. A document with **no
516 content** carries no settings and reads as an empty configuration; :mod:`pyTooling.Configuration.JSON` does
517 the same, so both formats answer alike.
519 :param configFile: Configuration file to read and parse.
520 :raises ConfigurationError: If the YAML file doesn't exist.
521 :raises ConfigurationError: If the YAML file's root isn't a mapping. |br|
522 An empty file and an explicit ``null`` are the exception: both are *no settings*
523 and read as an empty configuration.
524 """
525 if not configFile.exists():
526 raise ConfigurationError(f"YAML configuration file '{configFile}' not found.") from FileNotFoundError(configFile)
528 with configFile.open("r", encoding="utf-8") as file:
529 document = YAML().load(file)
531 # 'load' returns None for an empty file and for an explicit 'null' - a *null document*, which is valid YAML.
532 if document is None:
533 document = CommentedMap()
534 elif not isinstance(document, CommentedMap):
535 ex = ConfigurationError(f"YAML configuration file '{configFile}' doesn't describe a mapping.")
536 ex.add_note(f"Got type '{getFullyQualifiedName(document)}' at the document's root.")
537 ex.add_note("A configuration needs a mapping of keys to values at its root.")
538 raise ex
540 self._yamlConfig = document
542 Dictionary.__init__(self, self, None, None, self._yamlConfig)
543 Abstract_Configuration.__init__(self, configFile)
545 def __getitem__(self, key: str) -> ValueT:
546 """
547 Access a configuration node by key.
549 :param key: The key to look for.
550 :returns: A node (sequence or dictionary) or scalar value (int, float, str).
551 """
552 return self._GetNodeOrValue(str(key))
554 #
555 # :param key: Key of the value to write.
556 # :param value: The new value.
557 # :raises NotImplementedError: Writing a configuration is not supported by this implementation.
558 # """
559 # raise NotImplementedError()