Coverage for pyTooling / MetaClasses / __init__.py: 84%

439 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-23 22:21 +0000

1# ==================================================================================================================== # 

2# _____ _ _ __ __ _ ____ _ # 

3# _ __ _ |_ _|__ ___ | (_)_ __ __ _ | \/ | ___| |_ __ _ / ___| | __ _ ___ ___ ___ ___ # 

4# | '_ \| | | || |/ _ \ / _ \| | | '_ \ / _` | | |\/| |/ _ \ __/ _` | | | |/ _` / __/ __|/ _ \/ __| # 

5# | |_) | |_| || | (_) | (_) | | | | | | (_| |_| | | | __/ || (_| | |___| | (_| \__ \__ \ __/\__ \ # 

6# | .__/ \__, ||_|\___/ \___/|_|_|_| |_|\__, (_)_| |_|\___|\__\__,_|\____|_|\__,_|___/___/\___||___/ # 

7# |_| |___/ |___/ # 

8# ==================================================================================================================== # 

9# Authors: # 

10# Patrick Lehmann # 

11# Sven Köhler # 

12# # 

13# License: # 

14# ==================================================================================================================== # 

15# Copyright 2017-2026 Patrick Lehmann - Bötzingen, Germany # 

16# # 

17# Licensed under the Apache License, Version 2.0 (the "License"); # 

18# you may not use this file except in compliance with the License. # 

19# You may obtain a copy of the License at # 

20# # 

21# http://www.apache.org/licenses/LICENSE-2.0 # 

22# # 

23# Unless required by applicable law or agreed to in writing, software # 

24# distributed under the License is distributed on an "AS IS" BASIS, # 

25# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # 

26# See the License for the specific language governing permissions and # 

27# limitations under the License. # 

28# # 

29# SPDX-License-Identifier: Apache-2.0 # 

30# ==================================================================================================================== # 

31# 

32""" 

33The MetaClasses package implements Python meta-classes (classes to construct other classes in Python). 

34 

35.. hint:: 

36 

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

38""" 

39from functools import wraps 

40from itertools import chain 

41from sys import version_info 

42from threading import Condition 

43from types import FunctionType, MethodType 

44from typing import Any, Tuple, List, Dict, Callable, Generator, Set, Iterator, Iterable, Union, NoReturn, Self 

45from typing import Type, TypeVar, Generic, _GenericAlias, ClassVar, Optional as Nullable 

46 

47try: 

48 from pyTooling.Exceptions import ToolingException 

49 from pyTooling.Decorators import export, readonly 

50except (ImportError, ModuleNotFoundError): # pragma: no cover 

51 print("[pyTooling.MetaClasses] Could not import from 'pyTooling.*'!") 

52 

53 try: 

54 from Exceptions import ToolingException 

55 from Decorators import export, readonly 

56 except (ImportError, ModuleNotFoundError) as ex: # pragma: no cover 

57 print("[pyTooling.MetaClasses] Could not import directly!") 

58 raise ex 

59 

60 

61__all__ = ["M"] 

62 

63TAttr = TypeVar("TAttr") # , bound='Attribute') 

64"""A type variable for :class:`~pyTooling.Attributes.Attribute`.""" 

65 

66TAttributeFilter = Union[TAttr, Iterable[TAttr], None] 

67"""A type hint for a predicate parameter that accepts either a single :class:`~pyTooling.Attributes.Attribute` or an 

68iterable of those.""" 

69 

70 

71@export 

72class ExtendedTypeError(ToolingException): 

73 """The exception is raised by the meta-class :class:`~pyTooling.Metaclasses.ExtendedType`.""" 

74 

75 

76@export 

77class BaseClassWithoutSlotsError(ExtendedTypeError): 

78 """ 

79 This exception is raised when a class using ``__slots__`` inherits from at-least one base-class not using ``__slots__``. 

80 

81 .. seealso:: 

82 

83 * :ref:`Python data model for slots <slots>` 

84 * :term:`Glossary entry __slots__ <__slots__>` 

85 """ 

86 

87 

88@export 

89class BaseClassWithNonEmptySlotsError(ExtendedTypeError): 

90 """ 

91 This exception is raised when a mixin-class uses slots, but Python prohibits slots. 

92 

93 .. important:: 

94 

95 To fulfill Python's requirements on slots, pyTooling uses slots only on the prinmary inheritance line. 

96 Mixin-classes collect slots, which get materialized when the mixin-class (secondary inheritance lines) gets merged 

97 into the primary inheritance line. 

98 """ 

99 

100 

101@export 

102class BaseClassIsNotAMixinError(ExtendedTypeError): 

103 pass 

104 

105 

106@export 

107class DuplicateFieldInSlotsError(ExtendedTypeError): 

108 """ 

109 This exception is raised when a slot name is used multiple times within the inheritance hierarchy. 

110 """ 

111 

112 

113@export 

114class AbstractClassError(ExtendedTypeError): 

115 """ 

116 This exception is raised, when a class contains methods marked with *abstractmethod* or *must-override*. 

117 

118 .. seealso:: 

119 

120 :func:`@abstractmethod <pyTooling.MetaClasses.abstractmethod>` 

121 |rarr| Mark a method as *abstract*. 

122 :func:`@mustoverride <pyTooling.MetaClasses.mustoverride>` 

123 |rarr| Mark a method as *must overrride*. 

124 :exc:`~MustOverrideClassError` 

125 |rarr| Exception raised, if a method is marked as *must-override*. 

126 """ 

127 

128 

129@export 

130class MustOverrideClassError(AbstractClassError): 

131 """ 

132 This exception is raised, when a class contains methods marked with *must-override*. 

133 

134 .. seealso:: 

135 

136 :func:`@abstractmethod <pyTooling.MetaClasses.abstractmethod>` 

137 |rarr| Mark a method as *abstract*. 

138 :func:`@mustoverride <pyTooling.MetaClasses.mustoverride>` 

139 |rarr| Mark a method as *must overrride*. 

140 :exc:`~AbstractClassError` 

141 |rarr| Exception raised, if a method is marked as *abstract*. 

142 """ 

143 

144 

145# """ 

