Coverage for pyTooling/Common/__init__.py: 94%
195 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-03 23:02 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-03 23:02 +0000
1# ==================================================================================================================== #
2# _____ _ _ ____ #
3# _ __ _ |_ _|__ ___ | (_)_ __ __ _ / ___|___ _ __ ___ _ __ ___ ___ _ __ #
4# | '_ \| | | || |/ _ \ / _ \| | | '_ \ / _` || | / _ \| '_ ` _ \| '_ ` _ \ / _ \| '_ \ #
5# | |_) | |_| || | (_) | (_) | | | | | | (_| || |__| (_) | | | | | | | | | | | (_) | | | | #
6# | .__/ \__, ||_|\___/ \___/|_|_|_| |_|\__, (_)____\___/|_| |_| |_|_| |_| |_|\___/|_| |_| #
7# |_| |___/ |___/ #
8# ==================================================================================================================== #
9# Authors: #
10# Patrick Lehmann #
11# #
12# License: #
13# ==================================================================================================================== #
14# Copyright 2017-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"""
32Common types, helper functions and classes.
34.. hint::
36 See :ref:`high-level help <COMMON>` for explanations and usage examples.
38.. seealso::
40 :mod:`pyTooling.Decorators`
41 |rarr| Decorators used throughout the package.
42 :mod:`pyTooling.MetaClasses`
43 |rarr| The meta-class implementing slots, singletons and abstract classes.
44"""
45__author__ = "Patrick Lehmann"
46__email__ = "Paebbels@gmail.com"
47__copyright__ = "2017-2026, Patrick Lehmann"
48__license__ = "Apache License, Version 2.0"
49__version__ = "10.0.0"
50__keywords__ = [
51 "abstract", "argparse", "attributes", "bfs", "cli", "console", "data structure", "decorators", "dfs",
52 "double linked list", "exceptions", "file system statistics", "generators", "generic library", "generic path",
53 "geometry", "graph", "installation", "iterators", "licensing", "linked list", "message logging", "meta-classes",
54 "overloading", "override", "packaging", "path", "platform", "setuptools", "shapes", "shell", "singleton", "slots",
55 "terminal", "text user interface", "stopwatch", "tree", "TUI", "url", "versioning", "volumes", "warning", "wheel"
56]
57__project_url__ = "https://github.com/pyTooling/pyTooling"
58__documentation_url__ = "https://pyTooling.github.io/pyTooling"
59__issue_tracker_url__ = "https://GitHub.com/pyTooling/pyTooling/issues"
61from collections import deque
62from datetime import datetime, tzinfo
63from enum import StrEnum
64from importlib.resources import files
65from numbers import Number
66from os import chdir
67from pathlib import Path
68from types import ModuleType, TracebackType
69from typing import TypeVar, Callable, Generator, Hashable, Self
70from typing import Any, Union, Mapping, Iterable, Optional as Nullable
72from pyTooling.Decorators import export
75@export
76def getFullyQualifiedName(obj: Any) -> str:
77 """
78 Assemble the fully qualified name of a type.
80 :param obj: The object for with the fully qualified type is to be assembled.
81 :returns: The fully qualified name of obj's type.
82 """
83 try:
84 module = obj.__module__ # for class or function
85 except AttributeError:
86 module = obj.__class__.__module__
88 try:
89 name = obj.__qualname__ # for class or function
90 except AttributeError:
91 name = obj.__class__.__qualname__
93 # If obj is a method of builtin class, then module will be None
94 if module == "builtins" or module is None:
95 return name
97 return f"{module}.{name}"
100@export
101def getResourceFile(module: Union[str, ModuleType], filename: str) -> Path:
102 """
103 Compute the path to a file within a resource package.
105 :param module: The resource package.
106 :param filename: The filename.
107 :returns: Path to the resource's file.
108 :raises ToolingException: If resource file doesn't exist.
109 """
110 # TODO: files() has wrong TypeHint Traversible vs. Path
111 resourcePath: Path = files(module) / filename
112 if not resourcePath.exists(): 112 ↛ 113line 112 didn't jump to line 113 because the condition on line 112 was never true
113 from pyTooling.Exceptions import ToolingException
115 raise ToolingException(f"Resource file '{filename}' not found in resource '{module}'.") \
116 from FileNotFoundError(str(resourcePath))
118 return resourcePath
121@export
122def readResourceFile(module: Union[str, ModuleType], filename: str) -> str:
123 """
124 Read a text file resource from resource package.
126 :param module: The resource package.
127 :param filename: The filename.
128 :returns: File content.
129 """
130 # TODO: check if resource exists.
131 return files(module).joinpath(filename).read_text()
134@export
135def isnestedclass(cls: type, scope: type) -> bool:
136 """
137 Returns true, if the given class ``cls`` is a member on an outer class ``scope``.
139 :param cls: Class to check, if it's a nested class.
140 :param scope: Outer class which is the outer scope of ``cls``.
141 :returns: ``True``, if ``cls`` is a nested class within ``scope``.
142 """
143 for mroClass in scope.mro():
144 for memberName in mroClass.__dict__:
145 member = getattr(mroClass, memberName)
146 if isinstance(member, type):
147 if cls is member:
148 return True
150 return False
153@export
154def getsizeof(obj: Any) -> int:
155 """
156 Recursively calculate the "true" size of an object including complex members like ``__dict__``.
158 :param obj: Object to calculate the size of.
159 :returns: True size of an object in bytes.
161 .. admonition:: Background Information
163 The function :func:`sys.getsizeof` only returns the raw size of a Python object and doesn't account for the
164 overhead of e.g. ``_dict__`` to store dynamically allocated object members.
166 .. seealso::
168 The code is based on code snippets and ideas from:
170 * `Compute Memory Footprint of an Object and its Contents <https://code.activestate.com/recipes/577504/>`__ (MIT Lizense)
171 * `How do I determine the size of an object in Python? <https://stackoverflow.com/a/30316760/3719459>`__ (CC BY-SA 4.0)
172 * `Python __slots__, slots, and object layout <https://github.com/mCodingLLC/VideosSampleCode/tree/master/videos/080_python_slots>`__ (MIT Lizense)
173 """
174 from sys import getsizeof as sys_getsizeof
176 visitedIDs = set() #: A set to track visited objects, so memory consumption isn't counted multiple times.
178 def recurse(obj: Any) -> int:
179 """
180 Nested function for recursion.
182 :param obj: Subobject to calculate the size of.
183 :returns: Size of a subobject in bytes.
184 """
185 # If already visited, return 0 bytes, so no additional bytes are accumulated
186 objectID = id(obj)
187 if objectID in visitedIDs:
188 return 0
189 else:
190 visitedIDs.add(objectID)
192 # Get objects raw size
193 size: int = sys_getsizeof(obj)
195 # Skip elementary types
196 if isinstance(obj, (str, bytes, bytearray, range, Number)):
197 pass
198 # Handle iterables
199 elif isinstance(obj, (tuple, list, set, deque)): # TODO: What about builtin "set", "frozenset" and "dict"?
200 for item in obj:
201 size += recurse(item)
202 # Handle mappings
203 elif isinstance(obj, Mapping) or hasattr(obj, 'items'):
204 items = getattr(obj, 'items')
205 # Check if obj.items is a bound method.
206 if hasattr(items, "__self__"): 206 ↛ 209line 206 didn't jump to line 209 because the condition on line 206 was always true
207 itemView = items()
208 else:
209 itemView = {} # bind(obj, items)
211 for key, value in itemView:
212 size += recurse(key) + recurse(value)
214 # Accumulate members from __dict__
215 if hasattr(obj, '__dict__'):
216 v = vars(obj)
217 size += recurse(v)
219 # Accumulate members from __slots__
220 if hasattr(obj, '__slots__') and obj.__slots__ is not None:
221 for slot in obj.__slots__:
222 if hasattr(obj, slot): 222 ↛ 221line 222 didn't jump to line 221 because the condition on line 222 was always true
223 size += recurse(getattr(obj, slot))
225 return size
227 return recurse(obj)
230def bind(instance: Any, func: Callable[..., Any], methodName: Nullable[str] = None) -> None:
231 """
232 Bind the function *func* to *instance*, with either provided name *as_name*
233 or the existing name of *func*. The provided *func* should accept the
234 instance as the first argument, i.e. "self".
236 :param instance: Object to bind the function to.
237 :param func: Function to bind. Its first parameter is the instance (``self``).
238 :param methodName: Optional, name to bind the function as. If ``None``, the function's own name is used.
239 :returns: The bound method.
240 """
241 if methodName is None:
242 methodName = func.__name__
244 boundMethod = func.__get__(instance, instance.__class__)
245 setattr(instance, methodName, boundMethod)
247 return boundMethod
250@export
251def count(iterator: Iterable[Any]) -> int:
252 """
253 Returns the number of elements in an iterable.
255 .. attention:: After counting the iterable's elements, the iterable is consumed.
257 :param iterator: Iterable to consume and count.
258 :returns: Number of elements in the iterable.
259 """
260 return len(list(iterator))
263_Element = TypeVar("Element")
266@export
267def firstElement(indexable: Union[list[_Element], tuple[_Element, ...]]) -> _Element:
268 """
269 Returns the first element from an indexable.
271 :param indexable: Indexable to get the first element from.
272 :returns: First element.
273 """
274 return indexable[0]
277@export
278def lastElement(indexable: Union[list[_Element], tuple[_Element, ...]]) -> _Element:
279 """
280 Returns the last element from an indexable.
282 :param indexable: Indexable to get the last element from.
283 :returns: Last element.
284 """
285 return indexable[-1]
288@export
289def firstItem(iterable: Iterable[_Element]) -> _Element:
290 """
291 Returns the first item from an iterable.
293 :param iterable: Iterable to get the first item from.
294 :returns: First item.
295 :raises ValueError: If parameter 'iterable' contains no items.
296 """
297 i = iter(iterable)
298 try:
299 return next(i)
300 except StopIteration:
301 raise ValueError("Iterable contains no items.")
304@export
305def lastItem(iterable: Iterable[_Element]) -> _Element:
306 """
307 Returns the last item from an iterable.
309 :param iterable: Iterable to get the last item from.
310 :returns: Last item.
311 :raises ValueError: If parameter 'iterable' contains no items.
312 """
313 i = iter(iterable)
314 try:
315 element = next(i)
316 except StopIteration:
317 raise ValueError("Iterable contains no items.")
319 for element in i:
320 pass
321 return element
324_DictKey = TypeVar("_DictKey")
325_DictKey1 = TypeVar("_DictKey1")
326_DictKey2 = TypeVar("_DictKey2")
327_DictKey3 = TypeVar("_DictKey3")
328_DictValue1 = TypeVar("_DictValue1")
329_DictValue2 = TypeVar("_DictValue2")
330_DictValue3 = TypeVar("_DictValue3")
333@export
334def firstKey(d: dict[_DictKey1, _DictValue1]) -> _DictKey1:
335 """
336 Retrieves the first key from a dictionary's keys.
338 :param d: Dictionary to get the first key from.
339 :returns: The first key.
340 :raises ValueError: If parameter 'd' is an empty dictionary.
341 """
342 if len(d) == 0:
343 raise ValueError("Dictionary is empty.")
345 return next(iter(d.keys()))
348@export
349def firstValue(d: dict[_DictKey1, _DictValue1]) -> _DictValue1:
350 """
351 Retrieves the first value from a dictionary's values.
353 :param d: Dictionary to get the first value from.
354 :returns: The first value.
355 :raises ValueError: If parameter 'd' is an empty dictionary.
356 """
357 if len(d) == 0:
358 raise ValueError("Dictionary is empty.")
360 return next(iter(d.values()))
363@export
364def firstPair(d: dict[_DictKey1, _DictValue1]) -> tuple[_DictKey1, _DictValue1]:
365 """
366 Retrieves the first key-value-pair from a dictionary.
368 :param d: Dictionary to get the first key-value-pair from.
369 :returns: The first key-value-pair as tuple.
370 :raises ValueError: If parameter 'd' is an empty dictionary.
371 """
372 if len(d) == 0:
373 raise ValueError("Dictionary is empty.")
375 return next(iter(d.items()))
378@export
379def mergedicts(
380 *dicts: dict[Hashable, Any],
381 filter: Nullable[Callable[[Hashable, Any], bool]] = None
382) -> dict[Hashable, Any]:
383 """
384 Merge multiple dictionaries into a single new dictionary.
386 If parameter ``filter`` isn't ``None``, then this function is applied to every element during the merge operation. If
387 it returns true, the dictionary element will be present in the resulting dictionary.
389 :param dicts: Tuple of dictionaries to merge as positional parameters.
390 :param filter: Optional, filter function to apply to each dictionary element when merging.
391 :returns: A new dictionary containing the merge result.
392 :raises ValueError: If 'mergedicts' got called without any dictionaries parameters.
394 .. seealso::
396 `How do I merge two dictionaries in a single expression in Python? <https://stackoverflow.com/questions/38987/how-do-i-merge-two-dictionaries-in-a-single-expression-in-python>`__
397 """
398 if len(dicts) == 0:
399 raise ValueError("Called 'mergedicts' without any dictionary parameter.")
401 if filter is None:
402 return {k: v for d in dicts for k, v in d.items()}
403 else:
404 return {k: v for d in dicts for k, v in d.items() if filter(k, v)}
407@export
408def zipdicts(*dicts: dict[Hashable, Any]) -> Generator[tuple[Any, ...], None, None]:
409 """
410 Iterate multiple dictionaries simultaneously.
412 :param dicts: Tuple of dictionaries to iterate as positional parameters.
413 :returns: A generator returning a tuple containing the key and values of each dictionary in the order of
414 given dictionaries.
415 :raises ValueError: If 'zipdicts' got called without any dictionary parameters.
416 :raises ValueError: If not all dictionaries have the same length.
418 .. seealso::
420 The code is based on code snippets and ideas from:
422 * `zipping together Python dicts <https://github.com/mCodingLLC/VideosSampleCode/tree/master/videos/101_zip_dict>`__ (MIT Lizense)
423 """
424 if len(dicts) == 0:
425 raise ValueError("Called 'zipdicts' without any dictionary parameter.")
427 if any(len(d) != len(dicts[0]) for d in dicts):
428 raise ValueError("All given dictionaries must have the same length.")
430 def gen(ds: tuple[dict[Hashable, Any], ...]) -> Generator[tuple[Any, ...], None, None]:
431 """
432 Nested generator function, so the length check runs when :func:`zipdicts` is called, not on first iteration.
434 :param ds: The dictionaries to zip.
435 :returns: A generator yielding a tuple of the key and one value per dictionary.
436 """
437 for key, item0 in ds[0].items():
438 yield key, item0, *(d[key] for d in ds[1:])
440 return gen(dicts)
443@export
444def parseISO8601Timestamp(value: Nullable[str], defaultTimeZone: Nullable[tzinfo] = None) -> Nullable[datetime]:
445 """
446 Parse an ISO 8601 timestamp.
448 A timestamp carrying no UTC offset is naive, and a naive timestamp can't be compared with an aware one. Whether
449 that's a defect depends on where the timestamp came from, so the caller decides: a time zone given as
450 ``defaultTimeZone`` is attached to such a timestamp, while ``None`` leaves it naive.
452 :param value: The timestamp, e.g. ``'2026-09-15T06:35:24Z'``, or ``None``.
453 :param defaultTimeZone: Optional, time zone to attach to a timestamp that carries no UTC offset.
454 :returns: The timestamp, or ``None`` if the value is ``None`` or empty.
455 :raises ValueError: If the value isn't an ISO 8601 timestamp.
456 """
457 if value is None or value == "":
458 return None
460 try:
461 timestamp = datetime.fromisoformat(value)
462 except (TypeError, ValueError) as ex:
463 error = ValueError(f"'{value}' isn't an ISO 8601 timestamp.")
464 error.add_note("An ISO 8601 timestamp reads like '2026-09-15T06:35:24Z'.")
465 raise error from ex
467 if defaultTimeZone is not None and timestamp.utcoffset() is None:
468 timestamp = timestamp.replace(tzinfo=defaultTimeZone)
470 return timestamp
473@export
474class StringEnum(StrEnum):
475 """
476 A :class:`~enum.StrEnum` that converts a string to the member of that value, and says so when it can't.
478 Every enumeration whose members come from the outside - a command line, a configuration file, a REST reply -
479 needs the same three answers: what a missing value means, what a value of the wrong type is, and what a value
480 no member carries is. Written per enumeration, those answers drift; written here, an enumeration adds its
481 members and inherits :meth:`Parse`.
483 **A missing value is answered by the enumeration itself.** ``DEFAULT`` is an *alias* of the member that stands
484 for "nothing was given" - an alias, so it neither shows up when the enumeration is iterated nor becomes a
485 second member to compare against. An enumeration declaring none answers ``None`` instead, which is what a
486 field that may legitimately be absent wants.
488 .. admonition:: ``example.py``
490 .. code-block:: python
492 from pyTooling.Common import StringEnum
494 class GanttFormat(StringEnum):
495 MatplotlibPNG = "matplotlib-png"
496 MatplotlibSVG = "matplotlib-svg"
498 DEFAULT = MatplotlibPNG
500 GanttFormat.Parse("matplotlib-svg") # GanttFormat.MatplotlibSVG
501 GanttFormat.Parse(None) # GanttFormat.MatplotlibPNG
502 GanttFormat.Parse("matplotlib-gif") # ValueError, listing the values it accepts
504 Because it derives from :class:`~enum.StrEnum`, a member *is* its value: it goes into a message, a header or a
505 filename without being unwrapped, and :pycode:`", ".join(GanttFormat)` lists what an option accepts.
507 **An enumeration belonging to a domain with its own exception overrides** :meth:`Parse`, catches the
508 :exc:`ValueError` and chains it as the cause. :class:`pyTooling.CI.GitHub.Status` does that, because a value
509 a service sent that pyTooling doesn't know is that service's problem and not a programming error, and the
510 two are reported differently.
512 .. admonition:: ``GitHub.py``
514 .. code-block:: python
516 class Status(StringEnum):
517 Queued = "queued"
519 @classmethod
520 def Parse(cls, value: Nullable[str]) -> Nullable[Self]:
521 try:
522 return super().Parse(value)
523 except ValueError as ex:
524 error = GitHubError(f"'{value}' is not a GitHub status.")
525 error.add_note(f"Known: {', '.join(member.value for member in cls)}.")
526 raise error from ex
528 .. seealso::
530 :class:`enum.StrEnum`
531 |rarr| The standard library's string enumeration, which this extends.
532 """
534 @classmethod
535 def Parse(cls, value: Nullable[str]) -> Nullable[Self]:
536 """
537 Convert a string to the member carrying that value.
539 :param value: Optional, the string to convert. ``None`` and the empty string mean *no value was
540 given*. Default: ``None``.
541 :returns: The member carrying that value. If no value was given: ``DEFAULT``, if the enumeration
542 declares one, otherwise ``None``.
543 :raises TypeError: If parameter 'value' is not of type :class:`str`.
544 :raises ValueError: If no member of this enumeration carries that value. |br|
545 The note lists the values it accepts.
546 """
547 if value is None or value == "":
548 return cls.__members__.get("DEFAULT", None)
549 elif not isinstance(value, str):
550 ex = TypeError("Parameter 'value' is not of type 'str'.")
551 ex.add_note(f"Got type '{getFullyQualifiedName(value)}'.")
552 raise ex
553 elif value not in cls._value2member_map_:
554 ex = ValueError(f"'{value}' is not a valid {cls.__name__}.")
555 ex.add_note(f"Allowed values: {', '.join(member.value for member in cls)}.")
556 raise ex
558 return cls(value)
561@export
562class ChangeDirectory:
563 """
564 A context manager for changing a directory.
565 """
566 _oldWorkingDirectory: Path #: Working directory before directory change.
567 _newWorkingDirectory: Path #: New working directory.
569 def __init__(self, directory: Path) -> None:
570 """
571 Initializes the context manager for changing directories.
573 :param directory: The new working directory to change into.
574 """
575 self._newWorkingDirectory = directory
577 def __enter__(self) -> Path:
578 """
579 Enter the context and change the working directory to the parameter given in the class initializer.
581 :returns: The relative path between old and new working directories.
582 """
583 self._oldWorkingDirectory = Path.cwd()
584 chdir(self._newWorkingDirectory)
586 if self._newWorkingDirectory.is_absolute(): 586 ↛ 587line 586 didn't jump to line 587 because the condition on line 586 was never true
587 return self._newWorkingDirectory.resolve()
588 else:
589 return (self._oldWorkingDirectory / self._newWorkingDirectory).resolve()
591 def __exit__(
592 self,
593 exc_type: Nullable[type[BaseException]] = None,
594 exc_val: Nullable[BaseException] = None,
595 exc_tb: Nullable[TracebackType] = None
596 ) -> Nullable[bool]:
597 """
598 Exit the context and revert any working directory changes.
600 :param exc_type: Exception type
601 :param exc_val: Exception instance
602 :param exc_tb: Exception's traceback.
603 :returns: ``None``
604 """
605 chdir(self._oldWorkingDirectory)