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

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. 

33 

34.. hint:: 

35 

36 See :ref:`high-level help <COMMON>` for explanations and usage examples. 

37 

38.. seealso:: 

39 

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" 

60 

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 

71 

72from pyTooling.Decorators import export 

73 

74 

75@export 

76def getFullyQualifiedName(obj: Any) -> str: 

77 """ 

78 Assemble the fully qualified name of a type. 

79 

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__ 

87 

88 try: 

89 name = obj.__qualname__ # for class or function 

90 except AttributeError: 

91 name = obj.__class__.__qualname__ 

92 

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 

96 

97 return f"{module}.{name}" 

98 

99 

100@export 

101def getResourceFile(module: Union[str, ModuleType], filename: str) -> Path: 

102 """ 

103 Compute the path to a file within a resource package. 

104 

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 

114 

115 raise ToolingException(f"Resource file '{filename}' not found in resource '{module}'.") \ 

116 from FileNotFoundError(str(resourcePath)) 

117 

118 return resourcePath 

119 

120 

121@export 

122def readResourceFile(module: Union[str, ModuleType], filename: str) -> str: 

123 """ 

124 Read a text file resource from resource package. 

125 

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() 

132 

133 

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``. 

138 

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 

149 

150 return False 

151 

152 

153@export 

154def getsizeof(obj: Any) -> int: 

155 """ 

156 Recursively calculate the "true" size of an object including complex members like ``__dict__``. 

157 

158 :param obj: Object to calculate the size of. 

159 :returns: True size of an object in bytes. 

160 

161 .. admonition:: Background Information 

162 

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. 

165 

166 .. seealso:: 

167 

168 The code is based on code snippets and ideas from: 

169 

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 

175 

176 visitedIDs = set() #: A set to track visited objects, so memory consumption isn't counted multiple times. 

177 

178 def recurse(obj: Any) -> int: 

179 """ 

180 Nested function for recursion. 

181 

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) 

191 

192 # Get objects raw size 

193 size: int = sys_getsizeof(obj) 

194 

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) 

210 

211 for key, value in itemView: 

212 size += recurse(key) + recurse(value) 

213 

214 # Accumulate members from __dict__ 

215 if hasattr(obj, '__dict__'): 

216 v = vars(obj) 

217 size += recurse(v) 

218 

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)) 

224 

225 return size 

226 

227 return recurse(obj) 

228 

229 

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". 

235 

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__ 

243 

244 boundMethod = func.__get__(instance, instance.__class__) 

245 setattr(instance, methodName, boundMethod) 

246 

247 return boundMethod 

248 

249 

250@export 

251def count(iterator: Iterable[Any]) -> int: 

252 """ 

253 Returns the number of elements in an iterable. 

254 

255 .. attention:: After counting the iterable's elements, the iterable is consumed. 

256 

257 :param iterator: Iterable to consume and count. 

258 :returns: Number of elements in the iterable. 

259 """ 

260 return len(list(iterator)) 

261 

262 

263_Element = TypeVar("Element") 

264 

265 

266@export 

267def firstElement(indexable: Union[list[_Element], tuple[_Element, ...]]) -> _Element: 

268 """ 

269 Returns the first element from an indexable. 

270 

271 :param indexable: Indexable to get the first element from. 

272 :returns: First element. 

273 """ 

274 return indexable[0] 

275 

276 

277@export 

278def lastElement(indexable: Union[list[_Element], tuple[_Element, ...]]) -> _Element: 

279 """ 

280 Returns the last element from an indexable. 

281 

282 :param indexable: Indexable to get the last element from. 

283 :returns: Last element. 

284 """ 

285 return indexable[-1] 

286 

287 

288@export 

289def firstItem(iterable: Iterable[_Element]) -> _Element: 

290 """ 

291 Returns the first item from an iterable. 

292 

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.") 

302 

303 

304@export 

305def lastItem(iterable: Iterable[_Element]) -> _Element: 

