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

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. 

33 

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" 

49 

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 

58 

59 

60try: 

61 from pyTooling.Decorators import export 

62except ModuleNotFoundError: # pragma: no cover 

63 print("[pyTooling.Common] Could not import from 'pyTooling.*'!") 

64 

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 

70 

71 

72@export 

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

74 """ 

75 Assemble the fully qualified name of a type. 

76 

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__ 

84 

85 try: 

86 name = obj.__qualname__ # for class or function 

87 except AttributeError: 

88 name = obj.__class__.__qualname__ 

89 

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 

93 

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

95 

96 

97@export 

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

99 """ 

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

101 

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 

111 

112 raise ToolingException(f"Resource file '{filename}' not found in resource '{module}'.") from FileNotFoundError(str(resourcePath)) 

113 

114 return resourcePath 

115 

116 

117@export 

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

119 """ 

120 Read a text file resource from resource package. 

121 

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

128 

129 

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

134 

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 

145 

146 return False 

147 

148 

149@export 

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

151 """ 

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

153 

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

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

156 

157 .. admonition:: Background Information 

158 

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. 

161 

162 .. seealso:: 

163 

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

165 

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 

171 

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

173 

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

175 """ 

176 Nested function for recursion. 

177 

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) 

187 

188 # Get objects raw size 

189 size: int = sys_getsizeof(obj) 

190 

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) 

208 

209 # Accumulate members from __dict__ 

210 if hasattr(obj, '__dict__'): 

211 v = vars(obj) 

212 size += recurse(v) 

213 

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

219 

220 return size 

221 

222 return recurse(obj) 

223 

224 

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

230 

231 :param instance: 

232 :param func: 

233 :param methodName: 

234 :return: 

235 """ 

236 if methodName is None: 

237 methodName = func.__name__ 

238 

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

240 setattr(instance, methodName, boundMethod) 

241 

242 return boundMethod 

243 

244 

245@export 

246def count(iterator: Iterable) -> int: 

247 """ 

248 Returns the number of elements in an iterable. 

249 

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

251 

252 :param iterator: Iterable to consume and count. 

253 :return: Number of elements in the iterable. 

254 """ 

255 return len(list(iterator)) 

256 

257 

258_Element = TypeVar("Element") 

259 

260 

261@export 

262def firstElement(indexable: Union[List[_Element], Tuple[_Element, ...]]) -> _Element: 

263 """ 

264 Returns the first element from an indexable. 

265 

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

267 :return: First element. 

268 """ 

269 return indexable[0] 

270 

271 

272@export 

273def lastElement(indexable: Union[List[_Element], Tuple[_Element, ...]]) -> _Element: 

274 """ 

275 Returns the last element from an indexable. 

276 

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

278 :return: Last element. 

279 """ 

280 return indexable[-1] 

281 

282 

283@export 

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

285 """ 

286 Returns the first item from an iterable. 

287 

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

297 

298 

299@export 

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

301 """ 

302 Returns the last item from an iterable. 

303 

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

313 

314 for element in i: 

315 pass 

316 return element 

317 

318 

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

326 

327 

328@export 

329def firstKey(d: Dict[_DictKey1, _DictValue1]) -> _DictKey1: 

330 """ 

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

332 

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

339 

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

341 

342 

343@export 

344def firstValue(d: Dict[_DictKey1, _DictValue1]) -> _DictValue1: 

345 """ 

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

347 

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

354 

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

356 

357 

358@export 

359def firstPair(d: Dict[_DictKey1, _DictValue1]) -> Tuple[_DictKey1, _DictValue1]: 

360 """ 

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

362 

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

369 

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

371 

372 

373@export 

374def mergedicts(*dicts: Dict, filter: Nullable[Callable[[Hashable, Any], bool]] = None) -> Dict: 

375 """ 

376 Merge multiple dictionaries into a single new dictionary. 

377 

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. 

380 

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. 

385 

386 .. seealso:: 

387 

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

392 

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

397 

398 

399@export 

400def zipdicts(*dicts: Dict) -> Generator[Tuple, None, None]: 

401 """ 

402 Iterate multiple dictionaries simultaneously. 

403 

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. 

409 

410 .. seealso:: 

411 

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

413 

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

418 

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

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

421 

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

426 

427 return gen(dicts) 

428 

429 

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. 

437 

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

439 """ 

440 Initializes the context manager for changing directories. 

441 

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

443 """ 

444 self._newWorkingDirectory = directory 

445 

446 def __enter__(self) -> Path: 

447 """ 

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

449 

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

451 """ 

452 self._oldWorkingDirectory = Path.cwd() 

453 chdir(self._newWorkingDirectory) 

454 

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

459 

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. 

468 

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)