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
« 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).
35.. hint::
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
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.*'!")
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
60__all__ = ["M"]
62TAttr = TypeVar("TAttr") # , bound='Attribute')
63"""A type variable for :class:`~pyTooling.Attributes.Attribute`."""
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."""
70@export
71class ExtendedTypeError(ToolingException):
72 """The exception is raised by the meta-class :class:`~pyTooling.Metaclasses.ExtendedType`."""
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__``.
80 .. seealso::
82 * :ref:`Python data model for slots <slots>`
83 * :term:`Glossary entry __slots__ <__slots__>`
84 """
87@export
88class BaseClassWithNonEmptySlotsError(ExtendedTypeError):
89 """
90 This exception is raised when a mixin-class uses slots, but Python prohibits slots.
92 .. important::
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 """
100@export
101class BaseClassIsNotAMixinError(ExtendedTypeError):
102 pass
105@export
106class DuplicateFieldInSlotsError(ExtendedTypeError):
107 """
108 This exception is raised when a slot name is used multiple times within the inheritance hierarchy.
109 """
112@export
113class AbstractClassError(ExtendedTypeError):
114 """
115 This exception is raised, when a class contains methods marked with *abstractmethod* or *must-override*.
117 .. seealso::
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 """
128@export
129class MustOverrideClassError(AbstractClassError):
130 """
131 This exception is raised, when a class contains methods marked with *must-override*.
133 .. seealso::
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 """
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# """
153M = TypeVar("M", bound=Callable) #: A type variable for methods.
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?
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
176 return metacls(cls.__name__, bases, members, slots=True)
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?
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
199 return metacls(cls.__name__, bases, members, mixin=True)
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?
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
222 return metacls(cls.__name__, bases, members, singleton=True)
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`.
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.
233 .. warning::
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.
239 .. admonition:: ``example.py``
241 .. code-block:: python
243 class Data(mataclass=ExtendedType):
244 @abstractmethod
245 def method(self) -> bool:
246 '''This method needs to be implemented'''
248 :param method: Method that is marked as *abstract*.
249 :returns: Replacement method, which raises a :exc:`NotImplementedError`.
251 .. seealso::
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.")
261 func.__abstract__ = True
262 return func
265@export
266def mustoverride(method: M) -> M:
267 """
268 Mark a method as *must-override*.
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.
274 A *must-override* methods can offer a partial implementation, which is called via ``super()...``.
276 .. warning::
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.
282 .. admonition:: ``example.py``
284 .. code-block:: python
286 class Data(mataclass=ExtendedType):
287 @mustoverride
288 def method(self):
289 '''This is a very basic implementation'''
291 :param method: Method that is marked as *must-override*.
292 :returns: Same method, but with additional ``<method>.__mustOverride__`` field.
294 .. seealso::
296 * :exc:`~pyTooling.Exceptions.MustOverrideClassError`
297 * :func:`~pyTooling.Metaclasses.abstractmethod`
298 * :func:`~pyTooling.Metaclasses.notimplemented`
299 """
300 method.__mustOverride__ = True
301 return method
304# @export
305# def overloadable(method: M) -> M:
306# method.__overloadable__ = True
307# return method
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
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)
383@export
384class ExtendedType(type):
385 """
386 An updates meta-class to construct new classes with an extended feature set.
388 .. todo:: META::ExtendedType Needs documentation.
389 .. todo:: META::ExtendedType allow __dict__ and __weakref__ if slotted is enabled
391 Features:
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>`.
398 .. #* Allow method overloading and dispatch overloads based on argument signatures.
399 """
401 # @classmethod
402 # def __prepare__(cls, className, baseClasses, slots: bool = False, mixin: bool = False, singleton: bool = False):
403 # return DispatchDictionary()
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`.
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
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__
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)
436 # Compute abstract methods
437 abstractMethods, members = self._checkForAbstractMethods(baseClasses, members)
439 # Create a new class
440 newClass = type.__new__(self, className, baseClasses, members)
442 # Apply class fields
443 for fieldName, typeAnnotation in classFields.items():
444 setattr(newClass, fieldName, typeAnnotation)
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)
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)
462 # Check methods for attributes
463 methods, methodsWithAttributes = self._findMethods(newClass, baseClasses, members)
465 # Add new fields for found methods
466 newClass.__methods__ = tuple(methods)
467 newClass.__methodsWithAttributes__ = tuple(methodsWithAttributes)
469 # Additional methods on a class
470 def GetMethodsWithAttributes(self, predicate: Nullable[TAttributeFilter[TAttr]] = None) -> Dict[Callable, Tuple["Attribute", ...]]:
471 """
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
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'.")
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'.")
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)
504 if len(matchingAttributes) > 0:
505 methodAttributePairs[method] = tuple(matchingAttributes)
507 return methodAttributePairs
509 newClass.GetMethodsWithAttributes = classmethod(GetMethodsWithAttributes)
510 GetMethodsWithAttributes.__qualname__ = f"{className}.{GetMethodsWithAttributes.__name__}"
512 # GetMethods(predicate) -> dict[method, list[attribute]] / generator
513 # GetClassAtrributes -> list[attributes] / generator
514 # MethodHasAttributes(predicate) -> bool
515 # GetAttribute
517 return newClass
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`.
529 .. todo::
531 Describe algorithm.
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
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__
551 boundMethod = func.__get__(instance, instance.__class__)
552 setattr(instance, methodName, boundMethod)
554 return boundMethod
556 methods = []
557 methodsWithAttributes = []
558 attributeIndex = {}
560 for base in baseClasses:
561 if hasattr(base, "__methodsWithAttributes__"):
562 methodsWithAttributes.extend(base.__methodsWithAttributes__)
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)
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()
582 method.GetAttributes = bind(method, GetAttributes)
583 methods.append(method)
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)
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
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.
618 .. todo::
620 Describe algorithm.
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
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
645 # FIXME: should have a check for non-empty slots on secondary base-classes too
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
655 for annotation in base.__slots__:
656 inheritedSlottedFields[annotation] = base
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 = {}
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__}'.")
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]
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)
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]
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
725 @classmethod
726 def _aggregateMixinSlots(self, className: str, baseClasses: Tuple[type]) -> List[str]:
727 """
728 Aggregate slot names requested by mixin-base-classes.
730 .. todo::
732 Describe algorithm.
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
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__)
767 return mixinSlots
769 @classmethod
770 def _iterateBaseClasses(metacls, baseClasses: Tuple[type]) -> Generator[type, None, None]:
771 """
772 Return a generator to iterate (visit) all base-classes ...
774 .. todo::
776 Describe iteration order.
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
784 visited: Set[type] = set()
785 iteratorStack: List[Iterator[type]] = list()
787 for baseClass in baseClasses:
788 yield baseClass
789 visited.add(baseClass)
790 iteratorStack.append(iter(baseClass.__bases__))
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
802 except StopIteration:
803 iteratorStack.pop()
805 if len(iteratorStack) == 0:
806 break
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.
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).
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
823 typeStack: List[type] = list()
824 iteratorStack: List[Iterator[type]] = list()
826 for baseClass in baseClasses:
827 typeStack.append(baseClass)
828 iteratorStack.append(iter(baseClass.__bases__))
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__))
840 except StopIteration:
841 typeStack.pop()
842 iteratorStack.pop()
844 if len(typeStack) == 0:
845 break
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.
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.
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__)
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)
875 return inner
877 members[key] = outer(value)
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]
890 return abstractMethods, members
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.
897 Only the first object creation initializes the object.
899 This implementation is threadsafe.
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__
908 if singleton:
909 oldnew = newClass.__new__
910 if hasattr(oldnew, "__singleton_wrapper__"):
911 oldnew = oldnew.__wrapped__
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__
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__
926 return obj
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()
943 singleton_new.__singleton_wrapper__ = True
944 singleton_init.__singleton_wrapper__ = True
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
953 return False
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.
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__
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.""")
974 abstract_new.__raises_abstract_class_error__ = True
976 newClass.__new__ = abstract_new
977 return True
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__
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)
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
1008 return False
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__`).
1016 :returns: ``True``, if the class has Attributes.
1017 """
1018 try:
1019 return len(self.__pyattr__) > 0
1020 except AttributeError:
1021 return False
1023 @property
1024 def HasMethodAttributes(self) -> bool:
1025 """
1026 Read-only property to check if the class has methods with Attributes (:attr:`__methodsWithAttributes__`).
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
1036@export
1037class SlottedObject(metaclass=ExtendedType, slots=True):
1038 """Classes derived from this class will store all members in ``__slots__``."""