146# Metaclass that allows multiple dispatch of methods based on method signatures. 

147# 

148# .. seealso: 

149# 

150# `Python Cookbook - Multiple dispatch with function annotations <https://GitHub.com/dabeaz/python-cookbook/blob/master/src/9/multiple_dispatch_with_function_annotations/example1.py?ts=2>`__ 

151# """ 

152 

153 

154M = TypeVar("M", bound=Callable) #: A type variable for methods. 

155 

156 

157@export 

158def slotted(cls): 

159 if cls.__class__ is type: 

160 metacls = ExtendedType 

161 elif issubclass(cls.__class__, ExtendedType): 

162 metacls = cls.__class__ 

163 for method in cls.__methods__: 

164 delattr(method, "__classobj__") 

165 else: 

166 raise ExtendedTypeError("Class uses an incompatible meta-class.") # FIXME: create exception for it? 

167 

168 bases = tuple(base for base in cls.__bases__ if base is not object) 

169 slots = cls.__dict__["__slots__"] if "__slots__" in cls.__dict__ else tuple() 

170 members = { 

171 "__qualname__": cls.__qualname__ 

172 } 

173 for key, value in cls.__dict__.items(): 

174 if key not in slots: 

175 members[key] = value 

176 

177 return metacls(cls.__name__, bases, members, slots=True) 

178 

179 

180@export 

181def mixin(cls): 

182 if cls.__class__ is type: 

183 metacls = ExtendedType 

184 elif issubclass(cls.__class__, ExtendedType): 184 ↛ 189line 184 didn't jump to line 189 because the condition on line 184 was always true

185 metacls = cls.__class__ 

186 for method in cls.__methods__: 

187 delattr(method, "__classobj__") 

188 else: 

189 raise ExtendedTypeError("Class uses an incompatible meta-class.") # FIXME: create exception for it? 

190 

191 bases = tuple(base for base in cls.__bases__ if base is not object) 

192 slots = cls.__dict__["__slots__"] if "__slots__" in cls.__dict__ else tuple() 

193 members = { 

194 "__qualname__": cls.__qualname__ 

195 } 

196 for key, value in cls.__dict__.items(): 

197 if key not in slots: 

198 members[key] = value 

199 

200 return metacls(cls.__name__, bases, members, mixin=True) 

201 

202 

203@export 

204def singleton(cls): 

205 if cls.__class__ is type: 

206 metacls = ExtendedType 

207 elif issubclass(cls.__class__, ExtendedType): 

208 metacls = cls.__class__ 

209 for method in cls.__methods__: 

210 delattr(method, "__classobj__") 

211 else: 

212 raise ExtendedTypeError("Class uses an incompatible meta-class.") # FIXME: create exception for it? 

213 

214 bases = tuple(base for base in cls.__bases__ if base is not object) 

215 slots = cls.__dict__["__slots__"] if "__slots__" in cls.__dict__ else tuple() 

216 members = { 

217 "__qualname__": cls.__qualname__ 

218 } 

219 for key, value in cls.__dict__.items(): 

220 if key not in slots: 

221 members[key] = value 

222 

223 return metacls(cls.__name__, bases, members, singleton=True) 

224 

225 

226@export 

227def abstractmethod(method: M) -> M: 

228 """ 

229 Mark a method as *abstract* and replace the implementation with a new method raising a :exc:`NotImplementedError`. 

230 

231 The original method is stored in ``<method>.__wrapped__`` and it's doc-string is copied to the replacing method. In 

232 additional field ``<method>.__abstract__`` is added. 

233 

234 .. warning:: 

235 

236 This decorator should be used in combination with meta-class :class:`~pyTooling.Metaclasses.ExtendedType`. 

237 Otherwise, an abstract class itself doesn't throw a :exc:`~pyTooling.Exceptions.AbstractClassError` at 

238 instantiation. 

239 

240 .. admonition:: ``example.py`` 

241 

242 .. code-block:: python 

243 

244 class Data(mataclass=ExtendedType): 

245 @abstractmethod 

246 def method(self) -> bool: 

247 '''This method needs to be implemented''' 

248 

249 :param method: Method that is marked as *abstract*. 

250 :returns: Replacement method, which raises a :exc:`NotImplementedError`. 

251 

252 .. seealso:: 

253 

254 * :exc:`~pyTooling.Exceptions.AbstractClassError` 

255 * :func:`~pyTooling.Metaclasses.mustoverride` 

256 * :func:`~pyTooling.Metaclasses.notimplemented` 

257 """ 

258 @wraps(method) 

259 def func(self) -> NoReturn: 

260 raise NotImplementedError(f"Method '{method.__name__}' is abstract and needs to be overridden in a derived class.") 

261 

262 func.__abstract__ = True 

263 return func 

264 

265 

266@export 

267def mustoverride(method: M) -> M: 

268 """ 

269 Mark a method as *must-override*. 

270 

271 The returned function is the original function, but with an additional field ``<method>.____mustOverride__``, so a 

272 meta-class can identify a *must-override* method and raise an error. Such an error is not raised if the method is 

273 overridden by an inheriting class. 

274 

275 A *must-override* methods can offer a partial implementation, which is called via ``super()...``. 

276 

277 .. warning:: 

278 

279 This decorator needs to be used in combination with meta-class :class:`~pyTooling.Metaclasses.ExtendedType`. 

280 Otherwise, an abstract class itself doesn't throw a :exc:`~pyTooling.Exceptions.MustOverrideClassError` at 

281 instantiation. 

282 

283 .. admonition:: ``example.py`` 

284 

285 .. code-block:: python 

286 

287 class Data(mataclass=ExtendedType): 

288 @mustoverride 

289 def method(self): 

290 '''This is a very basic implementation''' 

291 

292 :param method: Method that is marked as *must-override*. 

293 :returns: Same method, but with additional ``<method>.__mustOverride__`` field. 

294 

295 .. seealso:: 

296 

297 * :exc:`~pyTooling.Exceptions.MustOverrideClassError` 

298 * :func:`~pyTooling.Metaclasses.abstractmethod` 

299 * :func:`~pyTooling.Metaclasses.notimplemented` 

300 """ 

