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

418 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2025-12-28 12:48 +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-2025 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 sys import version_info 

41from threading import Condition 

42from types import FunctionType, MethodType 

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

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

45 

46try: 

47 from pyTooling.Exceptions import ToolingException 

48 from pyTooling.Decorators import export, readonly 

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

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

51 

52 try: 

53 from Exceptions import ToolingException 

54 from Decorators import export, readonly 

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

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

57 raise ex 

58 

59 

60__all__ = ["M"] 

61 

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

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

64 

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

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

67iterable of those.""" 

68 

69 

70@export 

71class ExtendedTypeError(ToolingException): 

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

73 

74 

75@export 

76class BaseClassWithoutSlotsError(ExtendedTypeError): 

77 """ 

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

79 

80 .. seealso:: 

81 

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

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

84 """ 

85 

86 

87@export 

88class BaseClassWithNonEmptySlotsError(ExtendedTypeError): 

89 """ 

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

91 

92 .. important:: 

93 

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

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

96 into the primary inheritance line. 

97 """ 

98 

99 

100@export 

101class BaseClassIsNotAMixinError(ExtendedTypeError): 

102 pass 

103 

104 

105@export 

106class DuplicateFieldInSlotsError(ExtendedTypeError): 

107 """ 

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

109 """ 

110 

111 

112@export 

113class AbstractClassError(ExtendedTypeError): 

114 """ 

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

116 

117 .. seealso:: 

118 

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

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

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

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

123 :exc:`~MustOverrideClassError` 

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

125 """ 

126 

127 

128@export 

129class MustOverrideClassError(AbstractClassError): 

130 """ 

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

132 

133 .. seealso:: 

134 

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

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

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

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

139 :exc:`~AbstractClassError` 

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

141 """ 

142 

143 

144# """ 

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

146# 

147# .. seealso: 

148# 

149# `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>`__ 

150# """ 

151 

152 

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

154 

155 

156@export 

157def slotted(cls): 

158 if cls.__class__ is type: 

159 metacls = ExtendedType 

160 elif issubclass(cls.__class__, ExtendedType): 

161 metacls = cls.__class__ 

162 for method in cls.__methods__: 

163 delattr(method, "__classobj__") 

164 else: 

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

166 

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

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

169 members = { 

170 "__qualname__": cls.__qualname__ 

171 } 

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

173 if key not in slots: 

174 members[key] = value 

175 

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

177 

178 

179@export 

180def mixin(cls): 

181 if cls.__class__ is type: 

182 metacls = ExtendedType 

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

184 metacls = cls.__class__ 

185 for method in cls.__methods__: 

186 delattr(method, "__classobj__") 

187 else: 

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

189 

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

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

192 members = { 

193 "__qualname__": cls.__qualname__ 

194 } 

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

196 if key not in slots: 

197 members[key] = value 

198 

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

200 

201 

202@export 

203def singleton(cls): 

204 if cls.__class__ is type: 

205 metacls = ExtendedType 

206 elif issubclass(cls.__class__, ExtendedType): 

207 metacls = cls.__class__ 

208 for method in cls.__methods__: 

209 delattr(method, "__classobj__") 

210 else: 

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

212 

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

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

215 members = { 

216 "__qualname__": cls.__qualname__ 

217 } 

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

219 if key not in slots: 

220 members[key] = value 

221 

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

223 

224 

225@export 

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

227 """ 

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

229 

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

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

232 

233 .. warning:: 

234 

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

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

237 instantiation. 

238 

239 .. admonition:: ``example.py`` 

240 

241 .. code-block:: python 

242 

243 class Data(mataclass=ExtendedType): 

244 @abstractmethod 

245 def method(self) -> bool: 

246 '''This method needs to be implemented''' 

247 

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

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

250 

251 .. seealso:: 

252 

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

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

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

256 """ 

257 @wraps(method) 

258 def func(self) -> NoReturn: 

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

260 

261 func.__abstract__ = True 

262 return func 

263 

264 

265@export 

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

