Coverage for pyTooling/Common/__init__.py: 94%

201 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-10-25 13:06 +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.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" 

60 

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 

72 

73from pyTooling.Decorators import export 

74 

75 

76@export 

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

78 """ 

79 Assemble the fully qualified name of a type. 

80 

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. 

83 

84 .. seealso:: 

85 

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__ 

93 

94 try: 

95 name = obj.__qualname__ # for class or function 

96 except AttributeError: 

97 name = obj.__class__.__qualname__ 

98 

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 

102 

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

104 

105 

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. 

109 

110 

111@export 

112def getTypeName(obj: Any) -> str: 

113 """ 

114 Return the name of a type without its module. 

115 

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

120 

121 :param obj: The object whose type name is returned. 

122 :returns: The name of obj's type. 

123 

124 .. seealso:: 

125 

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__ 

131 

132 return obj.__class__.__name__ 

133 

134 

135@export 

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

137 """ 

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

139 

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 

146 

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 

151 

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

153 from FileNotFoundError(str(resourcePath)) 

154 

155 return resourcePath 

156 

157 

158@export 

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

160 """ 

161 Read a text file resource from resource package. 

162 

163 :param module: The resource package. 

164 :param filename: The filename. 

165 :returns: File content. 

166 """ 

167 from importlib.resources import files 

168 

169 # TODO: check if resource exists. 

170 return files(module).joinpath(filename).read_text() 

171 

172 

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

177 

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 

188 

189 return False 

190 

191 

192@export 

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

194 """ 

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

196 

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

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

199 

200 .. admonition:: Background Information 

201 

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. 

204 

205 .. seealso:: 

206 

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

208 

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. 

214 

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

216 """ 

217 Nested function for recursion. 

218 

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) 

228 

229 # Get objects raw size 

230 size: int = sys_getsizeof(obj) 

231 

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) 

247 

248 for key, value in itemView: 

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

250 

251 # Accumulate members from __dict__ 

252 if hasattr(obj, '__dict__'): 

253 v = vars(obj) 

254 size += recurse(v) 

255 

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

261 

262 return size 

263 

264 return recurse(obj) 

265 

266 

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

272 

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__ 

280 

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

282 setattr(instance, methodName, boundMethod) 

283 

284 return boundMethod 

285 

286 

287@export 

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

289 """ 

290 Returns the number of elements in an iterable. 

291 

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

293 

294 :param iterator: Iterable to consume and count. 

295 :returns: Number of elements in the iterable. 

296 """ 

297 return len(list(iterator)) 

298 

299 

300_Element = TypeVar("Element") 

301 

302 

303@export 

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

305 """ 

306 Returns the first element from an indexable. 

307 

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

309 :returns: First element. 

310 """ 

311 return indexable[0] 

312 

313 

314@export 

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

316 """ 

317 Returns the last element from an indexable. 

318 

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

320 :returns: Last element. 

321 """ 

322 return indexable[-1] 

323 

324 

325@export 

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

327 """ 

328 Returns the first item from an iterable. 

329 

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

339 

340 

341@export 

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

343 """ 

344 Returns the last item from an iterable. 

345 

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

355 

356 for element in i: 

357 pass 

358 return element 

359 

360 

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

368 

369 

370@export 

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

372 """ 

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

374 

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

381 

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

383 

384 

385@export 

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

387 """ 

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

389 

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

396 

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

398 

399 

400@export 

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

402 """ 

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

404 

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

411 

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

413 

414 

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. 

422 

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. 

425 

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. 

430 

431 .. seealso:: 

432 

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

437 

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

442 

443 

444@export 

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

446 """ 

447 Iterate multiple dictionaries simultaneously. 

448 

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. 

454 

455 .. seealso:: 

456 

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

458 

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

463 

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

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

466 

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. 

470 

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

476 

477 return gen(dicts) 

478 

479 

480@export 

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

482 """ 

483 Parse an ISO 8601 timestamp. 

484 

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. 

488 

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 

496 

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 

503 

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

505 timestamp = timestamp.replace(tzinfo=defaultTimeZone) 

506 

507 return timestamp 

508 

509 

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. 

514 

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

519 

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. 

524 

525 .. admonition:: ``example.py`` 

526 

527 .. code-block:: python 

528 

529 from pyTooling.Common import StringEnum 

530 

531 class GanttFormat(StringEnum): 

532 MatplotlibPNG = "matplotlib-png" 

533 MatplotlibSVG = "matplotlib-svg" 

534 

535 DEFAULT = MatplotlibPNG 

536 

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

538 GanttFormat.Parse(None) # GanttFormat.MatplotlibPNG 

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

540 

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. 

543 

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. 

548 

549 .. admonition:: ``GitHub.py`` 

550 

551 .. code-block:: python 

552 

553 class Status(StringEnum): 

554 Queued = "queued" 

555 

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 

564 

565 .. seealso:: 

566 

567 :class:`enum.StrEnum` 

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

569 """ 

570 

571 @classmethod 

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

573 """ 

574 Convert a string to the member carrying that value. 

575 

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 

593 

594 return cls(value) 

595 

596 

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. 

604 

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

606 """ 

607 Initializes the context manager for changing directories. 

608 

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

610 """ 

611 self._newWorkingDirectory = directory 

612 

613 def __enter__(self) -> Path: 

614 """ 

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

616 

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

618 """ 

619 self._oldWorkingDirectory = Path.cwd() 

620 chdir(self._newWorkingDirectory) 

621 

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

626 

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. 

635 

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)