301 method.__mustOverride__ = True 

302 return method 

303 

304 

305# @export 

306# def overloadable(method: M) -> M: 

307# method.__overloadable__ = True 

308# return method 

309 

310 

311# @export 

312# class DispatchableMethod: 

313# """Represents a single multimethod.""" 

314# 

315# _methods: Dict[Tuple, Callable] 

316# __name__: str 

317# __slots__ = ("_methods", "__name__") 

318# 

319# def __init__(self, name: str) -> None: 

320# self.__name__ = name 

321# self._methods = {} 

322# 

323# def __call__(self, *args: Any): 

324# """Call a method based on type signature of the arguments.""" 

325# types = tuple(type(arg) for arg in args[1:]) 

326# meth = self._methods.get(types, None) 

327# if meth: 

328# return meth(*args) 

329# else: 

330# raise TypeError(f"No matching method for types {types}.") 

331# 

332# def __get__(self, instance, cls): # Starting with Python 3.11+, use typing.Self as return type 

333# """Descriptor method needed to make calls work in a class.""" 

334# if instance is not None: 

335# return MethodType(self, instance) 

336# else: 

337# return self 

338# 

339# def register(self, method: Callable) -> None: 

340# """Register a new method as a dispatchable.""" 

341# 

342# # Build a signature from the method's type annotations 

343# sig = signature(method) 

344# types: List[Type] = [] 

345# 

346# for name, parameter in sig.parameters.items(): 

347# if name == "self": 

348# continue 

349# 

350# if parameter.annotation is Parameter.empty: 

351# raise TypeError(f"Parameter '{name}' in method '{method.__name__}' must be annotated with a type.") 

352# 

353# if not isinstance(parameter.annotation, type): 

354# raise TypeError(f"Parameter '{name}' in method '{method.__name__}' annotation must be a type.") 

355# 

356# if parameter.default is not Parameter.empty: 

357# self._methods[tuple(types)] = method 

358# 

359# types.append(parameter.annotation) 

360# 

361# self._methods[tuple(types)] = method 

362 

363 

364# @export 

365# class DispatchDictionary(dict): 

366# """Special dictionary to build dispatchable methods in a metaclass.""" 

367# 

368# def __setitem__(self, key: str, value: Any): 

369# if callable(value) and key in self: 

370# # If key already exists, it must be a dispatchable method or callable 

371# currentValue = self[key] 

372# if isinstance(currentValue, DispatchableMethod): 

373# currentValue.register(value) 

374# else: 

375# dispatchable = DispatchableMethod(key) 

376# dispatchable.register(currentValue) 

377# dispatchable.register(value) 

378# 

379# super().__setitem__(key, dispatchable) 

380# else: 

381# super().__setitem__(key, value) 

382 

383 

384@export 

385class ExtendedType(type): 

386 """ 

387 An updates meta-class to construct new classes with an extended feature set. 

388 

389 .. todo:: META::ExtendedType Needs documentation. 

390 .. todo:: META::ExtendedType allow __dict__ and __weakref__ if slotted is enabled 

391 

392 .. rubric:: Features: 

393 

394 * Store object members more efficiently in ``__slots__`` instead of ``_dict__``. 

395 

396 * Implement ``__slots__`` only on primary inheritance line. 

397 * Collect class variables on secondary inheritance lines (mixin-classes) and defer implementation as ``__slots__``. 

398 * Handle object state exporting and importing for slots (:mod:`pickle` support) via ``__getstate__``/``__setstate__``. 

399 

400 * Allow only a single instance to be created (:term:`singleton`). |br| 

401 Further instantiations will return the previously create instance (identical object). 

402 * Define methods as :term:`abstract <abstract method>` or :term:`must-override <mustoverride method>` and prohibit 

403 instantiation of :term:`abstract classes <abstract class>`. 

404 

405 .. #* Allow method overloading and dispatch overloads based on argument signatures. 

406 

407 .. rubric:: Added class fields: 

408 

409 :__slotted__: True, if class uses `__slots__`. 

410 :__allSlots__: Set of class fields stored in slots for all classes in the inheritance hierarchy. 

411 :__slots__: Tuple of class fields stored in slots for current class in the inheritance hierarchy. |br| 

412 See :pep:`253` for details. 

413 :__isMixin__: True, if class is a mixin-class 

414 :__mixinSlots__: List of collected slots from secondary inheritance hierarchy (mixin hierarchy). 

415 :__methods__: List of methods. 

416 :__methodsWithAttributes__: List of methods with pyTooling attributes. 

417 :__abstractMethods__: List of abstract methods, which need to be implemented in the next class hierarchy levels. 

418 :__isAbstract__: True, if class is abstract. 

419 :__isSingleton__: True, if class is a singleton 

420 :__singletonInstanceCond__: Condition variable to protect the singleton creation. 

421 :__singletonInstanceInit__: Singleton is initialized. 

422 :__singletonInstanceCache__: The singleton object, once created. 

423 :__pyattr__: List of class attributes. 

424 

425 .. rubric:: Added class properties: 

426 

427 :HasClassAttributes: Read-only property to check if the class has Attributes. 

428 :HasMethodAttributes: Read-only property to check if the class has methods with Attributes. 

429 

430 .. rubric:: Added methods: 

431 

432 If slots are used, the following methods are added to support :mod:`pickle`: 

433 

434 :__getstate__: Export an object's state for serialization. |br| 

435 See :pep:`307` for details. 

436 :__setstate__: Import an object's state for deserialization. |br| 

437 See :pep:`307` for details. 

438 

439 .. rubric:: Modified ``__new__`` method: 

440 

441 If class is a singleton, ``__new__`` will be replaced by a wrapper method. This wrapper is marked with ``__singleton_wrapper__``. 

442 

443 If class is abstract, ``__new__`` will be replaced by a method raising an exception. This replacement is marked with ``__raises_abstract_class_error__``. 

444 

445 .. rubric:: Modified ``__init__`` method: 

446 

447 If class is a singleton, ``__init__`` will be replaced by a wrapper method. This wrapper is marked by ``__singleton_wrapper__``. 

448 

449 .. rubric:: Modified abstract methods: 

450 

451 If a method is abstract, its marked with ``__abstract__``. |br| 

452 If a method is must override, its marked with ``__mustOverride__``. 

453 """ 