267 """ 

268 Mark a method as *must-override*. 

269 

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

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

272 overridden by an inheriting class. 

273 

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

275 

276 .. warning:: 

277 

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

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

280 instantiation. 

281 

282 .. admonition:: ``example.py`` 

283 

284 .. code-block:: python 

285 

286 class Data(mataclass=ExtendedType): 

287 @mustoverride 

288 def method(self): 

289 '''This is a very basic implementation''' 

290 

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

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

293 

294 .. seealso:: 

295 

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

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

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

299 """ 

300 method.__mustOverride__ = True 

301 return method 

302 

303 

304# @export 

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

306# method.__overloadable__ = True 

307# return method 

308 

309 

310# @export 

311# class DispatchableMethod: 

312# """Represents a single multimethod.""" 

313# 

314# _methods: Dict[Tuple, Callable] 

315# __name__: str 

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

317# 

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

319# self.__name__ = name 

320# self._methods = {} 

321# 

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

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

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

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

326# if meth: 

327# return meth(*args) 

328# else: 

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

330# 

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

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

333# if instance is not None: 

334# return MethodType(self, instance) 

335# else: 

336# return self 

337# 

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

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

340# 

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

342# sig = signature(method) 

343# types: List[Type] = [] 

344# 

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

346# if name == "self": 

347# continue 

348# 

349# if parameter.annotation is Parameter.empty: 

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

351# 

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

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

354# 

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

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

357# 

358# types.append(parameter.annotation) 

359# 

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

361 

362 

363# @export 

364# class DispatchDictionary(dict): 

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

366# 

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

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

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

370# currentValue = self[key] 

371# if isinstance(currentValue, DispatchableMethod): 

372# currentValue.register(value) 

373# else: 

374# dispatchable = DispatchableMethod(key) 

375# dispatchable.register(currentValue) 

376# dispatchable.register(value) 

377# 

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

379# else: 

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

381 

382 

383@export 

384class ExtendedType(type): 

385 """ 

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

387 

388 .. todo:: META::ExtendedType Needs documentation. 

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

390 

391 Features: 

392 

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

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

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

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

397 

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

399 """ 

400 

401 # @classmethod 

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

403 # return DispatchDictionary() 

404 

405 def __new__(self, className: str, baseClasses: Tuple[type], members: Dict[str, Any], 

406 slots: bool = False, mixin: bool = False, singleton: bool = False) -> "ExtendedType": 

407 """ 

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

409 

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

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

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

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

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

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

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

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

418 :returns: The new class. 

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

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

421 """ 

422 try: 

423 from pyTooling.Attributes import ATTRIBUTES_MEMBER_NAME, AttributeScope 

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

425 from Attributes import ATTRIBUTES_MEMBER_NAME, AttributeScope 

426 

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

428 if len(baseClasses) > 0: 

429 primaryBaseClass = baseClasses[0] 

430 if isinstance(primaryBaseClass, self): 

431 slots = primaryBaseClass.__slotted__ 

432 

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

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

435 

436 # Compute abstract methods 

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

438 

439 # Create a new class 

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

441 

442 # Apply class fields 

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

444 setattr(newClass, fieldName, typeAnnotation) 

445 

446 # Search in inheritance tree for abstract methods 

447 newClass.__abstractMethods__ = abstractMethods 

448 newClass.__isAbstract__ = self._wrapNewMethodIfAbstract(newClass) 

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

450 

451 # Check for inherited class attributes 

452 attributes = [] 

453 setattr(newClass, ATTRIBUTES_MEMBER_NAME, attributes) 

454 for base in baseClasses: 

455 if hasattr(base, ATTRIBUTES_MEMBER_NAME): 

456 pyAttr = getattr(base, ATTRIBUTES_MEMBER_NAME) 

457 for att in pyAttr: 

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

459 attributes.append(att) 

460 att.__class__._classes.append(newClass) 

461 

462 # Check methods for attributes 

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

464 

465 # Add new fields for found methods 

466 newClass.__methods__ = tuple(methods) 

467 newClass.__methodsWithAttributes__ = tuple(methodsWithAttributes) 

468 

469 # Additional methods on a class 

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

471 """ 

472 

473 :param predicate: 

474 :return: 

475 :raises ValueError: 

476 :raises ValueError: 

477 """ 

478 try: 

479 from ..Attributes import Attribute 

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

481 try: 

482 from Attributes import Attribute 

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

484 raise ex 

485 

486 if predicate is None: 

