Coverage for pyTooling / Configuration / YAML.py: 90%
164 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-02-13 22:36 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-02-13 22:36 +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 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.Decorators import export
47from pyTooling.MetaClasses import ExtendedType
48from pyTooling.Configuration import ConfigurationException, KeyT, NodeT, ValueT
49from pyTooling.Configuration import Node as Abstract_Node
50from pyTooling.Configuration import Dictionary as Abstract_Dict
51from pyTooling.Configuration import Sequence as Abstract_Seq
52from pyTooling.Configuration import Configuration as Abstract_Configuration
55@export
56class Node(Abstract_Node):
57 """
58 Node in a YAML configuration data structure.
59 """
61 _yamlNode: Union[CommentedMap, CommentedSeq] #: Reference to the associated YAML node.
62 _cache: Dict[str, ValueT]
63 _key: KeyT #: Key of this node.
64 _length: int #: Number of sub-elements.
66 def __init__(
67 self,
68 root: "Configuration",
69 parent: NodeT,
70 key: KeyT,
71 yamlNode: Union[CommentedMap, CommentedSeq]
72 ) -> None:
73 """
74 Initializes a YAML node.
76 :param root: Reference to the root node.
77 :param parent: Reference to the parent node.
78 :param key:
79 :param yamlNode: Reference to the YAML node.
80 """
81 Abstract_Node.__init__(self, root, parent)
83 self._yamlNode = yamlNode
84 self._cache = {}
85 self._key = key
86 self._length = len(yamlNode)
88 def __len__(self) -> int:
89 """
90 Returns the number of sub-elements.
92 :returns: Number of sub-elements.
93 """
94 return self._length
96 def __getitem__(self, key: KeyT) -> ValueT:
97 """
98 Access an element in the node by index or key.
100 :param key: Index or key of the element.
101 :returns: A node (sequence or dictionary) or scalar value (int, float, str).
102 """
103 return self._GetNodeOrValue(str(key))
105 @property
106 def Key(self) -> KeyT:
107 """
108 Property to access the node's key.
110 :returns: Key of the node.
111 """
112 return self._key
114 @Key.setter
115 def Key(self, value: KeyT) -> None:
116 raise NotImplementedError()
118 def QueryPath(self, query: str) -> ValueT:
119 """
120 Return a node or value based on a path description to that node or value.
122 :param query: String describing the path to the node or value.
123 :returns: A node (sequence or dictionary) or scalar value (int, float, str).
124 """
125 path = self._ToPath(query)
126 return self._GetNodeOrValueByPathExpression(path)
128 @staticmethod
129 def _ToPath(query: str) -> List[Union[str, int]]:
130 return query.split(":")
132 def _GetNodeOrValue(self, key: str) -> ValueT:
133 try:
134 value = self._cache[key]
135 except KeyError:
136 try:
137 value = self._yamlNode[key]
138 except (KeyError, TypeError):
139 try:
140 value = self._yamlNode[int(key)]
141 except KeyError:
142 try:
143 value = self._yamlNode[float(key)]
144 except KeyError as ex:
145 raise Exception(f"") from ex # XXX: needs error message
147 if isinstance(value, str):
148 value = self._ResolveVariables(value)
149 elif isinstance(value, (int, float)):
150 value = str(value)
151 elif isinstance(value, CommentedMap):
152 value = self.DICT_TYPE(self, self, key, value)
153 elif isinstance(value, CommentedSeq): 153 ↛ 156line 153 didn't jump to line 156 because the condition on line 153 was always true
154 value = self.SEQ_TYPE(self, self, key, value)
155 else:
156 raise Exception(f"") from TypeError(f"Unknown type '{value.__class__.__name__}' returned from ruamel.yaml.") # XXX: error message
158 self._cache[key] = value
160 return value
162 def _ResolveVariables(self, value: str) -> str:
163 if value == "": 163 ↛ 164line 163 didn't jump to line 164 because the condition on line 163 was never true
164 return ""
165 elif "$" not in value:
166 return value
168 rawValue = value
169 result = ""
171 while (len(rawValue) > 0):
172# print(f"_ResolveVariables: LOOP rawValue='{rawValue}'")
173 beginPos = rawValue.find("$")
174 if beginPos < 0:
175 result += rawValue
176 rawValue = ""
177 else:
178 result += rawValue[:beginPos]
179 if rawValue[beginPos + 1] == "$": 179 ↛ 180line 179 didn't jump to line 180 because the condition on line 179 was never true
180 result += "$"
181 rawValue = rawValue[1:]
182 elif rawValue[beginPos + 1] == "{": 182 ↛ 171line 182 didn't jump to line 171 because the condition on line 182 was always true
183 endPos = rawValue.find("}", beginPos)
184 nextPos = rawValue.rfind("$", beginPos, endPos)
185 if endPos < 0: 185 ↛ 186line 185 didn't jump to line 186 because the condition on line 185 was never true
186 raise Exception(f"") # XXX: InterpolationSyntaxError(option, section, f"Bad interpolation variable reference {rest!r}")
187 if (nextPos > 0) and (nextPos < endPos): # an embedded $-sign
188 path = rawValue[nextPos+2:endPos]
189# print(f"_ResolveVariables: path='{path}'")
190 innervalue = self._GetValueByPathExpression(self._ToPath(path))
191# print(f"_ResolveVariables: innervalue='{innervalue}'")
192 rawValue = rawValue[beginPos:nextPos] + str(innervalue) + rawValue[endPos + 1:]
193# print(f"_ResolveVariables: new rawValue='{rawValue}'")
194 else:
195 path = rawValue[beginPos+2:endPos]
196 rawValue = rawValue[endPos+1:]
197 result += str(self._GetValueByPathExpression(self._ToPath(path)))
199 return result
201 def _GetValueByPathExpression(self, path: List[KeyT]) -> ValueT:
202 node = self
203 for p in path:
204 if p == "..":
205 node = node._parent
206 else:
207 node = node._GetNodeOrValue(p)
209 if isinstance(node, Dictionary): 209 ↛ 210line 209 didn't jump to line 210 because the condition on line 209 was never true
210 raise Exception(f"Error when resolving path expression '{':'.join(path)}' at '{p}'.") from TypeError(f"") # XXX: needs error messages
212 return node
214 def _GetNodeOrValueByPathExpression(self, path: List[KeyT]) -> ValueT:
215 node = self
216 for p in path:
217 if p == "..": 217 ↛ 218line 217 didn't jump to line 218 because the condition on line 217 was never true
218 node = node._parent
219 else:
220 node = node._GetNodeOrValue(p)
222 return node
225@export
226class Dictionary(Node, Abstract_Dict):
227 """A dictionary node in a YAML data file."""
229 _keys: List[KeyT] #: List of keys in this dictionary.
231 def __init__(
232 self,
233 root: "Configuration",
234 parent: NodeT,
235 key: KeyT,
236 yamlNode: CommentedMap
237 ) -> None:
238 """
239 Initializes a YAML dictionary.
241 :param root: Reference to the root node.
242 :param parent: Reference to the parent node.
243 :param key:
244 :param yamlNode: Reference to the YAML node.
245 """
246 Node.__init__(self, root, parent, key, yamlNode)
248 self._keys = [str(k) for k in yamlNode.keys()]
250 def __contains__(self, key: KeyT) -> bool:
251 """
252 Checks if the key is in this dictionary.
254 :param key: The key to check.
255 :returns: ``True``, if the key is in the dictionary.
256 """
257 return key in self._keys
259 def __iter__(self) -> typing_Iterator[ValueT]:
260 """
261 Returns an iterator to iterate dictionary keys.
263 :returns: Dictionary key iterator.
264 """
266 class Iterator(metaclass=ExtendedType, slots=True):
267 """Iterator to iterate dictionary items."""
269 _iter: typing_Iterator[ValueT]
270 _obj: Dictionary
272 def __init__(self, obj: Dictionary) -> None:
273 """
274 Initializes an iterator for a YAML dictionary node.
276 :param obj: YAML dictionary to iterate.
277 """
278 self._iter = iter(obj._keys)
279 self._obj = obj
281 def __iter__(self) -> Self:
282 """
283 Return itself to fulfil the iterator protocol.
285 :returns: Itself.
286 """
287 return self # pragma: no cover
289 def __next__(self) -> ValueT:
290 """
291 Returns the next item in the dictionary.
293 :returns: Next item.
294 """
295 key = next(self._iter)
296 return self._obj[key]
298 return Iterator(self)
301@export
302class Sequence(Node, Abstract_Seq):
303 """A sequence node (ordered list) in a YAML data file."""
305 def __init__(
306 self,
307 root: "Configuration",
308 parent: NodeT,
309 key: KeyT,
310 yamlNode: CommentedSeq
311 ) -> None:
312 """
313 Initializes a YAML sequence (list).
315 :param root: Reference to the root node.
316 :param parent: Reference to the parent node.
317 :param key:
318 :param yamlNode: Reference to the YAML node.
319 """
320 Node.__init__(self, root, parent, key, yamlNode)
322 self._length = len(yamlNode)
324 def __iter__(self) -> typing_Iterator[ValueT]:
325 """
326 Returns an iterator to iterate items in the sequence of sub-nodes.
328 :returns: Iterator to iterate items in a sequence.
329 """
330 class Iterator(metaclass=ExtendedType, slots=True):
331 """Iterator to iterate sequence items."""
333 _i: int #: internal iterator position
334 _obj: Sequence #: Sequence object to iterate
336 def __init__(self, obj: Sequence) -> None:
337 """
338 Initializes an iterator for a YAML sequence node.
340 :param obj: YAML sequence to iterate.
341 """
342 self._i = 0
343 self._obj = obj
345 def __iter__(self) -> Self:
346 """
347 Return itself to fulfil the iterator protocol.
349 :returns: Itself.
350 """
351 return self # pragma: no cover
353 def __next__(self) -> ValueT:
354 """
355 Returns the next item in the sequence.
357 :returns: Next item.
358 :raises StopIteration: If end of sequence is reached.
359 """
360 try:
361 result = self._obj[str(self._i)]
362 self._i += 1
363 return result
364 except IndexError:
365 raise StopIteration
367 return Iterator(self)
370setattr(Node, "DICT_TYPE", Dictionary)
371setattr(Node, "SEQ_TYPE", Sequence)
374@export
375class Configuration(Dictionary, Abstract_Configuration):
376 """A configuration read from a YAML file."""
378 _yamlConfig: YAML
380 def __init__(self, configFile: Path) -> None:
381 """
382 Initializes a configuration instance that reads a YAML file as input.
384 All sequence items or dictionaries key-value-pairs in the YAML file are accessible via Python's dictionary syntax.
386 :param configFile: Configuration file to read and parse.
387 """
388 if not configFile.exists(): 388 ↛ 389line 388 didn't jump to line 389 because the condition on line 388 was never true
389 raise ConfigurationException(f"JSON configuration file '{configFile}' not found.") from FileNotFoundError(configFile)
391 with configFile.open("r", encoding="utf-8") as file:
392 self._yamlConfig = YAML().load(file)
394 Dictionary.__init__(self, self, self, None, self._yamlConfig)
395 Abstract_Configuration.__init__(self, configFile)
397 def __getitem__(self, key: str) -> ValueT:
398 """
399 Access a configuration node by key.
401 :param key: The key to look for.
402 :returns: A node (sequence or dictionary) or scalar value (int, float, str).
403 """
404 return self._GetNodeOrValue(str(key))
406 def __setitem__(self, key: str, value: ValueT) -> None:
407 raise NotImplementedError()