454 

455 # @classmethod 

456 # def __prepare__(cls, className, baseClasses, slots: bool = False, mixin: bool = False, singleton: bool = False): 

457 # return DispatchDictionary() 

458 

459 def __new__( 

460 self, 

461 className: str, 

462 baseClasses: Tuple[type], 

463 members: Dict[str, Any], 

464 slots: bool = False, 

465 mixin: bool = False, 

466 singleton: bool = False 

467 ) -> Self: 

468 """ 

469 Construct a new class using this :term:`meta-class`. 

470 

471 :param className: The name of the class to construct. 

472 :param baseClasses: The tuple of :term:`base-classes <base-class>` the class is derived from. 

473 :param members: The dictionary of members for the constructed class. 

474 :param slots: If true, store object attributes in :term:`__slots__ <slots>` instead of ``__dict__``. 

475 :param mixin: If true, make the class a :term:`Mixin-Class`. 

476 If false, create slots if ``slots`` is true. 

477 If none, preserve behavior of primary base-class. 

478 :param singleton: If true, make the class a :term:`Singleton`. 

479 :returns: The new class. 

480 :raises AttributeError: If base-class has no '__slots__' attribute. 

481 :raises AttributeError: If slot already exists in base-class. 

482 """ 

483 try: 

484 from pyTooling.Attributes import ATTRIBUTES_MEMBER_NAME, AttributeScope 

485 except (ImportError, ModuleNotFoundError) as ex: # pragma: no cover 

486 from Attributes import ATTRIBUTES_MEMBER_NAME, AttributeScope 

487 

488 # Inherit 'slots' feature from primary base-class 

489 if len(baseClasses) > 0: 

490 primaryBaseClass = baseClasses[0] 

491 if isinstance(primaryBaseClass, self): 

492 slots = primaryBaseClass.__slotted__ 

493 

494 # Compute slots and mixin-slots from annotated fields as well as class- and object-fields with initial values. 

495 classFields, objectFields = self._computeSlots(className, baseClasses, members, slots, mixin) 

496 

497 # Compute abstract methods 

498 abstractMethods, members = self._checkForAbstractMethods(baseClasses, members) 

499 

500 # Create a new class 

501 newClass = type.__new__(self, className, baseClasses, members) 

502 

503 # Apply class fields 

504 for fieldName, typeAnnotation in classFields.items(): 

505 setattr(newClass, fieldName, typeAnnotation) 

506 

507 # Search in inheritance tree for abstract methods 

508 newClass.__abstractMethods__ = abstractMethods 

509 newClass.__isAbstract__ = self._wrapNewMethodIfAbstract(newClass) 

510 newClass.__isSingleton__ = self._wrapNewMethodIfSingleton(newClass, singleton) 

511 

512 if slots: 

513 # If slots are used, implement __getstate__/__setstate__ API to support serialization using pickle. 

514 if "__getstate__" not in members: 

515 def __getstate__(self) -> Dict[str, Any]: 

516 try: 

517 return {slotName: getattr(self, slotName) for slotName in self.__allSlots__} 

518 except AttributeError as ex: 

519 raise ExtendedTypeError(f"Unassigned field '{ex.name}' in object '{self}' of type '{self.__class__.__name__}'.") from ex 

520 

521 newClass.__getstate__ = __getstate__ 

522 

523 if "__setstate__" not in members: 

524 def __setstate__(self, state: Dict[str, Any]) -> None: 

525 if self.__allSlots__ != (slots := set(state.keys())): 

526 if len(diff := self.__allSlots__.difference(slots)) > 0: 

527 raise ExtendedTypeError(f"""Missing fields in parameter 'state': '{"', '".join(diff)}'""") # WORKAROUND: Python <3.12 

528 else: 

529 diff = slots.difference(self.__allSlots__) 

530 raise ExtendedTypeError(f"""Unexpected fields in parameter 'state': '{"', '".join(diff)}'""") # WORKAROUND: Python <3.12 

531 

532 for slotName, value in state.items(): 

533 setattr(self, slotName, value) 

534 

535 newClass.__setstate__ = __setstate__ 

536 

537 # Check for inherited class attributes 

538 attributes = [] 

539 setattr(newClass, ATTRIBUTES_MEMBER_NAME, attributes) 

540 for base in baseClasses: 

541 if hasattr(base, ATTRIBUTES_MEMBER_NAME): 

542 pyAttr = getattr(base, ATTRIBUTES_MEMBER_NAME) 

543 for att in pyAttr: 

544 if AttributeScope.Class in att.Scope: 544 ↛ 543line 544 didn't jump to line 543 because the condition on line 544 was always true

545 attributes.append(att) 

546 att.__class__._classes.append(newClass) 

547 

548 # Check methods for attributes 

549 methods, methodsWithAttributes = self._findMethods(newClass, baseClasses, members) 

550 

551 # Add new fields for found methods 

552 newClass.__methods__ = tuple(methods) 

553 newClass.__methodsWithAttributes__ = tuple(methodsWithAttributes) 

554 

555 # Additional methods on a class 

556 def GetMethodsWithAttributes(self, predicate: Nullable[TAttributeFilter[TAttr]] = None) -> Dict[Callable, Tuple["Attribute", ...]]: 

557 """ 

558 

559 :param predicate: 

560 :return: 

561 :raises ValueError: 

562 :raises ValueError: 

563 """ 

564 try: 

565 from ..Attributes import Attribute 

566 except (ImportError, ModuleNotFoundError): # pragma: no cover 

567 try: 

568 from Attributes import Attribute 

569 except (ImportError, ModuleNotFoundError) as ex: # pragma: no cover 

570 raise ex 

571 

572 if predicate is None: 

573 predicate = Attribute 

574 elif isinstance(predicate, Iterable): 574 ↛ 575line 574 didn't jump to line 575 because the condition on line 574 was never true

575 for attribute in predicate: 

576 if not issubclass(attribute, Attribute): 