306 """ 

307 Returns the last item from an iterable. 

308 

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.") 

318 

319 for element in i: 

320 pass 

321 return element 

322 

323 

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") 

331 

332 

333@export 

334def firstKey(d: dict[_DictKey1, _DictValue1]) -> _DictKey1: 

335 """ 

336 Retrieves the first key from a dictionary's keys. 

337 

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.") 

344 

345 return next(iter(d.keys())) 

346 

347 

348@export 

349def firstValue(d: dict[_DictKey1, _DictValue1]) -> _DictValue1: 

350 """ 

351 Retrieves the first value from a dictionary's values. 

352 

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.") 

359 

360 return next(iter(d.values())) 

361 

362 

363@export 

364def firstPair(d: dict[_DictKey1, _DictValue1]) -> tuple[_DictKey1, _DictValue1]: 

365 """ 

366 Retrieves the first key-value-pair from a dictionary. 

367 

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.") 

374 

375 return next(iter(d.items())) 

376 

377 

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. 

385 

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. 

388 

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. 

393 

394 .. seealso:: 

395 

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.") 

400 

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)} 

405 

406 

407@export 

408def zipdicts(*dicts: dict[Hashable, Any]) -> Generator[tuple[Any, ...], None, None]: 

409 """ 

410 Iterate multiple dictionaries simultaneously. 

411 

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. 

417 

418 .. seealso:: 

419 

420 The code is based on code snippets and ideas from: 

421 

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.") 

426 

427 if any(len(d) != len(dicts[0]) for d in dicts): 

428 raise ValueError("All given dictionaries must have the same length.") 

429 

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. 

433 

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:]) 

439 

440 return gen(dicts) 

441 

442 

443@export 

444def parseISO8601Timestamp(value: Nullable[str], defaultTimeZone: Nullable[tzinfo] = None) -> Nullable[datetime]: 

445 """ 

446 Parse an ISO 8601 timestamp. 

447 

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. 

451 

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 

459 

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 

466 

467 if defaultTimeZone is not None and timestamp.utcoffset() is None: 

468 timestamp = timestamp.replace(tzinfo=defaultTimeZone) 

469 

470 return timestamp 

471 

472 

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. 

477 

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`. 

482 

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. 

487 

488 .. admonition:: ``example.py`` 

489 

490 .. code-block:: python 

491 

492 from pyTooling.Common import StringEnum 

493 

494 class GanttFormat(StringEnum): 

495 MatplotlibPNG = "matplotlib-png" 

496 MatplotlibSVG = "matplotlib-svg" 

497 

498 DEFAULT = MatplotlibPNG 

499 

500 GanttFormat.Parse("matplotlib-svg") # GanttFormat.MatplotlibSVG 

501 GanttFormat.Parse(None) # GanttFormat.MatplotlibPNG 

502 GanttFormat.Parse("matplotlib-gif") # ValueError, listing the values it accepts 

503 

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. 

506 

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. 

511 

512 .. admonition:: ``GitHub.py`` 

513 

514 .. code-block:: python 

515 

516 class Status(StringEnum): 

517 Queued = "queued" 

518 

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 

527 

528 .. seealso:: 

529 

530 :class:`enum.StrEnum` 

531 |rarr| The standard library's string enumeration, which this extends. 

532 """ 

533 

534 @classmethod 

535 def Parse(cls, value: Nullable[str]) -> Nullable[Self]: 

536 """ 

537 Convert a string to the member carrying that value. 

538 

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 

557 

558 return cls(value) 

559 

560 

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. 

568 

569 def __init__(self, directory: Path) -> None: 

570 """ 

571 Initializes the context manager for changing directories. 

572 

573 :param directory: The new working directory to change into. 

574 """ 

575 self._newWorkingDirectory = directory 

576 

577 def __enter__(self) -> Path: 

578 """ 

579 Enter the context and change the working directory to the parameter given in the class initializer. 

580 

581 :returns: The relative path between old and new working directories. 

582 """ 

583 self._oldWorkingDirectory = Path.cwd() 

584 chdir(self._newWorkingDirectory) 

585 

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() 

590 

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. 

599 

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)