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
« 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).
35.. hint::
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
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.*'!")
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
61__all__ = ["M"]
63TAttr = TypeVar("TAttr") # , bound='Attribute')
64"""A type variable for :class:`~pyTooling.Attributes.Attribute`."""
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."""
71@export
72class ExtendedTypeError(ToolingException):
73 """The exception is raised by the meta-class :class:`~pyTooling.Metaclasses.ExtendedType`."""
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__``.
81 .. seealso::
83 * :ref:`Python data model for slots <slots>`
84 * :term:`Glossary entry __slots__ <__slots__>`
85 """
88@export
89class BaseClassWithNonEmptySlotsError(ExtendedTypeError):
90 """
91 This exception is raised when a mixin-class uses slots, but Python prohibits slots.
93 .. important::
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 """
101@export
102class BaseClassIsNotAMixinError(ExtendedTypeError):
103 pass
106@export
107class DuplicateFieldInSlotsError(ExtendedTypeError):
108 """
109 This exception is raised when a slot name is used multiple times within the inheritance hierarchy.
110 """
113@export
114class AbstractClassError(ExtendedTypeError):
115 """
116 This exception is raised, when a class contains methods marked with *abstractmethod* or *must-override*.
118 .. seealso::
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 """
129@export
130class MustOverrideClassError(AbstractClassError):
131 """
132 This exception is raised, when a class contains methods marked with *must-override*.
134 .. seealso::
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 """
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# """
154M = TypeVar("M", bound=Callable) #: A type variable for methods.
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?
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
177 return metacls(cls.__name__, bases, members, slots=True)
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?
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
200 return metacls(cls.__name__, bases, members, mixin=True)
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?
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
223 return metacls(cls.__name__, bases, members, singleton=True)
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`.
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.
234 .. warning::
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.
240 .. admonition:: ``example.py``
242 .. code-block:: python
244 class Data(mataclass=ExtendedType):
245 @abstractmethod
246 def method(self) -> bool:
247 '''This method needs to be implemented'''
249 :param method: Method that is marked as *abstract*.
250 :returns: Replacement method, which raises a :exc:`NotImplementedError`.
252 .. seealso::
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.")
262 func.__abstract__ = True
263 return func
266@export
267def mustoverride(method: M) -> M:
268 """
269 Mark a method as *must-override*.
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.
275 A *must-override* methods can offer a partial implementation, which is called via ``super()...``.
277 .. warning::
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.
283 .. admonition:: ``example.py``
285 .. code-block:: python
287 class Data(mataclass=ExtendedType):
288 @mustoverride
289 def method(self):
290 '''This is a very basic implementation'''
292 :param method: Method that is marked as *must-override*.
293 :returns: Same method, but with additional ``<method>.__mustOverride__`` field.
295 .. seealso::
297 * :exc:`~pyTooling.Exceptions.MustOverrideClassError`
298 * :func:`~pyTooling.Metaclasses.abstractmethod`
299 * :func:`~pyTooling.Metaclasses.notimplemented`
300 """
301 method.__mustOverride__ = True
302 return method
305# @export
306# def overloadable(method: M) -> M:
307# method.__overloadable__ = True
308# return method
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
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)
384@export
385class ExtendedType(type):
386 """
387 An updates meta-class to construct new classes with an extended feature set.
389 .. todo:: META::ExtendedType Needs documentation.
390 .. todo:: META::ExtendedType allow __dict__ and __weakref__ if slotted is enabled
392 .. rubric:: Features:
394 * Store object members more efficiently in ``__slots__`` instead of ``_dict__``.
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__``.
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>`.
405 .. #* Allow method overloading and dispatch overloads based on argument signatures.
407 .. rubric:: Added class fields:
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.
425 .. rubric:: Added class properties:
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.
430 .. rubric:: Added methods:
432 If slots are used, the following methods are added to support :mod:`pickle`:
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.
439 .. rubric:: Modified ``__new__`` method:
441 If class is a singleton, ``__new__`` will be replaced by a wrapper method. This wrapper is marked with ``__singleton_wrapper__``.
443 If class is abstract, ``__new__`` will be replaced by a method raising an exception. This replacement is marked with ``__raises_abstract_class_error__``.
445 .. rubric:: Modified ``__init__`` method:
447 If class is a singleton, ``__init__`` will be replaced by a wrapper method. This wrapper is marked by ``__singleton_wrapper__``.
449 .. rubric:: Modified abstract methods:
451 If a method is abstract, its marked with ``__abstract__``. |br|
452 If a method is must override, its marked with ``__mustOverride__``.
453 """
455 # @classmethod
456 # def __prepare__(cls, className, baseClasses, slots: bool = False, mixin: bool = False, singleton: bool = False):
457 # return DispatchDictionary()
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`.
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
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__
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)
497 # Compute abstract methods
498 abstractMethods, members = self._checkForAbstractMethods(baseClasses, members)
500 # Create a new class
501 newClass = type.__new__(self, className, baseClasses, members)
503 # Apply class fields
504 for fieldName, typeAnnotation in classFields.items():
505 setattr(newClass, fieldName, typeAnnotation)
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)
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
521 newClass.__getstate__ = __getstate__
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
532 for slotName, value in state.items():
533 setattr(self, slotName, value)
535 newClass.__setstate__ = __setstate__
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)
548 # Check methods for attributes
549 methods, methodsWithAttributes = self._findMethods(newClass, baseClasses, members)
551 # Add new fields for found methods
552 newClass.__methods__ = tuple(methods)
553 newClass.__methodsWithAttributes__ = tuple(methodsWithAttributes)
555 # Additional methods on a class
556 def GetMethodsWithAttributes(self, predicate: Nullable[TAttributeFilter[TAttr]] = None) -> Dict[Callable, Tuple["Attribute", ...]]:
557 """
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
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'.")
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'.")
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)
590 if len(matchingAttributes) > 0:
591 methodAttributePairs[method] = tuple(matchingAttributes)
593 return methodAttributePairs
595 newClass.GetMethodsWithAttributes = classmethod(GetMethodsWithAttributes)
596 GetMethodsWithAttributes.__qualname__ = f"{className}.{GetMethodsWithAttributes.__name__}"
598 # GetMethods(predicate) -> dict[method, list[attribute]] / generator
599 # GetClassAtrributes -> list[attributes] / generator
600 # MethodHasAttributes(predicate) -> bool
601 # GetAttribute
603 return newClass
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`.
615 .. todo::
617 Describe algorithm.
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
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__
637 boundMethod = func.__get__(instance, instance.__class__)
638 setattr(instance, methodName, boundMethod)
640 return boundMethod
642 methods = []
643 methodsWithAttributes = []
644 attributeIndex = {}
646 for base in baseClasses:
647 if hasattr(base, "__methodsWithAttributes__"):
648 methodsWithAttributes.extend(base.__methodsWithAttributes__)
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)
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()
668 method.GetAttributes = bind(method, GetAttributes)
669 methods.append(method)
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)
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
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.
704 .. todo::
706 Describe algorithm.
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
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
731 # FIXME: should have a check for non-empty slots on secondary base-classes too
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
741 for annotation in base.__slots__:
742 inheritedSlottedFields[annotation] = base
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 = {}
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__}'.")
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]
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)
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]
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
813 @classmethod
814 def _aggregateMixinSlots(self, className: str, baseClasses: Tuple[type]) -> List[str]:
815 """
816 Aggregate slot names requested by mixin-base-classes.
818 .. todo::
820 Describe algorithm.
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
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__)
855 return mixinSlots
857 @classmethod
858 def _iterateBaseClasses(metacls, baseClasses: Tuple[type]) -> Generator[type, None, None]:
859 """
860 Return a generator to iterate (visit) all base-classes ...
862 .. todo::
864 Describe iteration order.
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
872 visited: Set[type] = set()
873 iteratorStack: List[Iterator[type]] = list()
875 for baseClass in baseClasses:
876 yield baseClass
877 visited.add(baseClass)
878 iteratorStack.append(iter(baseClass.__bases__))
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
890 except StopIteration:
891 iteratorStack.pop()
893 if len(iteratorStack) == 0:
894 break
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.
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).
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
911 typeStack: List[type] = list()
912 iteratorStack: List[Iterator[type]] = list()
914 for baseClass in baseClasses:
915 typeStack.append(baseClass)
916 iteratorStack.append(iter(baseClass.__bases__))
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__))
928 except StopIteration:
929 typeStack.pop()
930 iteratorStack.pop()
932 if len(typeStack) == 0:
933 break
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.
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.
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__)
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)
963 return inner
965 members[memberName] = outer(member)
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]
978 return abstractMethods, members
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.
985 Only the first object creation initializes the object.
987 This implementation is threadsafe.
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__
996 if singleton:
997 oldnew = newClass.__new__
998 if hasattr(oldnew, "__singleton_wrapper__"):
999 oldnew = oldnew.__wrapped__
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__
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__
1014 return obj
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()
1031 singleton_new.__singleton_wrapper__ = True
1032 singleton_init.__singleton_wrapper__ = True
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
1041 return False
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.
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__
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.""")
1062 abstract_new.__raises_abstract_class_error__ = True
1064 newClass.__new__ = abstract_new
1065 return True
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__
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)
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
1096 return False
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__`).
1104 :returns: ``True``, if the class has Attributes.
1105 """
1106 try:
1107 return len(self.__pyattr__) > 0
1108 except AttributeError:
1109 return False
1111 @readonly
1112 def HasMethodAttributes(self) -> bool:
1113 """
1114 Read-only property to check if the class has methods with Attributes (:attr:`__methodsWithAttributes__`).
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
1124@export
1125class SlottedObject(metaclass=ExtendedType, slots=True):
1126 """Classes derived from this class will store all members in ``__slots__``."""