577 raise ValueError(f"Parameter 'predicate' contains an element which is not a sub-class of 'Attribute'.") 

578 

579 predicate = tuple(predicate) 

580 elif not issubclass(predicate, Attribute): 580 ↛ 581line 580 didn't jump to line 581 because the condition on line 580 was never true

581 raise ValueError(f"Parameter 'predicate' is not a sub-class of 'Attribute'.") 

582 

583 methodAttributePairs = {} 

584 for method in newClass.__methodsWithAttributes__: 

585 matchingAttributes = [] 

586 for attribute in method.__pyattr__: 

587 if isinstance(attribute, predicate): 

588 matchingAttributes.append(attribute) 

589 

590 if len(matchingAttributes) > 0: 

591 methodAttributePairs[method] = tuple(matchingAttributes) 

592 

593 return methodAttributePairs 

594 

595 newClass.GetMethodsWithAttributes = classmethod(GetMethodsWithAttributes) 

596 GetMethodsWithAttributes.__qualname__ = f"{className}.{GetMethodsWithAttributes.__name__}" 

597 

598 # GetMethods(predicate) -> dict[method, list[attribute]] / generator 

599 # GetClassAtrributes -> list[attributes] / generator 

600 # MethodHasAttributes(predicate) -> bool 

601 # GetAttribute 

602 

603 return newClass 

604 

605 @classmethod 

606 def _findMethods( 

607 self, 

608 newClass: "ExtendedType", 

609 baseClasses: Tuple[type], 

610 members: Dict[str, Any] 

611 ) -> Tuple[List[MethodType], List[MethodType]]: 

612 """ 

613 Find methods and methods with :mod:`pyTooling.Attributes`. 

614 

615 .. todo:: 

616 

617 Describe algorithm. 

618 

619 :param newClass: Newly created class instance. 

620 :param baseClasses: The tuple of :term:`base-classes <base-class>` the class is derived from. 

621 :param members: Members of the new class. 

622 :return: 

623 """ 

624 try: 

625 from ..Attributes import Attribute 

626 except (ImportError, ModuleNotFoundError): # pragma: no cover 

627 try: 

628 from Attributes import Attribute 

629 except (ImportError, ModuleNotFoundError) as ex: # pragma: no cover 

630 raise ex 

631 

632 # Embedded bind function due to circular dependencies. 

633 def bind(instance: object, func: FunctionType, methodName: Nullable[str] = None): 

634 if methodName is None: 634 ↛ 637line 634 didn't jump to line 637 because the condition on line 634 was always true

635 methodName = func.__name__ 

636 

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

638 setattr(instance, methodName, boundMethod) 

639 

640 return boundMethod 

641 

642 methods = [] 

643 methodsWithAttributes = [] 

644 attributeIndex = {} 

645 

646 for base in baseClasses: 

647 if hasattr(base, "__methodsWithAttributes__"): 

648 methodsWithAttributes.extend(base.__methodsWithAttributes__) 

649 

650 for memberName, member in members.items(): 

651 if isinstance(member, FunctionType): 

652 method = newClass.__dict__[memberName] 

653 if hasattr(method, "__classobj__") and getattr(method, "__classobj__") is not newClass: 653 ↛ 654line 653 didn't jump to line 654 because the condition on line 653 was never true

654 raise TypeError(f"Method '{memberName}' is used by multiple classes: {method.__classobj__} and {newClass}.") 

655 else: 

656 setattr(method, "__classobj__", newClass) 

657 

658 def GetAttributes(inst: Any, predicate: Nullable[Type[Attribute]] = None) -> Tuple[Attribute, ...]: 

659 results = [] 

660 try: 

661 for attribute in inst.__pyattr__: # type: Attribute 

662 if isinstance(attribute, predicate): 

663 results.append(attribute) 

664 return tuple(results) 

665 except AttributeError: 

666 return tuple() 

667 

668 method.GetAttributes = bind(method, GetAttributes) 

669 methods.append(method) 

670 

671 # print(f" convert function: '{memberName}' to method") 

672 # print(f" {member}") 

673 if "__pyattr__" in member.__dict__: 

674 attributes = member.__pyattr__ # type: List[Attribute] 

675 if isinstance(attributes, list) and len(attributes) > 0: 675 ↛ 650line 675 didn't jump to line 650 because the condition on line 675 was always true

676 methodsWithAttributes.append(member) 

677 for attribute in attributes: 

678 attribute._functions.remove(method) 

679 attribute._methods.append(method) 

680 

681 # print(f" attributes: {attribute.__class__.__name__}") 

682 if attribute not in attributeIndex: 682 ↛ 685line 682 didn't jump to line 685 because the condition on line 682 was always true

683 attributeIndex[attribute] = [member] 

684 else: 

685 attributeIndex[attribute].append(member) 

686 # else: 

687 # print(f" But has no attributes.") 

688 # else: 

689 # print(f" ?? {memberName}") 

690 return methods, methodsWithAttributes 

691 

692 @classmethod 

693 def _computeSlots( 

694 self, 

695 className: str, 

696 baseClasses: Tuple[type], 

697 members: Dict[str, Any], 

698 slots: bool, 

699 mixin: bool 

700 ) -> Tuple[Dict[str, Any], Dict[str, Any]]: 

701 """ 

702 Compute which field are listed in __slots__ and which need to be initialized in an instance or class. 

703 

704 .. todo:: 

705 

706 Describe algorithm. 

707 

708 :param className: The name of the class to construct. 

709 :param baseClasses: Tuple of base-classes. 

710 :param members: Dictionary of class members. 

711 :param slots: True, if the class should setup ``__slots__``. 

712 :param mixin: True, if the class should behave as a mixin-class. 

713 :returns: A 2-tuple with a dictionary of class members and object members. 

714 """ 

715 # Compute which field are listed in __slots__ and which need to be initialized in an instance or class. 

716 slottedFields = [] 

717 classFields = {} 

718 objectFields = {} 

719 if slots or mixin: 

720 # If slots are used, all base classes must use __slots__. 

721 for baseClass in self._iterateBaseClasses(baseClasses): 

722 # Exclude object as a special case 