487 predicate = Attribute 

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

489 for attribute in predicate: 

490 if not issubclass(attribute, Attribute): 

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

492 

493 predicate = tuple(predicate) 

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

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

496 

497 methodAttributePairs = {} 

498 for method in newClass.__methodsWithAttributes__: 

499 matchingAttributes = [] 

500 for attribute in method.__pyattr__: 

501 if isinstance(attribute, predicate): 

502 matchingAttributes.append(attribute) 

503 

504 if len(matchingAttributes) > 0: 

505 methodAttributePairs[method] = tuple(matchingAttributes) 

506 

507 return methodAttributePairs 

508 

509 newClass.GetMethodsWithAttributes = classmethod(GetMethodsWithAttributes) 

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

511 

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

513 # GetClassAtrributes -> list[attributes] / generator 

514 # MethodHasAttributes(predicate) -> bool 

515 # GetAttribute 

516 

517 return newClass 

518 

519 @classmethod 

520 def _findMethods( 

521 self, 

522 newClass: "ExtendedType", 

523 baseClasses: Tuple[type], 

524 members: Dict[str, Any] 

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

526 """ 

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

528 

529 .. todo:: 

530 

531 Describe algorithm. 

532 

533 :param newClass: Newly created class instance. 

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

535 :param members: Members of the new class. 

536 :return: 

537 """ 

538 try: 

539 from ..Attributes import Attribute 

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

541 try: 

542 from Attributes import Attribute 

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

544 raise ex 

545 

546 # Embedded bind function due to circular dependencies. 

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

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

549 methodName = func.__name__ 

550 

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

552 setattr(instance, methodName, boundMethod) 

553 

554 return boundMethod 

555 

556 methods = [] 

557 methodsWithAttributes = [] 

558 attributeIndex = {} 

559 

560 for base in baseClasses: 

561 if hasattr(base, "__methodsWithAttributes__"): 

562 methodsWithAttributes.extend(base.__methodsWithAttributes__) 

563 

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

565 if isinstance(member, FunctionType): 

566 method = newClass.__dict__[memberName] 

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

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

569 else: 

570 setattr(method, "__classobj__", newClass) 

571 

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

573 results = [] 

574 try: 

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

576 if isinstance(attribute, predicate): 

577 results.append(attribute) 

578 return tuple(results) 

579 except AttributeError: 

580 return tuple() 

581 

582 method.GetAttributes = bind(method, GetAttributes) 

583 methods.append(method) 

584 

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

586 # print(f" {member}") 

587 if "__pyattr__" in member.__dict__: 

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

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

590 methodsWithAttributes.append(member) 

591 for attribute in attributes: 

592 attribute._functions.remove(method) 

593 attribute._methods.append(method) 

594 

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

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

597 attributeIndex[attribute] = [member] 

598 else: 

599 attributeIndex[attribute].append(member) 

600 # else: 

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

602 # else: 

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

604 return methods, methodsWithAttributes 

605 

606 @classmethod 

607 def _computeSlots( 

608 self, 

609 className: str, 

610 baseClasses: Tuple[type], 

611 members: Dict[str, Any], 

612 slots: bool, 

613 mixin: bool 

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

615 """ 

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

617 

618 .. todo:: 

619 

620 Describe algorithm. 

621 

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

623 :param baseClasses: Tuple of base-classes. 

624 :param members: Dictionary of class members. 

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

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

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

628 """ 

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

630 slottedFields = [] 

631 classFields = {} 

632 objectFields = {} 

633 if slots or mixin: 

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

635 for baseClass in self._iterateBaseClasses(baseClasses): 

636 # Exclude object as a special case 

637 if baseClass is object or baseClass is Generic: 

638 continue 

639 

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

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

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

643 raise ex 

644 

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

646 

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

648 inheritedSlottedFields = {} 

649 if len(baseClasses) > 0: 

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

651 # Exclude object as a special case 

652 if base is object or base is Generic: 

653 continue 

654 

655 for annotation in base.__slots__: 

656 inheritedSlottedFields[annotation] = base 

657 

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

659 if "__annotations__" in members: 

660 # WORKAROUND: LEGACY SUPPORT Python <= 3.13 

661 # Accessing annotations was changed in Python 3.14. 

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

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

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

665 from annotationlib import Format 

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

667 else: 

668 annotations = {} 

669 

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

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

672 cls = inheritedSlottedFields[fieldName] 

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

674 

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

676 # * copy field and initial value to classFields dictionary 

677 # * remove field from members 

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

679 classFields[fieldName] = members[fieldName] 

680 del members[fieldName] 

681 

682 # If an annotated field has an initial value 

683 # * copy field and initial value to objectFields dictionary 

684 # * remove field from members 

685 elif fieldName in members: 

686 slottedFields.append(fieldName) 

687 objectFields[fieldName] = members[fieldName] 

688 del members[fieldName] 

689 else: 

690 slottedFields.append(fieldName) 

691 

692 mixinSlots = self._aggregateMixinSlots(className, baseClasses) 

693 else: 

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

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

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

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

698 # * copy field and initial value to classFields dictionary 

699 # * remove field from members 

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

701 classFields[fieldName] = members[fieldName] 

702 del members[fieldName] 

703 

704 # FIXME: search for fields without annotation 

705 # TODO: document a list of added members due to ExtendedType 

706 if mixin: 

707 mixinSlots.extend(slottedFields) 

708 members["__slotted__"] = True 

709 members["__slots__"] = tuple() 

710 members["__isMixin__"] = True 

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

712 elif slots: 

713 slottedFields.extend(mixinSlots) 

714 members["__slotted__"] = True 

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

716 members["__isMixin__"] = False 

717 members["__mixinSlots__"] = tuple() 

718 else: 

719 members["__slotted__"] = False 

720 # NO __slots__ 

721 members["__isMixin__"] = False 

722 members["__mixinSlots__"] = tuple() 

723 return classFields, objectFields 

724 

725 @classmethod 

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

727 """ 

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

729 

730 .. todo:: 

731 

732 Describe algorithm. 

733 

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

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

736 :returns: A list of slot names. 

737 """ 

738 mixinSlots = [] 

739 if len(baseClasses) > 0: 

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

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

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

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

744 for typePath in inheritancePaths[1:]: 

745 for t in typePath: 

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

747 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}'.") 

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

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

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

751 raise ex 

752 

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

754 # Ensure all base-classes are either constructed 

755 # * by meta-class ExtendedType, or 

756 # * use no slots, or 

757 # * are typing.Generic 

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

759 for baseClass in baseClasses: # type: ExtendedType 

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

761 pass 

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

763 mixinSlots.extend(baseClass.__mixinSlots__) 

764 elif hasattr(baseClass, "__mixinSlots__"): 

765 mixinSlots.extend(baseClass.__mixinSlots__) 

766 

767 return mixinSlots 

768 

769 @classmethod 

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

771 """ 

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

773 

774 .. todo:: 

775 

776 Describe iteration order. 

777 

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

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

780 """ 

781 if len(baseClasses) == 0: 

782 return 

783 

784 visited: Set[type] = set() 

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

786 

787 for baseClass in baseClasses: 

788 yield baseClass 

789 visited.add(baseClass) 

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

791 

792 while True: 

793 try: 

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

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

796 yield base 

797 if len(base.__bases__) > 0: 

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

799 else: 

800 continue 

801 

802 except StopIteration: 

803 iteratorStack.pop() 

804 

805 if len(iteratorStack) == 0: 

806 break 

807 

808 @classmethod 

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

810 """ 

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

812 

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

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

815 

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

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

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

819 """ 

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

821 return 

822 

823 typeStack: List[type] = list() 

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

825 

826 for baseClass in baseClasses: 

827 typeStack.append(baseClass) 

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

829 

830 while True: 

831 try: 

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

833 typeStack.append(base) 

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

835 yield tuple(typeStack) 

836 typeStack.pop() 

837 else: 

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

839 

840 except StopIteration: 

841 typeStack.pop() 

842 iteratorStack.pop() 

843 

844 if len(typeStack) == 0: 

845 break 

846 

847 @classmethod 

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

849 """ 

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

851 

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

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

854 

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

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

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

858 """ 

859 abstractMethods = {} 

860 if baseClasses: 

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

862 for baseClass in baseClasses: 

863 if hasattr(baseClass, "__abstractMethods__"): 

864 abstractMethods.update(baseClass.__abstractMethods__) 

865 

866 for base in baseClasses: 

867 for key, value in base.__dict__.items(): 

868 if (key in abstractMethods and isinstance(value, FunctionType) and 

869 not (hasattr(value, "__abstract__") or hasattr(value, "__mustOverride__"))): 

870 def outer(method): 

871 @wraps(method) 

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

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

874 

875 return inner 

876 

877 members[key] = outer(value) 

878 

879 # Check if methods are marked: 

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

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

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

883 if callable(member): 

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

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

886 abstractMethods[memberName] = member 

887 elif memberName in abstractMethods: 

888 del abstractMethods[memberName] 

889 

890 return abstractMethods, members 

891 

892 @classmethod 

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

894 """ 

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

896 

897 Only the first object creation initializes the object. 

898 

899 This implementation is threadsafe. 

900 

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

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

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

904 """ 

905 if hasattr(newClass, "__isSingleton__"): 

906 singleton = newClass.__isSingleton__ 

907 

908 if singleton: 

909 oldnew = newClass.__new__ 

910 if hasattr(oldnew, "__singleton_wrapper__"): 

911 oldnew = oldnew.__wrapped__ 

912 

913 oldinit = newClass.__init__ 

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

915 oldinit = oldinit.__wrapped__ 

916 

917 @wraps(oldnew) 

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

919 with cls.__singletonInstanceCond__: 

920 if cls.__singletonInstanceCache__ is None: 

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

922 cls.__singletonInstanceCache__ = obj 

923 else: 

924 obj = cls.__singletonInstanceCache__ 

925 

926 return obj 

927 

928 @wraps(oldinit) 

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

930 cls = self.__class__ 

931 cv = cls.__singletonInstanceCond__ 

932 with cv: 

933 if cls.__singletonInstanceInit__: 

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

935 cls.__singletonInstanceInit__ = False 

936 cv.notify_all() 

937 elif args or kwargs: 

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

939 else: 

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

941 cv.wait() 

942 

943 singleton_new.__singleton_wrapper__ = True 

944 singleton_init.__singleton_wrapper__ = True 

945 

946 newClass.__new__ = singleton_new 

947 newClass.__init__ = singleton_init 

948 newClass.__singletonInstanceCond__ = Condition() 

949 newClass.__singletonInstanceInit__ = True 

950 newClass.__singletonInstanceCache__ = None 

951 return True 

952 

953 return False 

954 

955 @classmethod 

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

957 """ 

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

959 

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

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

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

963 """ 

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

965 if len(newClass.__abstractMethods__) > 0: 

966 oldnew = newClass.__new__ 

967 if hasattr(oldnew, "__raises_abstract_class_error__"): 

968 oldnew = oldnew.__wrapped__ 

969 

970 @wraps(oldnew) 

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

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

973 

974 abstract_new.__raises_abstract_class_error__ = True 

975 

976 newClass.__new__ = abstract_new 

977 return True 

978 

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

980 else: 

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

982 try: 

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

984 origNew = newClass.__new__.__wrapped__ 

985 

986 # WORKAROUND: __new__ checks tp_new and implements different behavior 

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

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

989 @wraps(object.__new__) 

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

991 return object.__new__(inst) 

992 

993 newClass.__new__ = wrapped_new 

994 else: 

995 newClass.__new__ = origNew 

996 elif newClass.__new__.__isSingleton__: 

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

998 except AttributeError as ex: 

999 # WORKAROUND: 

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

1001 try: 

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

1003 raise ex 

1004 except AttributeError: 

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

1006 raise ex 

1007 

1008 return False 

1009 

1010 # Additional properties and methods on a class 

1011 @property 

1012 def HasClassAttributes(self) -> bool: 

1013 """ 

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

1015 

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

1017 """ 

1018 try: 

1019 return len(self.__pyattr__) > 0 

1020 except AttributeError: 

1021 return False 

1022 

1023 @property 

1024 def HasMethodAttributes(self) -> bool: 

1025 """ 

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

1027 

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

1029 """ 

1030 try: 

1031 return len(self.__methodsWithAttributes__) > 0 

1032 except AttributeError: 

1033 return False 

1034 

1035 

1036@export 

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

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