Coverage for pyTooling/Configuration/__init__.py: 99%
135 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"""
32Abstract configuration reader.
34.. hint::
36 See :ref:`high-level help <CONFIG>` for explanations and usage examples.
38.. seealso::
40 :mod:`pyTooling.Configuration.JSON`
41 |rarr| A configuration read from a JSON file.
42 :mod:`pyTooling.Configuration.YAML`
43 |rarr| A configuration read from a YAML file.
44 :mod:`pyTooling.GenericPath`
45 |rarr| The path expressions a configuration is queried with.
46"""
47from __future__ import annotations
49from pathlib import Path
50from typing import Any, Union, ClassVar, Generator, Iterator, Optional as Nullable, Tuple
52from pyTooling.Common import getTypeName
53from pyTooling.Decorators import export, readonly
54from pyTooling.MetaClasses import ExtendedType, abstractmethod, mixin
55from pyTooling.Exceptions import ConfigurationError
58__all__ = ["KeyT", "NodeT", "ValueT"]
61KeyT = Union[str, int] #: Type variable for keys.
62NodeT = Union["Dictionary", "Sequence"] #: Type variable for nodes.
63ValueT = Nullable[Union[NodeT, str, int, float]] #: Type variable for values; ``None`` for a null value.
66@export
67class KeyNotFoundError(ConfigurationError, KeyError):
68 """
69 The requested key or index doesn't exist in the configuration node.
71 The key was neither found as a string, nor converted to an integer or float. A note lists the keys or the index
72 range offered by the node.
74 It is a :exc:`KeyError` as well, because a dictionary node answers the mapping protocol and the code reading it
75 writes ``except KeyError``. Catching :exc:`~pyTooling.Exceptions.ConfigurationError` still catches it.
76 """
78 _key: Nullable[KeyT] #: Key or index that wasn't found.
80 def __init__(self, message: str, /, *, key: Nullable[KeyT] = None) -> None:
81 """
82 Initializes the exception with the key or index that wasn't found.
84 :param message: The exception's message.
85 :param key: Optional, the key or index that wasn't found.
86 """
87 super().__init__(message)
88 self._key = key
90 @readonly
91 def Key(self) -> Nullable[KeyT]:
92 """
93 Read-only property to access the key or index that wasn't found (:attr:`_key`).
95 :returns: The missing key or index, or ``None`` if it wasn't recorded.
96 """
97 return self._key
99 def __str__(self) -> str:
100 """
101 Return the exception's message.
103 :returns: The exception's message.
104 """
105 return self.Message
108@export
109class UnsupportedValueTypeError(ConfigurationError, TypeError):
110 """
111 The configuration file parser returned a value of a type that isn't supported by :mod:`pyTooling.Configuration`.
113 Supported are scalars (:class:`str`, :class:`int`, :class:`float`) and the parser's dictionary and sequence types.
115 It is a :exc:`TypeError` as well, because the value's type is what's wrong. Catching
116 :exc:`~pyTooling.Exceptions.ConfigurationError` still catches it.
117 """
119 _value: Any #: Value of the unsupported type, as returned by the parser.
121 def __init__(self, message: str, /, *, value: Any = None) -> None:
122 """
123 Initializes the exception with the value of the unsupported type.
125 :param message: The exception's message.
126 :param value: Optional, the value of the unsupported type, as returned by the parser.
127 """
128 super().__init__(message)
129 self._value = value
131 @readonly
132 def Value(self) -> Any:
133 """
134 Read-only property to access the value of the unsupported type (:attr:`_value`).
136 :returns: The value as returned by the parser, or ``None`` if it wasn't recorded.
137 """
138 return self._value
140 @readonly
141 def ValueType(self) -> Nullable[type]:
142 """
143 Read-only property to return the unsupported type, the type of :attr:`_value`.
145 :returns: The type of the value, or ``None`` if the value wasn't recorded.
146 """
147 if self._value is None:
148 return None
150 return type(self._value)
153@export
154class InterpolationError(ConfigurationError):
155 """A variable reference (``${...}``) in a configuration value is malformed or can't be resolved."""
157 _value: Nullable[str] #: Configuration value containing the malformed variable reference.
158 _position: Nullable[int] #: Position of the malformed ``$`` in the value.
160 def __init__(self, message: str, /, *, value: Nullable[str] = None, position: Nullable[int] = None) -> None:
161 """
162 Initializes the exception with the value and the position of the malformed variable reference.
164 :param message: The exception's message.
165 :param value: Optional, the configuration value containing the malformed variable reference.
166 :param position: Optional, the position of the malformed ``$`` in the value.
167 """
168 super().__init__(message)
169 self._value = value
170 self._position = position
172 @readonly
173 def Value(self) -> Nullable[str]:
174 """
175 Read-only property to access the configuration value containing the malformed variable reference (:attr:`_value`).
177 :returns: The value, or ``None`` if it wasn't recorded.
178 """
179 return self._value
181 @readonly
182 def Position(self) -> Nullable[int]:
183 """
184 Read-only property to access the position of the malformed ``$`` in the value (:attr:`_position`).
186 :returns: The position, or ``None`` if it wasn't recorded.
187 """
188 return self._position
191@export
192class PathExpressionError(ConfigurationError):
193 """A path expression (``a:b:c``) doesn't describe a valid node or value in the configuration."""
195 _pathExpression: Nullable[str] #: Path expression that doesn't describe a valid node or value.
197 def __init__(self, message: str, /, *, pathExpression: Nullable[str] = None) -> None:
198 """
199 Initializes the exception with the path expression that doesn't describe a valid node or value.
201 :param message: The exception's message.
202 :param pathExpression: Optional, the path expression, with its elements separated by ``:``.
203 """
204 super().__init__(message)
205 self._pathExpression = pathExpression
207 @readonly
208 def PathExpression(self) -> Nullable[str]:
209 """
210 Read-only property to access the path expression (:attr:`_pathExpression`).
212 :returns: The path expression, or ``None`` if it wasn't recorded.
213 """
214 return self._pathExpression
217@export
218class Node(metaclass=ExtendedType, slots=True):
219 """Abstract node in a configuration data structure."""
221 DICT_TYPE: ClassVar[type[Dictionary]] #: Type reference used when instantiating new dictionaries
222 SEQ_TYPE: ClassVar[type[Sequence]] #: Type reference used when instantiating new sequences
223 _root: Configuration #: Reference to the root node; the root node refers to itself.
224 _parent: Nullable[Dictionary] #: Reference to a parent node; ``None`` for the root node.
226 def __init__(self, root: Nullable[Configuration] = None, parent: Nullable[NodeT] = None) -> None:
227 """
228 Initializes a node.
230 :param root: Optional, reference to the root node.
231 :param parent: Optional, reference to the parent node, or ``None`` for the root node.
232 """
233 self._root = root
234 self._parent = parent
236 @abstractmethod
237 def __len__(self) -> int: # type: ignore[empty-body]
238 """
239 Returns the number of sub-elements.
241 :returns: Number of sub-elements.
242 """
244 @abstractmethod
245 def __getitem__(self, key: KeyT) -> ValueT: # type: ignore[empty-body]
246 """
247 Access an element in the node by index or key.
249 :param key: Index or key of the element.
250 :returns: A node (sequence or dictionary) or scalar value (int, float, str).
251 """
253 def __setitem__(self, key: KeyT, value: ValueT) -> None:
254 """
255 Set an element in the node by index or key.
257 .. attention::
259 A configuration is **read-only**: the file format doesn't implements writing.
261 :param key: Index or key of the element.
262 :param value: The new value of that element.
263 :raises NotImplementedError: Always - a configuration is read-only.
264 """
265 raise NotImplementedError("Currently, the configuration is read-only. Writing isn't implemented.")
267 @abstractmethod
268 def __iter__(self) -> Iterator[ValueT]: # type: ignore[empty-body]
269 """
270 Returns an iterator to iterate a node.
272 :returns: Node iterator.
273 """
275 @property
276 def Key(self) -> KeyT:
277 """
278 Property to access the node's key.
280 :returns: Key of the node.
281 :raises NotImplementedError: If a deriving class doesn't implement this property.
282 """
283 raise NotImplementedError(f"Property 'Key' is abstract and not implemented by '{getTypeName(self)}'.")
285 @Key.setter
286 def Key(self, value: KeyT) -> None:
287 raise NotImplementedError("Renaming a key isn't supported by a configuration.")
289 @abstractmethod
290 def QueryPath(self, query: str) -> ValueT: # type: ignore[empty-body]
291 """
292 Return a node or value based on a path description to that node or value.
294 :param query: String describing the path to the node or value.
295 :returns: A node (sequence or dictionary) or scalar value (int, float, str).
296 """
299@export
300@mixin
301class Dictionary(Node):
302 """Abstract dictionary node in a configuration."""
304 _keys: list[KeyT] #: Keys of this dictionary node, in the order the document states them.
306 def __init__(self, keys: list[KeyT]) -> None:
307 """
308 Initializes the dictionary's keys.
310 A deriving class reads the keys from whatever it parsed and hands them over, so this node is never left
311 without them. Being a mixin's constructor, it initializes only the field this mixin adds.
313 :param keys: Keys of this dictionary node, in the order the document states them.
314 """
315 self._keys = keys
317 def __contains__(self, key: KeyT) -> bool:
318 """
319 Check if a key exists in this dictionary node.
321 :param key: The key to check for.
322 :returns: ``True``, if the key exists in this node.
323 """
324 return key in self._keys
326 def IterateKeys(self) -> Generator[KeyT, None, None]:
327 """
328 Iterate the keys of this dictionary node.
330 :returns: A generator of this node's keys, in the order the document states them.
331 """
332 yield from self._keys
334 def IterateValues(self) -> Generator[ValueT, None, None]:
335 """
336 Iterate the values of this dictionary node.
338 This is what :meth:`__iter__` yields, so ``for value in node`` and ``for value in node.IterateValues()`` are
339 the same walk. It exists so that the three iterators can be named alike and a reader doesn't have to remember
340 which of keys or values plain iteration gives.
342 :returns: A generator of this node's values, in the order the document states them.
343 """
344 for key in self._keys:
345 yield self[key]
347 def IterateItems(self) -> Generator[Tuple[KeyT, ValueT], None, None]:
348 """
349 Iterate the key-value pairs of this dictionary node.
351 :returns: A generator of this node's ``(key, value)`` pairs, in the order the document states them.
352 """
353 for key in self._keys:
354 yield key, self[key]
356 def keys(self) -> Tuple[KeyT, ...]:
357 """
358 Return this node's keys, so a dictionary node can be handed to code expecting a mapping.
360 This is :meth:`IterateKeys` materialized. The name is :class:`dict`'s, deliberately: :class:`dict` itself
361 looks for a ``keys`` method to decide whether an object is a mapping, so ``dict(node)`` and ``{**node}``
362 work because this exists.
364 :returns: This node's keys, in the order the document states them.
365 """
366 return tuple(self._keys)
368 def values(self) -> Tuple[ValueT, ...]:
369 """
370 Return this node's values, so a dictionary node can be handed to code expecting a mapping.
372 This is :meth:`IterateValues` materialized.
374 :returns: This node's values, in the order the document states them.
375 """
376 return tuple([self[key] for key in self._keys])
378 def items(self) -> Tuple[Tuple[KeyT, ValueT], ...]:
379 """
380 Return this node's key-value pairs, so a dictionary node can be handed to code expecting a mapping.
382 This is :meth:`IterateItems` materialized.
384 :returns: This node's ``(key, value)`` pairs, in the order the document states them.
385 """
386 return tuple([(key, self[key]) for key in self._keys])
388 def get(self, key: KeyT, default: Nullable[ValueT] = None) -> Nullable[ValueT]:
389 """
390 Return the value a key names, or a default when this node doesn't state that key.
392 :param key: The key to read.
393 :param default: Optional, what to return when the key isn't stated. Defaults to ``None``.
394 :returns: The value the key names, or ``default``.
395 """
396 return self[key] if key in self else default
399@export
400@mixin
401class Sequence(Node):
402 """Abstract sequence node in a configuration."""
404 def __init__(self, root: Nullable[Configuration] = None, parent: Nullable[NodeT] = None) -> None:
405 """
406 Initializes a sequence.
408 :param root: Optional, reference to the root node.
409 :param parent: Optional, reference to the parent node.
410 """
411 Node.__init__(self, root, parent)
413 @abstractmethod
414 def __getitem__(self, index: int) -> ValueT: # type: ignore[empty-body]
415 """
416 Read an element of this sequence node by index.
418 :param index: Index of the element to read.
419 :returns: A node (sequence or dictionary) or scalar value (int, float, str).
420 """
422 def __setitem__(self, index: int, value: ValueT) -> None:
423 """
424 Write an element of this sequence node by index.
426 .. attention::
428 A configuration is **read-only** - see :meth:`Node.__setitem__`.
430 :param index: Index of the element to write.
431 :param value: The new value of that element.
432 :raises NotImplementedError: Always - a configuration is read-only.
433 """
434 raise NotImplementedError("Currently, the configuration is read-only. Writing isn't implemented.")
436 def index(self, value: ValueT, start: int = 0, stop: Nullable[int] = None) -> int:
437 """
438 Return the index of the first element equal to a value, so a sequence node reads like a :class:`list`.
440 :param value: The value to search for.
441 :param start: Optional, index to start searching at. Defaults to ``0``.
442 :param stop: Optional, index to stop searching before. Defaults to the end of this node.
443 :returns: Index of the first matching element.
444 :raises ValueError: If no element in the searched range equals the value.
445 """
446 length = len(self)
447 start = max(0, length + start if start < 0 else start)
448 stop = length if stop is None else min(length, length + stop if stop < 0 else stop)
450 for index in range(start, stop):
451 if self[index] == value:
452 return index
454 raise ValueError(f"'{value}' is not in this sequence node.")
456 def count(self, value: ValueT) -> int:
457 """
458 Return how many elements of this node equal a value, so a sequence node reads like a :class:`list`.
460 :param value: The value to count.
461 :returns: Number of matching elements.
462 """
463 return sum(1 for element in self if element == value)
466setattr(Node, "DICT_TYPE", Dictionary)
467setattr(Node, "SEQ_TYPE", Sequence)
470@export
471@mixin
472class Configuration(Node):
473 """Abstract root node in a configuration."""
475 _configFile: Path #: Path to the configuration file.
477 def __init__(self, configFile: Path) -> None:
478 """
479 Initializes a configuration, the root node of the tree.
481 The root node refers to itself as the root and has no parent.
483 :param configFile: Configuration file.
484 """
485 Node.__init__(self, self, None)
487 self._configFile = configFile
489 @readonly
490 def ConfigFile(self) -> Path:
491 """
492 Read-only property to access the configuration file's path.
494 :returns: Path to the configuration file.
495 """
496 return self._configFile