723 if baseClass is object or baseClass is Generic: 

724 continue 

725 

726 if not hasattr(baseClass, "__slots__"): 

727 ex = BaseClassWithoutSlotsError(f"Base-classes '{baseClass.__name__}' doesn't use '__slots__'.") 

728 ex.add_note(f"All base-classes of a class using '__slots__' must use '__slots__' itself.") 

729 raise ex 

730 

731 # FIXME: should have a check for non-empty slots on secondary base-classes too 

732 

733 # Copy all field names from primary base-class' __slots__, which are later needed for error checking. 

734 inheritedSlottedFields = {} 

735 if len(baseClasses) > 0: 

736 for base in reversed(baseClasses[0].mro()): 

737 # Exclude object as a special case 

738 if base is object or base is Generic: 

739 continue 

740 

741 for annotation in base.__slots__: 

742 inheritedSlottedFields[annotation] = base 

743 

744 # When adding annotated fields to slottedFields, check if name was not used in inheritance hierarchy. 

745 if "__annotations__" in members: 

746 # WORKAROUND: LEGACY SUPPORT Python <= 3.13 

747 # Accessing annotations was changed in Python 3.14. 

748 # The necessary 'annotationlib' is not available for older Python versions. 

749 annotations: Dict[str, Any] = members.get("__annotations__", {}) 

750 elif version_info >= (3, 14) and (annotate := members.get("__annotate_func__", None)) is not None: 

751 from annotationlib import Format 

752 annotations: Dict[str, Any] = annotate(Format.VALUE) 

753 else: 

754 annotations = {} 

755 

756 for fieldName, typeAnnotation in annotations.items(): 

757 if fieldName in inheritedSlottedFields: 757 ↛ 758line 757 didn't jump to line 758 because the condition on line 757 was never true

758 cls = inheritedSlottedFields[fieldName] 

759 raise AttributeError(f"Slot '{fieldName}' already exists in base-class '{cls.__module__}.{cls.__name__}'.") 

760 

761 # If annotated field is a ClassVar, and it has an initial value 

762 # * copy field and initial value to classFields dictionary 

763 # * remove field from members 

764 if isinstance(typeAnnotation, _GenericAlias) and typeAnnotation.__origin__ is ClassVar and fieldName in members: 

765 classFields[fieldName] = members[fieldName] 

766 del members[fieldName] 

767 

768 # If an annotated field has an initial value 

769 # * copy field and initial value to objectFields dictionary 

770 # * remove field from members 

771 elif fieldName in members: 

772 slottedFields.append(fieldName) 

773 objectFields[fieldName] = members[fieldName] 

774 del members[fieldName] 

775 else: 

776 slottedFields.append(fieldName) 

777 

778 mixinSlots = self._aggregateMixinSlots(className, baseClasses) 

779 else: 

780 # When adding annotated fields to slottedFields, check if name was not used in inheritance hierarchy. 

781 annotations: Dict[str, Any] = members.get("__annotations__", {}) 

782 for fieldName, typeAnnotation in annotations.items(): 

783 # If annotated field is a ClassVar, and it has an initial value 

784 # * copy field and initial value to classFields dictionary 

785 # * remove field from members 

786 if isinstance(typeAnnotation, _GenericAlias) and typeAnnotation.__origin__ is ClassVar and fieldName in members: 

787 classFields[fieldName] = members[fieldName] 

788 del members[fieldName] 

789 

790 # FIXME: search for fields without annotation 

791 if mixin: 

792 mixinSlots.extend(slottedFields) 

793 members["__slotted__"] = True 

794 members["__slots__"] = tuple() 

795 members["__allSlots__"] = set() 

796 members["__isMixin__"] = True 

797 members["__mixinSlots__"] = tuple(mixinSlots) 

798 elif slots: 

799 slottedFields.extend(mixinSlots) 

800 members["__slotted__"] = True 

801 members["__slots__"] = tuple(slottedFields) 

802 members["__allSlots__"] = set(chain(slottedFields, inheritedSlottedFields.keys())) 

803 members["__isMixin__"] = False 

804 members["__mixinSlots__"] = tuple() 

805 else: 

806 members["__slotted__"] = False 

807 # NO __slots__ 

808 # members["__allSlots__"] = set() 

809 members["__isMixin__"] = False 

810 members["__mixinSlots__"] = tuple() 

811 return classFields, objectFields 

812 

813 @classmethod 

814 def _aggregateMixinSlots(self, className: str, baseClasses: Tuple[type]) -> List[str]: 

815 """ 

816 Aggregate slot names requested by mixin-base-classes. 

817 

818 .. todo:: 

819 

820 Describe algorithm. 

821 

822 :param className: The name of the class to construct. 

823 :param baseClasses: The tuple of :term:`base-classes <base-class>` the class is derived from. 

824 :returns: A list of slot names. 

825 """ 

826 mixinSlots = [] 

827 if len(baseClasses) > 0: 

828 # If class has base-classes ensure only the primary inheritance path uses slots and all secondary inheritance 

829 # paths have an empty slots tuple. Otherwise, raise a BaseClassWithNonEmptySlotsError. 

830 inheritancePaths = [path for path in self._iterateBaseClassPaths(baseClasses)] 

831 primaryInharitancePath: Set[type] = set(inheritancePaths[0]) 

832 for typePath in inheritancePaths[1:]: 

833 for t in typePath: 

834 if hasattr(t, "__slots__") and len(t.__slots__) != 0 and t not in primaryInharitancePath: 

835 ex = BaseClassWithNonEmptySlotsError(f"Base-class '{t.__name__}' has non-empty __slots__ and can't be used as a direct or indirect base-class for '{className}'.") 

836 ex.add_note(f"In Python, only one inheritance branch can use non-empty __slots__.") 

837 # ex.add_note(f"With ExtendedType, only the primary base-class can use non-empty __slots__.") 

838 # ex.add_note(f"Secondary base-classes should be marked as mixin-classes.") 

839 raise ex 

840 

841 # If current class is set to be a mixin, then aggregate all mixinSlots in a list. 

842 # Ensure all base-classes are either constructed 

843 # * by meta-class ExtendedType, or 

