Coverage for pyTooling/Common/__init__.py: 94%
201 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 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.0rc9"
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 numbers import Number
65from os import chdir
66from pathlib import Path
67from sys import getsizeof as sys_getsizeof
68from types import ModuleType, TracebackType, FunctionType, MethodType, BuiltinFunctionType, \
69 MethodWrapperType, MethodDescriptorType, WrapperDescriptorType
70from typing import TypeVar, Callable, Generator, Hashable, Self, Any, Union, Mapping, Iterable, \
71 Optional as Nullable
73from pyTooling.Decorators import export
76@export
77def getFullyQualifiedName(obj: Any) -> str:
78 """
79 Assemble the fully qualified name of a type.
81 :param obj: The object for with the fully qualified type is to be assembled.
82 :returns: The fully qualified name of obj's type.
84 .. seealso::
86 :func:`~pyTooling.Common.getTypeName`
87 |rarr| Return the name of a type without its module.
88 """
89 try:
90 module = obj.__module__ # for class or function
91 except AttributeError:
92 module = obj.__class__.__module__
94 try:
95 name = obj.__qualname__ # for class or function
96 except AttributeError:
97 name = obj.__class__.__qualname__
99 # If obj is a method of builtin class, then module will be None
100 if module == "builtins" or module is None:
101 return name
103 return f"{module}.{name}"
106_NAMED_TYPES = (
107 type, FunctionType, MethodType, BuiltinFunctionType, MethodDescriptorType, WrapperDescriptorType, MethodWrapperType
108) #: Types of objects carrying their own name: classes, functions and methods, incl. built-in ones.
111@export
112def getTypeName(obj: Any) -> str:
113 """
114 Return the name of a type without its module.
116 A class, a function or a method (built-in ones included) returns its own name, every other object the name of its
117 class, like :pycode:`obj.__class__.__name__`. It's the name, not the qualified name: a nested class ``Outer.Inner``
118 returns ``Inner``. Exception messages name a class by its name, while a note reporting a wrong type uses
119 :func:`~pyTooling.Common.getFullyQualifiedName`.
121 :param obj: The object whose type name is returned.
122 :returns: The name of obj's type.
124 .. seealso::
126 :func:`~pyTooling.Common.getFullyQualifiedName`
127 |rarr| Assemble the fully qualified name of a type.
128 """
129 if isinstance(obj, _NAMED_TYPES):
130 return obj.__name__
132 return obj.__class__.__name__
135@export
136def getResourceFile(module: Union[str, ModuleType], filename: str) -> Path:
137 """
138 Compute the path to a file within a resource package.
140 :param module: The resource package.
141 :param filename: The filename.
142 :returns: Path to the resource's file.
143 :raises ToolingException: If resource file doesn't exist.
144 """
145 from importlib.resources import files
147 # TODO: files() has wrong TypeHint Traversible vs. Path
148 resourcePath: Path = files(module) / filename
149 if not resourcePath.exists(): 149 ↛ 150line 149 didn't jump to line 150 because the condition on line 149 was never true
150 from pyTooling.Exceptions import ToolingException
152 raise ToolingException(f"Resource file '{filename}' not found in resource '{module}'.") \
153 from FileNotFoundError(str(resourcePath))
155 return resourcePath
158@export
159def readResourceFile(module: Union[str, ModuleType], filename: str) -> str:
160 """
161 Read a text file resource from resource package.
163 :param module: The resource package.
164 :param filename: The filename.
165 :returns: File content.
166 """
167 from importlib.resources import files
169 # TODO: check if resource exists.
170 return files(module).joinpath(filename).read_text()
173@export
174def isnestedclass(cls: type, scope: type) -> bool:
175 """
176 Returns true, if the given class ``cls`` is a member on an outer class ``scope``.
178 :param cls: Class to check, if it's a nested class.
179 :param scope: Outer class which is the outer scope of ``cls``.
180 :returns: ``True``, if ``cls`` is a nested class within ``scope``.
181 """
182 for mroClass in scope.mro():
183 for memberName in mroClass.__dict__:
184 member = getattr(mroClass, memberName)
185 if isinstance(member, type):
186 if cls is member:
187 return True
189 return False
192@export
193def getsizeof(obj: Any) -> int:
194 """
195 Recursively calculate the "true" size of an object including complex members like ``__dict__``.
197 :param obj: Object to calculate the size of.
198 :returns: True size of an object in bytes.
200 .. admonition:: Background Information
202 The function :func:`sys.getsizeof` only returns the raw size of a Python object and doesn't account for the
203 overhead of e.g. ``_dict__`` to store dynamically allocated object members.
205 .. seealso::
207 The code is based on code snippets and ideas from:
209 * `Compute Memory Footprint of an Object and its Contents <https://code.activestate.com/recipes/577504/>`__ (MIT License)
210 * `How do I determine the size of an object in Python? <https://stackoverflow.com/a/30316760/3719459>`__ (CC BY-SA 4.0)
211 * :gh:`Python __slots__, slots, and object layout <mCodingLLC/VideosSampleCode/tree/master/videos/080_python_slots>` (MIT License)
212 """
213 visitedIDs = set() #: A set to track visited objects, so memory consumption isn't counted multiple times.
215 def recurse(obj: Any) -> int:
216 """
217 Nested function for recursion.
219 :param obj: Subobject to calculate the size of.
220 :returns: Size of a subobject in bytes.
221 """
222 # If already visited, return 0 bytes, so no additional bytes are accumulated
223 objectID = id(obj)
224 if objectID in visitedIDs:
225 return 0
226 else:
227 visitedIDs.add(objectID)
229 # Get objects raw size
230 size: int = sys_getsizeof(obj)
232 # Skip elementary types
233 if isinstance(obj, (str, bytes, bytearray, range, Number)):
234 pass
235 # Handle iterables
236 elif isinstance(obj, (tuple, list, set, deque)): # TODO: What about builtin "set", "frozenset" and "dict"?
237 for item in obj:
238 size += recurse(item)
239 # Handle mappings
240 elif isinstance(obj, Mapping) or hasattr(obj, 'items'):
241 items = getattr(obj, 'items')
242 # Check if obj.items is a bound method.
243 if hasattr(items, "__self__"): 243 ↛ 246line 243 didn't jump to line 246 because the condition on line 243 was always true
244 itemView = items()
245 else:
246 itemView = {} # bind(obj, items)
248 for key, value in itemView:
249 size += recurse(key) + recurse(value)
251 # Accumulate members from __dict__
252 if hasattr(obj, '__dict__'):
253 v = vars(obj)
254 size += recurse(v)
256 # Accumulate members from __slots__
257 if hasattr(obj, '__slots__') and obj.__slots__ is not None:
258 for slot in obj.__slots__:
259 if hasattr(obj, slot): 259 ↛ 258line 259 didn't jump to line 258 because the condition on line 259 was always true
260 size += recurse(getattr(obj, slot))
262 return size
264 return recurse(obj)
267def bind(instance: Any, func: Callable[..., Any], methodName: Nullable[str] = None) -> None:
268 """
269 Bind the function *func* to *instance*, with either provided name *as_name*
270 or the existing name of *func*. The provided *func* should accept the
271 instance as the first argument, i.e. "self".
273 :param instance: Object to bind the function to.
274 :param func: Function to bind. Its first parameter is the instance (``self``).
275 :param methodName: Optional, name to bind the function as. If ``None``, the function's own name is used.
276 :returns: The bound method.
277 """
278 if methodName is None:
279 methodName = func.__name__
281 boundMethod = func.__get__(instance, instance.__class__)
282 setattr(instance, methodName, boundMethod)
284 return boundMethod
287@export
288def count(iterator: Iterable[Any]) -> int:
289 """
290 Returns the number of elements in an iterable.
292 .. attention:: After counting the iterable's elements, the iterable is consumed.
294 :param iterator: Iterable to consume and count.
295 :returns: Number of elements in the iterable.
296 """
297 return len(list(iterator))
300_Element = TypeVar("Element")
303@export
304def firstElement(indexable: Union[list[_Element], tuple[_Element, ...]]) -> _Element:
305 """
306 Returns the first element from an indexable.
308 :param indexable: Indexable to get the first element from.
309 :returns: First element.
310 """
311 return indexable[0]
314@export
315def lastElement(indexable: Union[list[_Element], tuple[_Element, ...]]) -> _Element:
316 """
317 Returns the last element from an indexable.
319 :param indexable: Indexable to get the last element from.
320 :returns: Last element.
321 """
322 return indexable[-1]
325@export
326def firstItem(iterable: Iterable[_Element]) -> _Element:
327 """
328 Returns the first item from an iterable.
330 :param iterable: Iterable to get the first item from.
331 :returns: First item.
332 :raises ValueError: If parameter 'iterable' contains no items.
333 """
334 i = iter(iterable)
335 try:
336 return next(i)
337 except StopIteration:
338 raise ValueError("Iterable contains no items.")
341@export
342def lastItem(iterable: Iterable[_Element]) -> _Element:
343 """
344 Returns the last item from an iterable.
346 :param iterable: Iterable to get the last item from.
347 :returns: Last item.
348 :raises ValueError: If parameter 'iterable' contains no items.
349 """
350 i = iter(iterable)
351 try:
352 element = next(i)
353 except StopIteration:
354 raise ValueError("Iterable contains no items.")
356 for element in i:
357 pass
358 return element
361_DictKey = TypeVar("_DictKey")
362_DictKey1 = TypeVar("_DictKey1")
363_DictKey2 = TypeVar("_DictKey2")
364_DictKey3 = TypeVar("_DictKey3")
365_DictValue1 = TypeVar("_DictValue1")
366_DictValue2 = TypeVar("_DictValue2")
367_DictValue3 = TypeVar("_DictValue3")
370@export
371def firstKey(d: dict[_DictKey1, _DictValue1]) -> _DictKey1:
372 """
373 Retrieves the first key from a dictionary's keys.
375 :param d: Dictionary to get the first key from.
376 :returns: The first key.
377 :raises ValueError: If parameter 'd' is an empty dictionary.
378 """
379 if len(d) == 0:
380 raise ValueError("Dictionary is empty.")
382 return next(iter(d.keys()))
385@export
386def firstValue(d: dict[_DictKey1, _DictValue1]) -> _DictValue1:
387 """
388 Retrieves the first value from a dictionary's values.
390 :param d: Dictionary to get the first value from.
391 :returns: The first value.
392 :raises ValueError: If parameter 'd' is an empty dictionary.
393 """
394 if len(d) == 0:
395 raise ValueError("Dictionary is empty.")
397 return next(iter(d.values()))
400@export
401def firstPair(d: dict[_DictKey1, _DictValue1]) -> tuple[_DictKey1, _DictValue1]:
402 """
403 Retrieves the first key-value-pair from a dictionary.
405 :param d: Dictionary to get the first key-value-pair from.
406 :returns: The first key-value-pair as tuple.
407 :raises ValueError: If parameter 'd' is an empty dictionary.
408 """
409 if len(d) == 0:
410 raise ValueError("Dictionary is empty.")
412 return next(iter(d.items()))
415@export
416def mergedicts(
417 *dicts: dict[Hashable, Any],
418 filter: Nullable[Callable[[Hashable, Any], bool]] = None
419) -> dict[Hashable, Any]:
420 """
421 Merge multiple dictionaries into a single new dictionary.
423 If parameter ``filter`` isn't ``None``, then this function is applied to every element during the merge operation. If
424 it returns true, the dictionary element will be present in the resulting dictionary.
426 :param dicts: Tuple of dictionaries to merge as positional parameters.
427 :param filter: Optional, filter function to apply to each dictionary element when merging.
428 :returns: A new dictionary containing the merge result.
429 :raises ValueError: If 'mergedicts' got called without any dictionaries parameters.
431 .. seealso::
433 `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>`__
434 """
435 if len(dicts) == 0:
436 raise ValueError("Called 'mergedicts' without any dictionary parameter.")
438 if filter is None:
439 return {k: v for d in dicts for k, v in d.items()}
440 else:
441 return {k: v for d in dicts for k, v in d.items() if filter(k, v)}
444@export
445def zipdicts(*dicts: dict[Hashable, Any]) -> Generator[tuple[Any, ...], None, None]:
446 """
447 Iterate multiple dictionaries simultaneously.
449 :param dicts: Tuple of dictionaries to iterate as positional parameters.
450 :returns: A generator returning a tuple containing the key and values of each dictionary in the order of
451 given dictionaries.
452 :raises ValueError: If 'zipdicts' got called without any dictionary parameters.
453 :raises ValueError: If not all dictionaries have the same length.
455 .. seealso::
457 The code is based on code snippets and ideas from:
459 * :gh:`zipping together Python dicts <mCodingLLC/VideosSampleCode/tree/master/videos/101_zip_dict>` (MIT License)
460 """
461 if len(dicts) == 0:
462 raise ValueError("Called 'zipdicts' without any dictionary parameter.")
464 if any(len(d) != len(dicts[0]) for d in dicts):
465 raise ValueError("All given dictionaries must have the same length.")
467 def gen(ds: tuple[dict[Hashable, Any], ...]) -> Generator[tuple[Any, ...], None, None]:
468 """
469 Nested generator function, so the length check runs when :func:`zipdicts` is called, not on first iteration.
471 :param ds: The dictionaries to zip.
472 :returns: A generator yielding a tuple of the key and one value per dictionary.
473 """
474 for key, item0 in ds[0].items():
475 yield key, item0, *(d[key] for d in ds[1:])
477 return gen(dicts)
480@export
481def parseISO8601Timestamp(value: Nullable[str], defaultTimeZone: Nullable[tzinfo] = None) -> Nullable[datetime]:
482 """
483 Parse an ISO 8601 timestamp.
485 A timestamp carrying no UTC offset is naive, and a naive timestamp can't be compared with an aware one. Whether
486 that's a defect depends on where the timestamp came from, so the caller decides: a time zone given as
487 ``defaultTimeZone`` is attached to such a timestamp, while ``None`` leaves it naive.
489 :param value: The timestamp, e.g. ``'2026-09-15T06:35:24Z'``, or ``None``.
490 :param defaultTimeZone: Optional, time zone to attach to a timestamp that carries no UTC offset.
491 :returns: The timestamp, or ``None`` if the value is ``None`` or empty.
492 :raises ValueError: If the value isn't an ISO 8601 timestamp.
493 """
494 if value is None or value == "":
495 return None
497 try:
498 timestamp = datetime.fromisoformat(value)
499 except (TypeError, ValueError) as ex:
500 error = ValueError(f"'{value}' isn't an ISO 8601 timestamp.")
501 error.add_note("An ISO 8601 timestamp reads like '2026-09-15T06:35:24Z'.")
502 raise error from ex
504 if defaultTimeZone is not None and timestamp.utcoffset() is None:
505 timestamp = timestamp.replace(tzinfo=defaultTimeZone)
507 return timestamp
510@export
511class StringEnum(StrEnum):
512 """
513 A :class:`~enum.StrEnum` that converts a string to the member of that value, and says so when it can't.
515 Every enumeration whose members come from the outside - a command line, a configuration file, a REST reply -
516 needs the same three answers: what a missing value means, what a value of the wrong type is, and what a value
517 no member carries is. Written per enumeration, those answers drift; written here, an enumeration adds its
518 members and inherits :meth:`Parse`.
520 **A missing value is answered by the enumeration itself.** ``DEFAULT`` is an *alias* of the member that stands
521 for "nothing was given" - an alias, so it neither shows up when the enumeration is iterated nor becomes a
522 second member to compare against. An enumeration declaring none answers ``None`` instead, which is what a
523 field that may legitimately be absent wants.
525 .. admonition:: ``example.py``
527 .. code-block:: python
529 from pyTooling.Common import StringEnum
531 class GanttFormat(StringEnum):
532 MatplotlibPNG = "matplotlib-png"
533 MatplotlibSVG = "matplotlib-svg"
535 DEFAULT = MatplotlibPNG
537 GanttFormat.Parse("matplotlib-svg") # GanttFormat.MatplotlibSVG
538 GanttFormat.Parse(None) # GanttFormat.MatplotlibPNG
539 GanttFormat.Parse("matplotlib-gif") # ValueError, listing the values it accepts
541 Because it derives from :class:`~enum.StrEnum`, a member *is* its value: it goes into a message, a header or a
542 filename without being unwrapped, and :pycode:`", ".join(GanttFormat)` lists what an option accepts.
544 **An enumeration belonging to a domain with its own exception overrides** :meth:`Parse`, catches the
545 :exc:`ValueError` and chains it as the cause. ``Status`` of pyTooling.GitHub does that, because a value
546 a service sent that pyTooling doesn't know is that service's problem and not a programming error, and the
547 two are reported differently.
549 .. admonition:: ``GitHub.py``
551 .. code-block:: python
553 class Status(StringEnum):
554 Queued = "queued"
556 @classmethod
557 def Parse(cls, value: Nullable[str]) -> Nullable[Self]:
558 try:
559 return super().Parse(value)
560 except ValueError as ex:
561 error = GitHubError(f"'{value}' is not a GitHub status.")
562 error.add_note(f"Known: {', '.join(member.value for member in cls)}.")
563 raise error from ex
565 .. seealso::
567 :class:`enum.StrEnum`
568 |rarr| The standard library's string enumeration, which this extends.
569 """
571 @classmethod
572 def Parse(cls, value: Nullable[str]) -> Nullable[Self]:
573 """
574 Convert a string to the member carrying that value.
576 :param value: The string to convert. ``None`` and the empty string mean *no value was given*.
577 :returns: The member carrying that value. If no value was given: ``DEFAULT``, if the enumeration
578 declares one, otherwise ``None``.
579 :raises TypeError: If parameter 'value' is not of type :class:`str`.
580 :raises ValueError: If no member of this enumeration carries that value. |br|
581 The note lists the values it accepts.
582 """
583 if value is None or value == "":
584 return cls.__members__.get("DEFAULT", None)
585 elif not isinstance(value, str):
586 ex = TypeError("Parameter 'value' is not of type 'str'.")
587 ex.add_note(f"Got type '{getFullyQualifiedName(value)}'.")
588 raise ex
589 elif value not in cls._value2member_map_:
590 ex = ValueError(f"'{value}' is not a valid {cls.__name__}.")
591 ex.add_note(f"Allowed values: {', '.join(member.value for member in cls)}.")
592 raise ex
594 return cls(value)
597@export
598class ChangeDirectory:
599 """
600 A context manager for changing a directory.
601 """
602 _oldWorkingDirectory: Path #: Working directory before directory change.
603 _newWorkingDirectory: Path #: New working directory.
605 def __init__(self, directory: Path) -> None:
606 """
607 Initializes the context manager for changing directories.
609 :param directory: The new working directory to change into.
610 """
611 self._newWorkingDirectory = directory
613 def __enter__(self) -> Path:
614 """
615 Enter the context and change the working directory to the parameter given in the class initializer.
617 :returns: The relative path between old and new working directories.
618 """
619 self._oldWorkingDirectory = Path.cwd()
620 chdir(self._newWorkingDirectory)
622 if self._newWorkingDirectory.is_absolute(): 622 ↛ 623line 622 didn't jump to line 623 because the condition on line 622 was never true
623 return self._newWorkingDirectory.resolve()
624 else:
625 return (self._oldWorkingDirectory / self._newWorkingDirectory).resolve()
627 def __exit__(
628 self,
629 exc_type: Nullable[type[BaseException]] = None,
630 exc_val: Nullable[BaseException] = None,
631 exc_tb: Nullable[TracebackType] = None
632 ) -> Nullable[bool]:
633 """
634 Exit the context and revert any working directory changes.
636 :param exc_type: Optional, exception type.
637 :param exc_val: Optional, exception instance.
638 :param exc_tb: Optional, exception's traceback.
639 :returns: ``None``
640 """
641 chdir(self._oldWorkingDirectory)