Coverage for pyTooling/Common/__init__.py: 91%
166 statements
« prev ^ index » next coverage.py v7.10.3, created at 2025-08-12 20:40 +0000
« prev ^ index » next coverage.py v7.10.3, created at 2025-08-12 20:40 +0000
1# ==================================================================================================================== #
2# _____ _ _ ____ #
3# _ __ _ |_ _|__ ___ | (_)_ __ __ _ / ___|___ _ __ ___ _ __ ___ ___ _ __ #
4# | '_ \| | | || |/ _ \ / _ \| | | '_ \ / _` || | / _ \| '_ ` _ \| '_ ` _ \ / _ \| '_ \ #
5# | |_) | |_| || | (_) | (_) | | | | | | (_| || |__| (_) | | | | | | | | | | | (_) | | | | #
6# | .__/ \__, ||_|\___/ \___/|_|_|_| |_|\__, (_)____\___/|_| |_| |_|_| |_| |_|\___/|_| |_| #
7# |_| |___/ |___/ #
8# ==================================================================================================================== #
9# Authors: #
10# Patrick Lehmann #
11# #
12# License: #
13# ==================================================================================================================== #
14# Copyright 2017-2025 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:: See :ref:`high-level help <COMMON>` for explanations and usage examples.
35"""
36__author__ = "Patrick Lehmann"
37__email__ = "Paebbels@gmail.com"
38__copyright__ = "2017-2025, Patrick Lehmann"
39__license__ = "Apache License, Version 2.0"
40__version__ = "8.6.0"
41__keywords__ = [
42 "abstract", "argparse", "attributes", "bfs", "cli", "console", "data structure", "decorators", "dfs",
43 "double linked list", "exceptions", "file system statistics", "generators", "generic library", "generic path",
44 "geometry", "graph", "installation", "iterators", "licensing", "linked list", "message logging", "meta-classes",
45 "overloading", "override", "packaging", "path", "platform", "setuptools", "shapes", "shell", "singleton", "slots",
46 "terminal", "text user interface", "stopwatch", "tree", "TUI", "url", "versioning", "volumes", "wheel"
47]
48__issue_tracker__ = "https://GitHub.com/pyTooling/pyTooling/issues"
50from collections import deque
51from importlib.resources import files
52from numbers import Number
53from os import chdir
54from pathlib import Path
55from types import ModuleType, TracebackType
56from typing import Type, TypeVar, Callable, Generator, overload, Hashable, Optional, List
57from typing import Any, Dict, Tuple, Union, Mapping, Set, Iterable, Optional as Nullable
60try:
61 from pyTooling.Decorators import export
62except ModuleNotFoundError: # pragma: no cover
63 print("[pyTooling.Common] Could not import from 'pyTooling.*'!")
65 try:
66 from Decorators import export
67 except ModuleNotFoundError as ex: # pragma: no cover
68 print("[pyTooling.Common] Could not import directly!")
69 raise ex
72@export
73def getFullyQualifiedName(obj: Any) -> str:
74 """
75 Assemble the fully qualified name of a type.
77 :param obj: The object for with the fully qualified type is to be assembled.
78 :returns: The fully qualified name of obj's type.
79 """
80 try:
81 module = obj.__module__ # for class or function
82 except AttributeError:
83 module = obj.__class__.__module__
85 try:
86 name = obj.__qualname__ # for class or function
87 except AttributeError:
88 name = obj.__class__.__qualname__
90 # If obj is a method of builtin class, then module will be None
91 if module == "builtins" or module is None:
92 return name
94 return f"{module}.{name}"
97@export
98def getResourceFile(module: Union[str, ModuleType], filename: str) -> Path:
99 """
100 Compute the path to a file within a resource package.
102 :param module: The resource package.
103 :param filename: The filename.
104 :returns: Path to the resource's file.
105 :raises ToolingException: If resource file doesn't exist.
106 """
107 # TODO: files() has wrong TypeHint Traversible vs. Path
108 resourcePath: Path = files(module) / filename
109 if not resourcePath.exists():
110 from pyTooling.Exceptions import ToolingException
112 raise ToolingException(f"Resource file '{filename}' not found in resource '{module}'.") from FileNotFoundError(str(resourcePath))
114 return resourcePath
117@export
118def readResourceFile(module: Union[str, ModuleType], filename: str) -> str:
119 """
120 Read a text file resource from resource package.
122 :param module: The resource package.
123 :param filename: The filename.
124 :returns: File content.
125 """
126 # TODO: check if resource exists.
127 return files(module).joinpath(filename).read_text()
130@export
131def isnestedclass(cls: Type, scope: Type) -> bool:
132 """
133 Returns true, if the given class ``cls`` is a member on an outer class ``scope``.
135 :param cls: Class to check, if it's a nested class.
136 :param scope: Outer class which is the outer scope of ``cls``.
137 :returns: ``True``, if ``cls`` is a nested class within ``scope``.
138 """
139 for mroClass in scope.mro():
140 for memberName in mroClass.__dict__:
141 member = getattr(mroClass, memberName)
142 if isinstance(member, Type):
143 if cls is member:
144 return True
146 return False
149@export
150def getsizeof(obj: Any) -> int:
151 """
152 Recursively calculate the "true" size of an object including complex members like ``__dict__``.
154 :param obj: Object to calculate the size of.
155 :returns: True size of an object in bytes.
157 .. admonition:: Background Information
159 The function :func:`sys.getsizeof` only returns the raw size of a Python object and doesn't account for the
160 overhead of e.g. ``_dict__`` to store dynamically allocated object members.
162 .. seealso::
164 The code is based on code snippets and ideas from:
166 * `Compute Memory Footprint of an Object and its Contents <https://code.activestate.com/recipes/577504/>`__ (MIT Lizense)
167 * `How do I determine the size of an object in Python? <https://stackoverflow.com/a/30316760/3719459>`__ (CC BY-SA 4.0)
168 * `Python __slots__, slots, and object layout <https://github.com/mCodingLLC/VideosSampleCode/tree/master/videos/080_python_slots>`__ (MIT Lizense)
169 """
170 from sys import getsizeof as sys_getsizeof
172 visitedIDs = set() #: A set to track visited objects, so memory consumption isn't counted multiple times.
174 def recurse(obj: Any) -> int:
175 """
176 Nested function for recursion.
178 :param obj: Subobject to calculate the size of.
179 :returns: Size of a subobject in bytes.
180 """
181 # If already visited, return 0 bytes, so no additional bytes are accumulated
182 objectID = id(obj)
183 if objectID in visitedIDs:
184 return 0
185 else:
186 visitedIDs.add(objectID)
188 # Get objects raw size
189 size: int = sys_getsizeof(obj)
191 # Skip elementary types
192 if isinstance(obj, (str, bytes, bytearray, range, Number)):
193 pass
194 # Handle iterables
195 elif isinstance(obj, (tuple, list, Set, deque)): # TODO: What about builtin "set", "frozenset" and "dict"?
196 for item in obj:
197 size += recurse(item)
198 # Handle mappings
199 elif isinstance(obj, Mapping) or hasattr(obj, 'items'):
200 items = getattr(obj, 'items')
201 # Check if obj.items is a bound method.
202 if hasattr(items, "__self__"):
203 itemView = items()
204 else:
205 itemView = {} # bind(obj, items)
206 for key, value in itemView:
207 size += recurse(key) + recurse(value)
209 # Accumulate members from __dict__
210 if hasattr(obj, '__dict__'):
211 v = vars(obj)
212 size += recurse(v)
214 # Accumulate members from __slots__
215 if hasattr(obj, '__slots__') and obj.__slots__ is not None:
216 for slot in obj.__slots__:
217 if hasattr(obj, slot): 217 ↛ 216line 217 didn't jump to line 216 because the condition on line 217 was always true
218 size += recurse(getattr(obj, slot))
220 return size
222 return recurse(obj)
225def bind(instance, func, methodName: Nullable[str] = None):
226 """
227 Bind the function *func* to *instance*, with either provided name *as_name*
228 or the existing name of *func*. The provided *func* should accept the
229 instance as the first argument, i.e. "self".
231 :param instance:
232 :param func:
233 :param methodName:
234 :return:
235 """
236 if methodName is None:
237 methodName = func.__name__
239 boundMethod = func.__get__(instance, instance.__class__)
240 setattr(instance, methodName, boundMethod)
242 return boundMethod
245@export
246def count(iterator: Iterable) -> int:
247 """
248 Returns the number of elements in an iterable.
250 .. attention:: After counting the iterable's elements, the iterable is consumed.
252 :param iterator: Iterable to consume and count.
253 :return: Number of elements in the iterable.
254 """
255 return len(list(iterator))
258_Element = TypeVar("Element")
261@export
262def firstElement(indexable: Union[List[_Element], Tuple[_Element, ...]]) -> _Element:
263 """
264 Returns the first element from an indexable.
266 :param indexable: Indexable to get the first element from.
267 :return: First element.
268 """
269 return indexable[0]
272@export
273def lastElement(indexable: Union[List[_Element], Tuple[_Element, ...]]) -> _Element:
274 """
275 Returns the last element from an indexable.
277 :param indexable: Indexable to get the last element from.
278 :return: Last element.
279 """
280 return indexable[-1]
283@export
284def firstItem(iterable: Iterable[_Element]) -> _Element:
285 """
286 Returns the first item from an iterable.
288 :param iterable: Iterable to get the first item from.
289 :return: First item.
290 :raises ValueError: If parameter 'iterable' contains no items.
291 """
292 i = iter(iterable)
293 try:
294 return next(i)
295 except StopIteration:
296 raise ValueError(f"Iterable contains no items.")
299@export
300def lastItem(iterable: Iterable[_Element]) -> _Element:
301 """
302 Returns the last item from an iterable.
304 :param iterable: Iterable to get the last item from.
305 :return: Last item.
306 :raises ValueError: If parameter 'iterable' contains no items.
307 """
308 i = iter(iterable)
309 try:
310 element = next(i)
311 except StopIteration:
312 raise ValueError(f"Iterable contains no items.")
314 for element in i:
315 pass
316 return element
319_DictKey = TypeVar("_DictKey")
320_DictKey1 = TypeVar("_DictKey1")
321_DictKey2 = TypeVar("_DictKey2")
322_DictKey3 = TypeVar("_DictKey3")
323_DictValue1 = TypeVar("_DictValue1")
324_DictValue2 = TypeVar("_DictValue2")
325_DictValue3 = TypeVar("_DictValue3")
328@export
329def firstKey(d: Dict[_DictKey1, _DictValue1]) -> _DictKey1:
330 """
331 Retrieves the first key from a dictionary's keys.
333 :param d: Dictionary to get the first key from.
334 :returns: The first key.
335 :raises ValueError: If parameter 'd' is an empty dictionary.
336 """
337 if len(d) == 0:
338 raise ValueError(f"Dictionary is empty.")
340 return next(iter(d.keys()))
343@export
344def firstValue(d: Dict[_DictKey1, _DictValue1]) -> _DictValue1:
345 """
346 Retrieves the first value from a dictionary's values.
348 :param d: Dictionary to get the first value from.
349 :returns: The first value.
350 :raises ValueError: If parameter 'd' is an empty dictionary.
351 """
352 if len(d) == 0:
353 raise ValueError(f"Dictionary is empty.")
355 return next(iter(d.values()))
358@export
359def firstPair(d: Dict[_DictKey1, _DictValue1]) -> Tuple[_DictKey1, _DictValue1]:
360 """
361 Retrieves the first key-value-pair from a dictionary.
363 :param d: Dictionary to get the first key-value-pair from.
364 :returns: The first key-value-pair as tuple.
365 :raises ValueError: If parameter 'd' is an empty dictionary.
366 """
367 if len(d) == 0:
368 raise ValueError(f"Dictionary is empty.")
370 return next(iter(d.items()))
373@export
374def mergedicts(*dicts: Dict, filter: Nullable[Callable[[Hashable, Any], bool]] = None) -> Dict:
375 """
376 Merge multiple dictionaries into a single new dictionary.
378 If parameter ``filter`` isn't ``None``, then this function is applied to every element during the merge operation. If
379 it returns true, the dictionary element will be present in the resulting dictionary.
381 :param dicts: Tuple of dictionaries to merge as positional parameters.
382 :param filter: Optional filter function to apply to each dictionary element when merging.
383 :returns: A new dictionary containing the merge result.
384 :raises ValueError: If 'mergedicts' got called without any dictionaries parameters.
386 .. seealso::
388 `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>`__
389 """
390 if len(dicts) == 0:
391 raise ValueError(f"Called 'mergedicts' without any dictionary parameter.")
393 if filter is None:
394 return {k: v for d in dicts for k, v in d.items()}
395 else:
396 return {k: v for d in dicts for k, v in d.items() if filter(k, v)}
399@export
400def zipdicts(*dicts: Dict) -> Generator[Tuple, None, None]:
401 """
402 Iterate multiple dictionaries simultaneously.
404 :param dicts: Tuple of dictionaries to iterate as positional parameters.
405 :returns: A generator returning a tuple containing the key and values of each dictionary in the order of
406 given dictionaries.
407 :raises ValueError: If 'zipdicts' got called without any dictionary parameters.
408 :raises ValueError: If not all dictionaries have the same length.
410 .. seealso::
412 The code is based on code snippets and ideas from:
414 * `zipping together Python dicts <https://github.com/mCodingLLC/VideosSampleCode/tree/master/videos/101_zip_dict>`__ (MIT Lizense)
415 """
416 if len(dicts) == 0:
417 raise ValueError(f"Called 'zipdicts' without any dictionary parameter.")
419 if any(len(d) != len(dicts[0]) for d in dicts):
420 raise ValueError(f"All given dictionaries must have the same length.")
422 def gen(ds: Tuple[Dict, ...]) -> Generator[Tuple, None, None]:
423 for key, item0 in ds[0].items():
424 # WORKAROUND: using redundant parenthesis for Python 3.7 and pypy-3.10
425 yield key, item0, *(d[key] for d in ds[1:])
427 return gen(dicts)
430@export
431class ChangeDirectory:
432 """
433 A context manager for changing a directory.
434 """
435 _oldWorkingDirectory: Path #: Working directory before directory change.
436 _newWorkingDirectory: Path #: New working directory.
438 def __init__(self, directory: Path) -> None:
439 """
440 Initializes the context manager for changing directories.
442 :param directory: The new working directory to change into.
443 """
444 self._newWorkingDirectory = directory
446 def __enter__(self) -> Path:
447 """
448 Enter the context and change the working directory to the parameter given in the class initializer.
450 :returns: The relative path between old and new working directories.
451 """
452 self._oldWorkingDirectory = Path.cwd()
453 chdir(self._newWorkingDirectory)
455 if self._newWorkingDirectory.is_absolute(): 455 ↛ 456line 455 didn't jump to line 456 because the condition on line 455 was never true
456 return self._newWorkingDirectory.resolve()
457 else:
458 return (self._oldWorkingDirectory / self._newWorkingDirectory).resolve()
460 def __exit__(
461 self,
462 exc_type: Nullable[Type[BaseException]] = None,
463 exc_val: Nullable[BaseException] = None,
464 exc_tb: Nullable[TracebackType] = None
465 ) -> Nullable[bool]:
466 """
467 Exit the context and revert any working directory changes.
469 :param exc_type: Exception type
470 :param exc_val: Exception instance
471 :param exc_tb: Exception's traceback.
472 :returns: ``None``
473 """
474 chdir(self._oldWorkingDirectory)