844 # * use no slots, or 

845 # * are typing.Generic 

846 # If it was constructed by ExtendedType, then ensure this class itself is a mixin-class. 

847 for baseClass in baseClasses: # type: ExtendedType 

848 if isinstance(baseClass, _GenericAlias) and baseClass.__origin__ is Generic: 848 ↛ 849line 848 didn't jump to line 849 because the condition on line 848 was never true

849 pass 

850 elif baseClass.__class__ is self and baseClass.__isMixin__: 

851 mixinSlots.extend(baseClass.__mixinSlots__) 

852 elif hasattr(baseClass, "__mixinSlots__"): 

853 mixinSlots.extend(baseClass.__mixinSlots__) 

854 

855 return mixinSlots 

856 

857 @classmethod 

858 def _iterateBaseClasses(metacls, baseClasses: Tuple[type]) -> Generator[type, None, None]: 

859 """ 

860 Return a generator to iterate (visit) all base-classes ... 

861 

862 .. todo:: 

863 

864 Describe iteration order. 

865 

866 :param baseClasses: The tuple of :term:`base-classes <base-class>` the class is derived from. 

867 :returns: Generator to iterate all base-classes. 

868 """ 

869 if len(baseClasses) == 0: 

870 return 

871 

872 visited: Set[type] = set() 

873 iteratorStack: List[Iterator[type]] = list() 

874 

875 for baseClass in baseClasses: 

876 yield baseClass 

877 visited.add(baseClass) 

878 iteratorStack.append(iter(baseClass.__bases__)) 

879 

880 while True: 

881 try: 

882 base = next(iteratorStack[-1]) # type: type 

883 if base not in visited: 883 ↛ 888line 883 didn't jump to line 888 because the condition on line 883 was always true

884 yield base 

885 if len(base.__bases__) > 0: 

886 iteratorStack.append(iter(base.__bases__)) 

887 else: 

888 continue 

889 

890 except StopIteration: 

891 iteratorStack.pop() 

892 

893 if len(iteratorStack) == 0: 

894 break 

895 

896 @classmethod 

897 def _iterateBaseClassPaths(metacls, baseClasses: Tuple[type]) -> Generator[Tuple[type, ...], None, None]: 

898 """ 

899 Return a generator to iterate all possible inheritance paths for a given list of base-classes. 

900 

901 An inheritance path is expressed as a tuple of base-classes from current base-class (left-most item) to 

902 :class:`object` (right-most item). 

903 

904 :param baseClasses: The tuple of :term:`base-classes <base-class>` the class is derived from. 

905 :returns: Generator to iterate all inheritance paths. |br| 

906 An inheritance path is a tuple of types (base-classes). 

907 """ 

908 if len(baseClasses) == 0: 908 ↛ 909line 908 didn't jump to line 909 because the condition on line 908 was never true

909 return 

910 

911 typeStack: List[type] = list() 

912 iteratorStack: List[Iterator[type]] = list() 

913 

914 for baseClass in baseClasses: 

915 typeStack.append(baseClass) 

916 iteratorStack.append(iter(baseClass.__bases__)) 

917 

918 while True: 

919 try: 

920 base = next(iteratorStack[-1]) # type: type 

921 typeStack.append(base) 

922 if len(base.__bases__) == 0: 

923 yield tuple(typeStack) 

924 typeStack.pop() 

925 else: 

926 iteratorStack.append(iter(base.__bases__)) 

927 

928 except StopIteration: 

929 typeStack.pop() 

930 iteratorStack.pop() 

931 

932 if len(typeStack) == 0: 

933 break 

934 

935 @classmethod 

936 def _checkForAbstractMethods(metacls, baseClasses: Tuple[type], members: Dict[str, Any]) -> Tuple[Dict[str, Callable], Dict[str, Any]]: 

937 """ 

938 Check if the current class contains abstract methods and return a tuple of them. 

939 

940 These abstract methods might be inherited from any base-class. If there are inherited abstract methods, check if 

941 they are now implemented (overridden) by the current class that's right now constructed. 

942 

943 :param baseClasses: The tuple of :term:`base-classes <base-class>` the class is derived from. 

944 :param members: The dictionary of members for the constructed class. 

945 :returns: A tuple of abstract method's names. 

946 """ 

947 abstractMethods = {} 

948 if baseClasses: 

949 # Aggregate all abstract methods from all base-classes. 

950 for baseClass in baseClasses: 

951 if hasattr(baseClass, "__abstractMethods__"): 

952 abstractMethods.update(baseClass.__abstractMethods__) 

953 

954 for base in baseClasses: 

955 for memberName, member in base.__dict__.items(): 

956 if (memberName in abstractMethods and isinstance(member, FunctionType) and 

957 not (hasattr(member, "__abstract__") or hasattr(member, "__mustOverride__"))): 

958 def outer(method): 

959 @wraps(method) 

960 def inner(cls, *args: Any, **kwargs: Any): 

961 return method(cls, *args, **kwargs) 

962 

963 return inner 

964 

965 members[memberName] = outer(member) 

966 

967 # Check if methods are marked: 

968 # * If so, add them to list of abstract methods 

969 # * If not, method is now implemented and removed from list 

970 for memberName, member in members.items(): 

971 if callable(member): 

972 if ((hasattr(member, "__abstract__") and member.__abstract__) or 

973 (hasattr(member, "__mustOverride__") and member.__mustOverride__)): 

974 abstractMethods[memberName] = member 

975 elif memberName in abstractMethods: 

976 del abstractMethods[memberName] 

977 

978 return abstractMethods, members 

979 

980 @classmethod 

981 def _wrapNewMethodIfSingleton(metacls, newClass, singleton: bool) -> bool: 

982 """ 

983 If a class is a singleton, wrap the ``_new__`` method, so it returns a cached object, if a first object was created. 

984 

985 Only the first object creation initializes the object. 

986 

987 This implementation is threadsafe. 

988 

989 :param newClass: The newly constructed class for further modifications. 

990 :param singleton: If ``True``, the class allows only a single instance to exist. 

991 :returns: ``True``, if the class is a singleton. 

992 """ 

993 if hasattr(newClass, "__isSingleton__"): 

994 singleton = newClass.__isSingleton__ 

995 

996 if singleton: 

997 oldnew = newClass.__new__ 

998 if hasattr(oldnew, "__singleton_wrapper__"): 

999 oldnew = oldnew.__wrapped__ 

1000 

1001 oldinit = newClass.__init__ 

1002 if hasattr(oldinit, "__singleton_wrapper__"): 1002 ↛ 1003line 1002 didn't jump to line 1003 because the condition on line 1002 was never true

1003 oldinit = oldinit.__wrapped__ 

1004 

1005 @wraps(oldnew) 

1006 def singleton_new(cls, *args: Any, **kwargs: Any): 

1007 with cls.__singletonInstanceCond__: 

1008 if cls.__singletonInstanceCache__ is None: 

1009 obj = oldnew(cls, *args, **kwargs) 

1010 cls.__singletonInstanceCache__ = obj 

1011 else: 

1012 obj = cls.__singletonInstanceCache__ 

1013 

1014 return obj 

1015 

1016 @wraps(oldinit) 

1017 def singleton_init(self, *args: Any, **kwargs: Any): 

1018 cls = self.__class__ 

1019 cv = cls.__singletonInstanceCond__ 

1020 with cv: 

1021 if cls.__singletonInstanceInit__: 

1022 oldinit(self, *args, **kwargs) 

1023 cls.__singletonInstanceInit__ = False 

1024 cv.notify_all() 

1025 elif args or kwargs: 

1026 raise ValueError(f"A further instance of a singleton can't be reinitialized with parameters.") 

1027 else: 

1028 while cls.__singletonInstanceInit__: 1028 ↛ 1029line 1028 didn't jump to line 1029 because the condition on line 1028 was never true

1029 cv.wait() 

1030 

1031 singleton_new.__singleton_wrapper__ = True 

1032 singleton_init.__singleton_wrapper__ = True 

1033 

1034 newClass.__new__ = singleton_new 

1035 newClass.__init__ = singleton_init 

1036 newClass.__singletonInstanceCond__ = Condition() 

1037 newClass.__singletonInstanceInit__ = True 

1038 newClass.__singletonInstanceCache__ = None 

1039 return True 

1040 

1041 return False 

1042 

1043 @classmethod 

1044 def _wrapNewMethodIfAbstract(metacls, newClass) -> bool: 

1045 """ 

1046 If the class has abstract methods, replace the ``_new__`` method, so it raises an exception. 

1047 

1048 :param newClass: The newly constructed class for further modifications. 

1049 :returns: ``True``, if the class is abstract. 

1050 :raises AbstractClassError: If the class is abstract and can't be instantiated. 

1051 """ 

1052 # Replace '__new__' by a variant to throw an error on not overridden methods 

1053 if len(newClass.__abstractMethods__) > 0: 

1054 oldnew = newClass.__new__ 

1055 if hasattr(oldnew, "__raises_abstract_class_error__"): 

1056 oldnew = oldnew.__wrapped__ 

1057 

1058 @wraps(oldnew) 

1059 def abstract_new(cls, *_, **__): 

1060 raise AbstractClassError(f"""Class '{cls.__name__}' is abstract. The following methods: '{"', '".join(newClass.__abstractMethods__)}' need to be overridden in a derived class.""") 

1061 

1062 abstract_new.__raises_abstract_class_error__ = True 

1063 

1064 newClass.__new__ = abstract_new 

1065 return True 

1066 

1067 # Handle classes which are not abstract, especially derived classes, if not abstract anymore 

1068 else: 

1069 # skip intermediate 'new' function if class isn't abstract anymore 

1070 try: 

1071 if newClass.__new__.__raises_abstract_class_error__: 1071 ↛ 1084line 1071 didn't jump to line 1084 because the condition on line 1071 was always true

1072 origNew = newClass.__new__.__wrapped__ 

1073 

1074 # WORKAROUND: __new__ checks tp_new and implements different behavior 

1075 # Bugreport: https://github.com/python/cpython/issues/105888 

1076 if origNew is object.__new__: 1076 ↛ 1083line 1076 didn't jump to line 1083 because the condition on line 1076 was always true

1077 @wraps(object.__new__) 

1078 def wrapped_new(inst, *_, **__): 

1079 return object.__new__(inst) 

1080 

1081 newClass.__new__ = wrapped_new 

1082 else: 

1083 newClass.__new__ = origNew 

1084 elif newClass.__new__.__isSingleton__: 

1085 raise Exception(f"Found a singleton wrapper around an AbstractError raising method. This case is not handled yet.") 

1086 except AttributeError as ex: 

1087 # WORKAROUND: 

1088 # AttributeError.name was added in Python 3.10. For version <3.10 use a string contains operation. 

1089 try: 

1090 if ex.name != "__raises_abstract_class_error__": 1090 ↛ 1091line 1090 didn't jump to line 1091 because the condition on line 1090 was never true

1091 raise ex 

1092 except AttributeError: 

1093 if "__raises_abstract_class_error__" not in str(ex): 

1094 raise ex 

1095 

1096 return False 

1097 

1098 # Additional properties and methods on a class 

1099 @readonly 

1100 def HasClassAttributes(self) -> bool: 

1101 """ 

1102 Read-only property to check if the class has Attributes (:attr:`__pyattr__`). 

1103 

1104 :returns: ``True``, if the class has Attributes. 

1105 """ 

1106 try: 

1107 return len(self.__pyattr__) > 0 

1108 except AttributeError: 

1109 return False 

1110 

1111 @readonly 

1112 def HasMethodAttributes(self) -> bool: 

1113 """ 

1114 Read-only property to check if the class has methods with Attributes (:attr:`__methodsWithAttributes__`). 

1115 

1116 :returns: ``True``, if the class has any method with Attributes. 

1117 """ 

1118 try: 

1119 return len(self.__methodsWithAttributes__) > 0 

1120 except AttributeError: 

1121 return False 

1122 

1123 

1124@export 

1125class SlottedObject(metaclass=ExtendedType, slots=True): 

1126 """Classes derived from this class will store all members in ``__slots__``."""