Coverage for pyTooling / MetaClasses / __init__.py: 84%
435 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-02-20 22:29 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-02-20 22:29 +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
47from pyTooling.Exceptions import ToolingException
48from pyTooling.Decorators import export, readonly
51__all__ = ["M"]
53TAttr = TypeVar("TAttr") # , bound='Attribute')
54"""A type variable for :class:`~pyTooling.Attributes.Attribute`."""
56TAttributeFilter = Union[TAttr, Iterable[TAttr], None]
57"""A type hint for a predicate parameter that accepts either a single :class:`~pyTooling.Attributes.Attribute` or an
58iterable of those."""
61@export
62class ExtendedTypeError(ToolingException):
63 """The exception is raised by the meta-class :class:`~pyTooling.Metaclasses.ExtendedType`."""
66@export
67class BaseClassWithoutSlotsError(ExtendedTypeError):
68 """
69 This exception is raised when a class using ``__slots__`` inherits from at-least one base-class not using ``__slots__``.
71 .. seealso::
73 * :ref:`Python data model for slots <slots>`
74 * :term:`Glossary entry __slots__ <__slots__>`
75 """
78@export
79class BaseClassWithNonEmptySlotsError(ExtendedTypeError):
80 """
81 This exception is raised when a mixin-class uses slots, but Python prohibits slots.
83 .. important::
85 To fulfill Python's requirements on slots, pyTooling uses slots only on the prinmary inheritance line.
86 Mixin-classes collect slots, which get materialized when the mixin-class (secondary inheritance lines) gets merged
87 into the primary inheritance line.
88 """
91@export
92class BaseClassIsNotAMixinError(ExtendedTypeError):
93 pass
96@export
97class DuplicateFieldInSlotsError(ExtendedTypeError):
98 """
99 This exception is raised when a slot name is used multiple times within the inheritance hierarchy.
100 """
103@export
104class AbstractClassError(ExtendedTypeError):
105 """
106 This exception is raised, when a class contains methods marked with *abstractmethod* or *must-override*.
108 .. seealso::
110 :func:`@abstractmethod <pyTooling.MetaClasses.abstractmethod>`
111 |rarr| Mark a method as *abstract*.
112 :func:`@mustoverride <pyTooling.MetaClasses.mustoverride>`
113 |rarr| Mark a method as *must overrride*.
114 :exc:`~MustOverrideClassError`
115 |rarr| Exception raised, if a method is marked as *must-override*.
116 """
119@export
120class MustOverrideClassError(AbstractClassError):
121 """
122 This exception is raised, when a class contains methods marked with *must-override*.
124 .. seealso::
126 :func:`@abstractmethod <pyTooling.MetaClasses.abstractmethod>`
127 |rarr| Mark a method as *abstract*.
128 :func:`@mustoverride <pyTooling.MetaClasses.mustoverride>`
129 |rarr| Mark a method as *must overrride*.
130 :exc:`~AbstractClassError`
131 |rarr| Exception raised, if a method is marked as *abstract*.
132 """
135# """
136# Metaclass that allows multiple dispatch of methods based on method signatures.
137#
138# .. seealso:
139#
140# `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>`__
141# """
144M = TypeVar("M", bound=Callable) #: A type variable for methods.
147@export
148def slotted(cls):
149 if cls.__class__ is type:
150 metacls = ExtendedType
151 elif issubclass(cls.__class__, ExtendedType):
152 metacls = cls.__class__
153 for method in cls.__methods__:
154 delattr(method, "__classobj__")
155 else:
156 raise ExtendedTypeError("Class uses an incompatible meta-class.") # FIXME: create exception for it?
158 bases = tuple(base for base in cls.__bases__ if base is not object)
159 slots = cls.__dict__["__slots__"] if "__slots__" in cls.__dict__ else tuple()
160 members = {
161 "__qualname__": cls.__qualname__
162 }
163 for key, value in cls.__dict__.items():
164 if key not in slots:
165 members[key] = value
167 return metacls(cls.__name__, bases, members, slots=True)
170@export
171def mixin(cls):
172 if cls.__class__ is type:
173 metacls = ExtendedType
174 elif issubclass(cls.__class__, ExtendedType): 174 ↛ 179line 174 didn't jump to line 179 because the condition on line 174 was always true
175 metacls = cls.__class__
176 for method in cls.__methods__:
177 delattr(method, "__classobj__")
178 else:
179 raise ExtendedTypeError("Class uses an incompatible meta-class.") # FIXME: create exception for it?
181 bases = tuple(base for base in cls.__bases__ if base is not object)
182 slots = cls.__dict__["__slots__"] if "__slots__" in cls.__dict__ else tuple()
183 members = {
184 "__qualname__": cls.__qualname__
185 }
186 for key, value in cls.__dict__.items():
187 if key not in slots:
188 members[key] = value
190 return metacls(cls.__name__, bases, members, mixin=True)
193@export
194def singleton(cls):
195 if cls.__class__ is type:
196 metacls = ExtendedType
197 elif issubclass(cls.__class__, ExtendedType):
198 metacls = cls.__class__
199 for method in cls.__methods__:
200 delattr(method, "__classobj__")
201 else:
202 raise ExtendedTypeError("Class uses an incompatible meta-class.") # FIXME: create exception for it?
204 bases = tuple(base for base in cls.__bases__ if base is not object)
205 slots = cls.__dict__["__slots__"] if "__slots__" in cls.__dict__ else tuple()
206 members = {
207 "__qualname__": cls.__qualname__
208 }
209 for key, value in cls.__dict__.items():
210 if key not in slots:
211 members[key] = value
213 return metacls(cls.__name__, bases, members, singleton=True)
216@export
217def abstractmethod(method: M) -> M:
218 """
219 Mark a method as *abstract* and replace the implementation with a new method raising a :exc:`NotImplementedError`.
221 The original method is stored in ``<method>.__wrapped__`` and it's doc-string is copied to the replacing method. In
222 additional field ``<method>.__abstract__`` is added.
224 .. warning::
226 This decorator should be used in combination with meta-class :class:`~pyTooling.Metaclasses.ExtendedType`.
227 Otherwise, an abstract class itself doesn't throw a :exc:`~pyTooling.Exceptions.AbstractClassError` at
228 instantiation.
230 .. admonition:: ``example.py``
232 .. code-block:: python
234 class Data(mataclass=ExtendedType):
235 @abstractmethod
236 def method(self) -> bool:
237 '''This method needs to be implemented'''
239 :param method: Method that is marked as *abstract*.
240 :returns: Replacement method, which raises a :exc:`NotImplementedError`.
242 .. seealso::
244 * :exc:`~pyTooling.Exceptions.AbstractClassError`
245 * :func:`~pyTooling.Metaclasses.mustoverride`
246 * :func:`~pyTooling.Metaclasses.notimplemented`
247 """
248 @wraps(method)
249 def func(self) -> NoReturn:
250 raise NotImplementedError(f"Method '{method.__name__}' is abstract and needs to be overridden in a derived class.")
252 func.__abstract__ = True
253 return func
256@export
257def mustoverride(method: M) -> M:
258 """
259 Mark a method as *must-override*.
261 The returned function is the original function, but with an additional field ``<method>.____mustOverride__``, so a
262 meta-class can identify a *must-override* method and raise an error. Such an error is not raised if the method is
263 overridden by an inheriting class.
265 A *must-override* methods can offer a partial implementation, which is called via ``super()...``.
267 .. warning::
269 This decorator needs to be used in combination with meta-class :class:`~pyTooling.Metaclasses.ExtendedType`.
270 Otherwise, an abstract class itself doesn't throw a :exc:`~pyTooling.Exceptions.MustOverrideClassError` at
271 instantiation.
273 .. admonition:: ``example.py``
275 .. code-block:: python
277 class Data(mataclass=ExtendedType):
278 @mustoverride
279 def method(self):
280 '''This is a very basic implementation'''
282 :param method: Method that is marked as *must-override*.
283 :returns: Same method, but with additional ``<method>.__mustOverride__`` field.
285 .. seealso::
287 * :exc:`~pyTooling.Exceptions.MustOverrideClassError`
288 * :func:`~pyTooling.Metaclasses.abstractmethod`
289 * :func:`~pyTooling.Metaclasses.notimplemented`
290 """
291 method.__mustOverride__ = True
292 return method
295# @export
296# def overloadable(method: M) -> M:
297# method.__overloadable__ = True
298# return method
301# @export
302# class DispatchableMethod:
303# """Represents a single multimethod."""
304#
305# _methods: Dict[Tuple, Callable]
306# __name__: str
307# __slots__ = ("_methods", "__name__")
308#
309# def __init__(self, name: str) -> None:
310# self.__name__ = name
311# self._methods = {}
312#
313# def __call__(self, *args: Any):
314# """Call a method based on type signature of the arguments."""
315# types = tuple(type(arg) for arg in args[1:])
316# meth = self._methods.get(types, None)
317# if meth:
318# return meth(*args)
319# else:
320# raise TypeError(f"No matching method for types {types}.")
321#
322# def __get__(self, instance, cls): # Starting with Python 3.11+, use typing.Self as return type
323# """Descriptor method needed to make calls work in a class."""
324# if instance is not None:
325# return MethodType(self, instance)
326# else:
327# return self
328#
329# def register(self, method: Callable) -> None:
330# """Register a new method as a dispatchable."""
331#
332# # Build a signature from the method's type annotations
333# sig = signature(method)
334# types: List[Type] = []
335#
336# for name, parameter in sig.parameters.items():
337# if name == "self":
338# continue
339#
340# if parameter.annotation is Parameter.empty:
341# raise TypeError(f"Parameter '{name}' in method '{method.__name__}' must be annotated with a type.")
342#
343# if not isinstance(parameter.annotation, type):
344# raise TypeError(f"Parameter '{name}' in method '{method.__name__}' annotation must be a type.")
345#
346# if parameter.default is not Parameter.empty:
347# self._methods[tuple(types)] = method
348#
349# types.append(parameter.annotation)
350#
351# self._methods[tuple(types)] = method
354# @export
355# class DispatchDictionary(dict):
356# """Special dictionary to build dispatchable methods in a metaclass."""
357#
358# def __setitem__(self, key: str, value: Any):
359# if callable(value) and key in self:
360# # If key already exists, it must be a dispatchable method or callable
361# currentValue = self[key]
362# if isinstance(currentValue, DispatchableMethod):
363# currentValue.register(value)
364# else:
365# dispatchable = DispatchableMethod(key)
366# dispatchable.register(currentValue)
367# dispatchable.register(value)
368#
369# super().__setitem__(key, dispatchable)
370# else:
371# super().__setitem__(key, value)
374@export
375class ExtendedType(type):
376 """
377 An updates meta-class to construct new classes with an extended feature set.
379 .. todo:: META::ExtendedType Needs documentation.
380 .. todo:: META::ExtendedType allow __dict__ and __weakref__ if slotted is enabled
382 .. rubric:: Features:
384 * Store object members more efficiently in ``__slots__`` instead of ``_dict__``.
386 * Implement ``__slots__`` only on primary inheritance line.
387 * Collect class variables on secondary inheritance lines (mixin-classes) and defer implementation as ``__slots__``.
388 * Handle object state exporting and importing for slots (:mod:`pickle` support) via ``__getstate__``/``__setstate__``.
390 * Allow only a single instance to be created (:term:`singleton`). |br|
391 Further instantiations will return the previously create instance (identical object).
392 * Define methods as :term:`abstract <abstract method>` or :term:`must-override <mustoverride method>` and prohibit
393 instantiation of :term:`abstract classes <abstract class>`.
395 .. #* Allow method overloading and dispatch overloads based on argument signatures.
397 .. rubric:: Added class fields:
399 :__slotted__: True, if class uses `__slots__`.
400 :__allSlots__: Set of class fields stored in slots for all classes in the inheritance hierarchy.
401 :__slots__: Tuple of class fields stored in slots for current class in the inheritance hierarchy. |br|
402 See :pep:`253` for details.
403 :__isMixin__: True, if class is a mixin-class
404 :__mixinSlots__: List of collected slots from secondary inheritance hierarchy (mixin hierarchy).
405 :__methods__: List of methods.
406 :__methodsWithAttributes__: List of methods with pyTooling attributes.
407 :__abstractMethods__: List of abstract methods, which need to be implemented in the next class hierarchy levels.
408 :__isAbstract__: True, if class is abstract.
409 :__isSingleton__: True, if class is a singleton
410 :__singletonInstanceCond__: Condition variable to protect the singleton creation.
411 :__singletonInstanceInit__: Singleton is initialized.
412 :__singletonInstanceCache__: The singleton object, once created.
413 :__pyattr__: List of class attributes.
415 .. rubric:: Added class properties:
417 :HasClassAttributes: Read-only property to check if the class has Attributes.
418 :HasMethodAttributes: Read-only property to check if the class has methods with Attributes.
420 .. rubric:: Added methods:
422 If slots are used, the following methods are added to support :mod:`pickle`:
424 :__getstate__: Export an object's state for serialization. |br|
425 See :pep:`307` for details.
426 :__setstate__: Import an object's state for deserialization. |br|
427 See :pep:`307` for details.
429 .. rubric:: Modified ``__new__`` method:
431 If class is a singleton, ``__new__`` will be replaced by a wrapper method. This wrapper is marked with ``__singleton_wrapper__``.
433 If class is abstract, ``__new__`` will be replaced by a method raising an exception. This replacement is marked with ``__raises_abstract_class_error__``.
435 .. rubric:: Modified ``__init__`` method:
437 If class is a singleton, ``__init__`` will be replaced by a wrapper method. This wrapper is marked by ``__singleton_wrapper__``.
439 .. rubric:: Modified abstract methods:
441 If a method is abstract, its marked with ``__abstract__``. |br|
442 If a method is must override, its marked with ``__mustOverride__``.
443 """
445 # @classmethod
446 # def __prepare__(cls, className, baseClasses, slots: bool = False, mixin: bool = False, singleton: bool = False):
447 # return DispatchDictionary()
449 def __new__(
450 self,
451 className: str,
452 baseClasses: Tuple[type],
453 members: Dict[str, Any],
454 slots: bool = False,
455 mixin: bool = False,
456 singleton: bool = False
457 ) -> Self:
458 """
459 Construct a new class using this :term:`meta-class`.
461 :param className: The name of the class to construct.
462 :param baseClasses: The tuple of :term:`base-classes <base-class>` the class is derived from.
463 :param members: The dictionary of members for the constructed class.
464 :param slots: If true, store object attributes in :term:`__slots__ <slots>` instead of ``__dict__``.
465 :param mixin: If true, make the class a :term:`Mixin-Class`.
466 If false, create slots if ``slots`` is true.
467 If none, preserve behavior of primary base-class.
468 :param singleton: If true, make the class a :term:`Singleton`.
469 :returns: The new class.
470 :raises AttributeError: If base-class has no '__slots__' attribute.
471 :raises AttributeError: If slot already exists in base-class.
472 """
473 from pyTooling.Attributes import ATTRIBUTES_MEMBER_NAME, AttributeScope
475 # Inherit 'slots' feature from primary base-class
476 if len(baseClasses) > 0:
477 primaryBaseClass = baseClasses[0]
478 if isinstance(primaryBaseClass, self):
479 slots = primaryBaseClass.__slotted__
481 # Compute slots and mixin-slots from annotated fields as well as class- and object-fields with initial values.
482 classFields, objectFields = self._computeSlots(className, baseClasses, members, slots, mixin)
484 # Compute abstract methods
485 abstractMethods, members = self._checkForAbstractMethods(baseClasses, members)
487 # Create a new class
488 newClass = type.__new__(self, className, baseClasses, members)
490 # Apply class fields
491 for fieldName, typeAnnotation in classFields.items():
492 setattr(newClass, fieldName, typeAnnotation)
494 # Search in inheritance tree for abstract methods
495 newClass.__abstractMethods__ = abstractMethods
496 newClass.__isAbstract__ = self._wrapNewMethodIfAbstract(newClass)
497 newClass.__isSingleton__ = self._wrapNewMethodIfSingleton(newClass, singleton)
499 if slots:
500 # If slots are used, implement __getstate__/__setstate__ API to support serialization using pickle.
501 if "__getstate__" not in members:
502 def __getstate__(self) -> Dict[str, Any]:
503 try:
504 return {slotName: getattr(self, slotName) for slotName in self.__allSlots__}
505 except AttributeError as ex:
506 raise ExtendedTypeError(f"Unassigned field '{ex.name}' in object '{self}' of type '{self.__class__.__name__}'.") from ex
508 newClass.__getstate__ = __getstate__
510 if "__setstate__" not in members:
511 def __setstate__(self, state: Dict[str, Any]) -> None:
512 if self.__allSlots__ != (slots := set(state.keys())):
513 if len(diff := self.__allSlots__.difference(slots)) > 0:
514 raise ExtendedTypeError(f"""Missing fields in parameter 'state': '{"', '".join(diff)}'""") # WORKAROUND: Python <3.12
515 else:
516 diff = slots.difference(self.__allSlots__)
517 raise ExtendedTypeError(f"""Unexpected fields in parameter 'state': '{"', '".join(diff)}'""") # WORKAROUND: Python <3.12
519 for slotName, value in state.items():
520 setattr(self, slotName, value)
522 newClass.__setstate__ = __setstate__
524 # Check for inherited class attributes
525 attributes = []
526 setattr(newClass, ATTRIBUTES_MEMBER_NAME, attributes)
527 for base in baseClasses:
528 if hasattr(base, ATTRIBUTES_MEMBER_NAME):
529 pyAttr = getattr(base, ATTRIBUTES_MEMBER_NAME)
530 for att in pyAttr:
531 if AttributeScope.Class in att.Scope: 531 ↛ 530line 531 didn't jump to line 530 because the condition on line 531 was always true
532 attributes.append(att)
533 att.__class__._classes.append(newClass)
535 # Check methods for attributes
536 methods, methodsWithAttributes = self._findMethods(newClass, baseClasses, members)
538 # Add new fields for found methods
539 newClass.__methods__ = tuple(methods)
540 newClass.__methodsWithAttributes__ = tuple(methodsWithAttributes)
542 # Additional methods on a class
543 def GetMethodsWithAttributes(self, predicate: Nullable[TAttributeFilter[TAttr]] = None) -> Dict[Callable, Tuple["Attribute", ...]]:
544 """
546 :param predicate:
547 :return:
548 :raises ValueError:
549 :raises ValueError:
550 """
551 from pyTooling.Attributes import Attribute
553 if predicate is None:
554 predicate = Attribute
555 elif isinstance(predicate, Iterable): 555 ↛ 556line 555 didn't jump to line 556 because the condition on line 555 was never true
556 for attribute in predicate:
557 if not issubclass(attribute, Attribute):
558 raise ValueError(f"Parameter 'predicate' contains an element which is not a sub-class of 'Attribute'.")
560 predicate = tuple(predicate)
561 elif not issubclass(predicate, Attribute): 561 ↛ 562line 561 didn't jump to line 562 because the condition on line 561 was never true
562 raise ValueError(f"Parameter 'predicate' is not a sub-class of 'Attribute'.")
564 methodAttributePairs = {}
565 for method in newClass.__methodsWithAttributes__:
566 matchingAttributes = []
567 for attribute in method.__pyattr__:
568 if isinstance(attribute, predicate):
569 matchingAttributes.append(attribute)
571 if len(matchingAttributes) > 0:
572 methodAttributePairs[method] = tuple(matchingAttributes)
574 return methodAttributePairs
576 newClass.GetMethodsWithAttributes = classmethod(GetMethodsWithAttributes)
577 GetMethodsWithAttributes.__qualname__ = f"{className}.{GetMethodsWithAttributes.__name__}"
579 # GetMethods(predicate) -> dict[method, list[attribute]] / generator
580 # GetClassAtrributes -> list[attributes] / generator
581 # MethodHasAttributes(predicate) -> bool
582 # GetAttribute
584 return newClass
586 @classmethod
587 def _findMethods(
588 self,
589 newClass: "ExtendedType",
590 baseClasses: Tuple[type],
591 members: Dict[str, Any]
592 ) -> Tuple[List[MethodType], List[MethodType]]:
593 """
594 Find methods and methods with :mod:`pyTooling.Attributes`.
596 .. todo::
598 Describe algorithm.
600 :param newClass: Newly created class instance.
601 :param baseClasses: The tuple of :term:`base-classes <base-class>` the class is derived from.
602 :param members: Members of the new class.
603 :return:
604 """
605 from pyTooling.Attributes import Attribute
607 # Embedded bind function due to circular dependencies.
608 def bind(instance: object, func: FunctionType, methodName: Nullable[str] = None):
609 if methodName is None: 609 ↛ 612line 609 didn't jump to line 612 because the condition on line 609 was always true
610 methodName = func.__name__
612 boundMethod = func.__get__(instance, instance.__class__)
613 setattr(instance, methodName, boundMethod)
615 return boundMethod
617 methods = []
618 methodsWithAttributes = []
619 attributeIndex = {}
621 for base in baseClasses:
622 if hasattr(base, "__methodsWithAttributes__"):
623 methodsWithAttributes.extend(base.__methodsWithAttributes__)
625 for memberName, member in members.items():
626 if isinstance(member, FunctionType):
627 method = newClass.__dict__[memberName]
628 if hasattr(method, "__classobj__") and getattr(method, "__classobj__") is not newClass: 628 ↛ 629line 628 didn't jump to line 629 because the condition on line 628 was never true
629 raise TypeError(f"Method '{memberName}' is used by multiple classes: {method.__classobj__} and {newClass}.")
630 else:
631 setattr(method, "__classobj__", newClass)
633 def GetAttributes(inst: Any, predicate: Nullable[Type[Attribute]] = None) -> Tuple[Attribute, ...]:
634 results = []
635 try:
636 for attribute in inst.__pyattr__: # type: Attribute
637 if isinstance(attribute, predicate):
638 results.append(attribute)
639 return tuple(results)
640 except AttributeError:
641 return tuple()
643 method.GetAttributes = bind(method, GetAttributes)
644 methods.append(method)
646 # print(f" convert function: '{memberName}' to method")
647 # print(f" {member}")
648 if "__pyattr__" in member.__dict__:
649 attributes = member.__pyattr__ # type: List[Attribute]
650 if isinstance(attributes, list) and len(attributes) > 0: 650 ↛ 625line 650 didn't jump to line 625 because the condition on line 650 was always true
651 methodsWithAttributes.append(member)
652 for attribute in attributes:
653 attribute._functions.remove(method)
654 attribute._methods.append(method)
656 # print(f" attributes: {attribute.__class__.__name__}")
657 if attribute not in attributeIndex: 657 ↛ 660line 657 didn't jump to line 660 because the condition on line 657 was always true
658 attributeIndex[attribute] = [member]
659 else:
660 attributeIndex[attribute].append(member)
661 # else:
662 # print(f" But has no attributes.")
663 # else:
664 # print(f" ?? {memberName}")
665 return methods, methodsWithAttributes
667 @classmethod
668 def _computeSlots(
669 self,
670 className: str,
671 baseClasses: Tuple[type],
672 members: Dict[str, Any],
673 slots: bool,
674 mixin: bool
675 ) -> Tuple[Dict[str, Any], Dict[str, Any]]:
676 """
677 Compute which field are listed in __slots__ and which need to be initialized in an instance or class.
679 .. todo::
681 Describe algorithm.
683 :param className: The name of the class to construct.
684 :param baseClasses: Tuple of base-classes.
685 :param members: Dictionary of class members.
686 :param slots: True, if the class should setup ``__slots__``.
687 :param mixin: True, if the class should behave as a mixin-class.
688 :returns: A 2-tuple with a dictionary of class members and object members.
689 """
690 # Compute which field are listed in __slots__ and which need to be initialized in an instance or class.
691 slottedFields = []
692 classFields = {}
693 objectFields = {}
694 if slots or mixin:
695 # If slots are used, all base classes must use __slots__.
696 for baseClass in self._iterateBaseClasses(baseClasses):
697 # Exclude object as a special case
698 if baseClass is object or baseClass is Generic:
699 continue
701 if not hasattr(baseClass, "__slots__"):
702 ex = BaseClassWithoutSlotsError(f"Base-classes '{baseClass.__name__}' doesn't use '__slots__'.")
703 ex.add_note(f"All base-classes of a class using '__slots__' must use '__slots__' itself.")
704 raise ex
706 # FIXME: should have a check for non-empty slots on secondary base-classes too
708 # Copy all field names from primary base-class' __slots__, which are later needed for error checking.
709 inheritedSlottedFields = {}
710 if len(baseClasses) > 0:
711 for base in reversed(baseClasses[0].mro()):
712 # Exclude object as a special case
713 if base is object or base is Generic:
714 continue
716 for annotation in base.__slots__:
717 inheritedSlottedFields[annotation] = base
719 # When adding annotated fields to slottedFields, check if name was not used in inheritance hierarchy.
720 if "__annotations__" in members:
721 # WORKAROUND: LEGACY SUPPORT Python <= 3.13
722 # Accessing annotations was changed in Python 3.14.
723 # The necessary 'annotationlib' is not available for older Python versions.
724 annotations: Dict[str, Any] = members.get("__annotations__", {})
725 elif version_info >= (3, 14) and (annotate := members.get("__annotate_func__", None)) is not None:
726 from annotationlib import Format
727 annotations: Dict[str, Any] = annotate(Format.VALUE)
728 else:
729 annotations = {}
731 for fieldName, typeAnnotation in annotations.items():
732 if fieldName in inheritedSlottedFields: 732 ↛ 733line 732 didn't jump to line 733 because the condition on line 732 was never true
733 cls = inheritedSlottedFields[fieldName]
734 raise AttributeError(f"Slot '{fieldName}' already exists in base-class '{cls.__module__}.{cls.__name__}'.")
736 # If annotated field is a ClassVar, and it has an initial value
737 # * copy field and initial value to classFields dictionary
738 # * remove field from members
739 if isinstance(typeAnnotation, _GenericAlias) and typeAnnotation.__origin__ is ClassVar and fieldName in members:
740 classFields[fieldName] = members[fieldName]
741 del members[fieldName]
743 # If an annotated field has an initial value
744 # * copy field and initial value to objectFields dictionary
745 # * remove field from members
746 elif fieldName in members:
747 slottedFields.append(fieldName)
748 objectFields[fieldName] = members[fieldName]
749 del members[fieldName]
750 else:
751 slottedFields.append(fieldName)
753 mixinSlots = self._aggregateMixinSlots(className, baseClasses)
754 else:
755 # When adding annotated fields to slottedFields, check if name was not used in inheritance hierarchy.
756 annotations: Dict[str, Any] = members.get("__annotations__", {})
757 for fieldName, typeAnnotation in annotations.items():
758 # If annotated field is a ClassVar, and it has an initial value
759 # * copy field and initial value to classFields dictionary
760 # * remove field from members
761 if isinstance(typeAnnotation, _GenericAlias) and typeAnnotation.__origin__ is ClassVar and fieldName in members:
762 classFields[fieldName] = members[fieldName]
763 del members[fieldName]
765 # FIXME: search for fields without annotation
766 if mixin:
767 mixinSlots.extend(slottedFields)
768 members["__slotted__"] = True
769 members["__slots__"] = tuple()
770 members["__allSlots__"] = set()
771 members["__isMixin__"] = True
772 members["__mixinSlots__"] = tuple(mixinSlots)
773 elif slots:
774 slottedFields.extend(mixinSlots)
775 members["__slotted__"] = True
776 members["__slots__"] = tuple(slottedFields)
777 members["__allSlots__"] = set(chain(slottedFields, inheritedSlottedFields.keys()))
778 members["__isMixin__"] = False
779 members["__mixinSlots__"] = tuple()
780 else:
781 members["__slotted__"] = False
782 # NO __slots__
783 # members["__allSlots__"] = set()
784 members["__isMixin__"] = False
785 members["__mixinSlots__"] = tuple()
786 return classFields, objectFields
788 @classmethod
789 def _aggregateMixinSlots(self, className: str, baseClasses: Tuple[type]) -> List[str]:
790 """
791 Aggregate slot names requested by mixin-base-classes.
793 .. todo::
795 Describe algorithm.
797 :param className: The name of the class to construct.
798 :param baseClasses: The tuple of :term:`base-classes <base-class>` the class is derived from.
799 :returns: A list of slot names.
800 """
801 mixinSlots = []
802 if len(baseClasses) > 0:
803 # If class has base-classes ensure only the primary inheritance path uses slots and all secondary inheritance
804 # paths have an empty slots tuple. Otherwise, raise a BaseClassWithNonEmptySlotsError.
805 inheritancePaths = [path for path in self._iterateBaseClassPaths(baseClasses)]
806 primaryInharitancePath: Set[type] = set(inheritancePaths[0])
807 for typePath in inheritancePaths[1:]:
808 for t in typePath:
809 if hasattr(t, "__slots__") and len(t.__slots__) != 0 and t not in primaryInharitancePath:
810 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}'.")
811 ex.add_note(f"In Python, only one inheritance branch can use non-empty __slots__.")
812 # ex.add_note(f"With ExtendedType, only the primary base-class can use non-empty __slots__.")
813 # ex.add_note(f"Secondary base-classes should be marked as mixin-classes.")
814 raise ex
816 # If current class is set to be a mixin, then aggregate all mixinSlots in a list.
817 # Ensure all base-classes are either constructed
818 # * by meta-class ExtendedType, or
819 # * use no slots, or
820 # * are typing.Generic
821 # If it was constructed by ExtendedType, then ensure this class itself is a mixin-class.
822 for baseClass in baseClasses: # type: ExtendedType
823 if isinstance(baseClass, _GenericAlias) and baseClass.__origin__ is Generic: 823 ↛ 824line 823 didn't jump to line 824 because the condition on line 823 was never true
824 pass
825 elif baseClass.__class__ is self and baseClass.__isMixin__:
826 mixinSlots.extend(baseClass.__mixinSlots__)
827 elif hasattr(baseClass, "__mixinSlots__"):
828 mixinSlots.extend(baseClass.__mixinSlots__)
830 return mixinSlots
832 @classmethod
833 def _iterateBaseClasses(metacls, baseClasses: Tuple[type]) -> Generator[type, None, None]:
834 """
835 Return a generator to iterate (visit) all base-classes ...
837 .. todo::
839 Describe iteration order.
841 :param baseClasses: The tuple of :term:`base-classes <base-class>` the class is derived from.
842 :returns: Generator to iterate all base-classes.
843 """
844 if len(baseClasses) == 0:
845 return
847 visited: Set[type] = set()
848 iteratorStack: List[Iterator[type]] = list()
850 for baseClass in baseClasses:
851 yield baseClass
852 visited.add(baseClass)
853 iteratorStack.append(iter(baseClass.__bases__))
855 while True:
856 try:
857 base = next(iteratorStack[-1]) # type: type
858 if base not in visited: 858 ↛ 863line 858 didn't jump to line 863 because the condition on line 858 was always true
859 yield base
860 if len(base.__bases__) > 0:
861 iteratorStack.append(iter(base.__bases__))
862 else:
863 continue
865 except StopIteration:
866 iteratorStack.pop()
868 if len(iteratorStack) == 0:
869 break
871 @classmethod
872 def _iterateBaseClassPaths(metacls, baseClasses: Tuple[type]) -> Generator[Tuple[type, ...], None, None]:
873 """
874 Return a generator to iterate all possible inheritance paths for a given list of base-classes.
876 An inheritance path is expressed as a tuple of base-classes from current base-class (left-most item) to
877 :class:`object` (right-most item).
879 :param baseClasses: The tuple of :term:`base-classes <base-class>` the class is derived from.
880 :returns: Generator to iterate all inheritance paths. |br|
881 An inheritance path is a tuple of types (base-classes).
882 """
883 if len(baseClasses) == 0: 883 ↛ 884line 883 didn't jump to line 884 because the condition on line 883 was never true
884 return
886 typeStack: List[type] = list()
887 iteratorStack: List[Iterator[type]] = list()
889 for baseClass in baseClasses:
890 typeStack.append(baseClass)
891 iteratorStack.append(iter(baseClass.__bases__))
893 while True:
894 try:
895 base = next(iteratorStack[-1]) # type: type
896 typeStack.append(base)
897 if len(base.__bases__) == 0:
898 yield tuple(typeStack)
899 typeStack.pop()
900 else:
901 iteratorStack.append(iter(base.__bases__))
903 except StopIteration:
904 typeStack.pop()
905 iteratorStack.pop()
907 if len(typeStack) == 0:
908 break
910 @classmethod
911 def _checkForAbstractMethods(metacls, baseClasses: Tuple[type], members: Dict[str, Any]) -> Tuple[Dict[str, Callable], Dict[str, Any]]:
912 """
913 Check if the current class contains abstract methods and return a tuple of them.
915 These abstract methods might be inherited from any base-class. If there are inherited abstract methods, check if
916 they are now implemented (overridden) by the current class that's right now constructed.
918 :param baseClasses: The tuple of :term:`base-classes <base-class>` the class is derived from.
919 :param members: The dictionary of members for the constructed class.
920 :returns: A tuple of abstract method's names.
921 """
922 abstractMethods = {}
923 if baseClasses:
924 # Aggregate all abstract methods from all base-classes.
925 for baseClass in baseClasses:
926 if hasattr(baseClass, "__abstractMethods__"):
927 abstractMethods.update(baseClass.__abstractMethods__)
929 for base in baseClasses:
930 for memberName, member in base.__dict__.items():
931 if (memberName in abstractMethods and isinstance(member, FunctionType) and
932 not (hasattr(member, "__abstract__") or hasattr(member, "__mustOverride__"))):
933 def outer(method):
934 @wraps(method)
935 def inner(cls, *args: Any, **kwargs: Any):
936 return method(cls, *args, **kwargs)
938 return inner
940 members[memberName] = outer(member)
942 # Check if methods are marked:
943 # * If so, add them to list of abstract methods
944 # * If not, method is now implemented and removed from list
945 for memberName, member in members.items():
946 if callable(member):
947 if ((hasattr(member, "__abstract__") and member.__abstract__) or
948 (hasattr(member, "__mustOverride__") and member.__mustOverride__)):
949 abstractMethods[memberName] = member
950 elif memberName in abstractMethods:
951 del abstractMethods[memberName]
953 return abstractMethods, members
955 @classmethod
956 def _wrapNewMethodIfSingleton(metacls, newClass, singleton: bool) -> bool:
957 """
958 If a class is a singleton, wrap the ``_new__`` method, so it returns a cached object, if a first object was created.
960 Only the first object creation initializes the object.
962 This implementation is threadsafe.
964 :param newClass: The newly constructed class for further modifications.
965 :param singleton: If ``True``, the class allows only a single instance to exist.
966 :returns: ``True``, if the class is a singleton.
967 """
968 if hasattr(newClass, "__isSingleton__"):
969 singleton = newClass.__isSingleton__
971 if singleton:
972 oldnew = newClass.__new__
973 if hasattr(oldnew, "__singleton_wrapper__"):
974 oldnew = oldnew.__wrapped__
976 oldinit = newClass.__init__
977 if hasattr(oldinit, "__singleton_wrapper__"): 977 ↛ 978line 977 didn't jump to line 978 because the condition on line 977 was never true
978 oldinit = oldinit.__wrapped__
980 @wraps(oldnew)
981 def singleton_new(cls, *args: Any, **kwargs: Any):
982 with cls.__singletonInstanceCond__:
983 if cls.__singletonInstanceCache__ is None:
984 obj = oldnew(cls, *args, **kwargs)
985 cls.__singletonInstanceCache__ = obj
986 else:
987 obj = cls.__singletonInstanceCache__
989 return obj
991 @wraps(oldinit)
992 def singleton_init(self, *args: Any, **kwargs: Any):
993 cls = self.__class__
994 cv = cls.__singletonInstanceCond__
995 with cv:
996 if cls.__singletonInstanceInit__:
997 oldinit(self, *args, **kwargs)
998 cls.__singletonInstanceInit__ = False
999 cv.notify_all()
1000 elif args or kwargs:
1001 raise ValueError(f"A further instance of a singleton can't be reinitialized with parameters.")
1002 else:
1003 while cls.__singletonInstanceInit__: 1003 ↛ 1004line 1003 didn't jump to line 1004 because the condition on line 1003 was never true
1004 cv.wait()
1006 singleton_new.__singleton_wrapper__ = True
1007 singleton_init.__singleton_wrapper__ = True
1009 newClass.__new__ = singleton_new
1010 newClass.__init__ = singleton_init
1011 newClass.__singletonInstanceCond__ = Condition()
1012 newClass.__singletonInstanceInit__ = True
1013 newClass.__singletonInstanceCache__ = None
1014 return True
1016 return False
1018 @classmethod
1019 def _wrapNewMethodIfAbstract(metacls, newClass) -> bool:
1020 """
1021 If the class has abstract methods, replace the ``_new__`` method, so it raises an exception.
1023 :param newClass: The newly constructed class for further modifications.
1024 :returns: ``True``, if the class is abstract.
1025 :raises AbstractClassError: If the class is abstract and can't be instantiated.
1026 """
1027 # Replace '__new__' by a variant to throw an error on not overridden methods
1028 if len(newClass.__abstractMethods__) > 0:
1029 oldnew = newClass.__new__
1030 if hasattr(oldnew, "__raises_abstract_class_error__"):
1031 oldnew = oldnew.__wrapped__
1033 @wraps(oldnew)
1034 def abstract_new(cls, *_, **__):
1035 raise AbstractClassError(f"""Class '{cls.__name__}' is abstract. The following methods: '{"', '".join(newClass.__abstractMethods__)}' need to be overridden in a derived class.""")
1037 abstract_new.__raises_abstract_class_error__ = True
1039 newClass.__new__ = abstract_new
1040 return True
1042 # Handle classes which are not abstract, especially derived classes, if not abstract anymore
1043 else:
1044 # skip intermediate 'new' function if class isn't abstract anymore
1045 try:
1046 if newClass.__new__.__raises_abstract_class_error__: 1046 ↛ 1059line 1046 didn't jump to line 1059 because the condition on line 1046 was always true
1047 origNew = newClass.__new__.__wrapped__
1049 # WORKAROUND: __new__ checks tp_new and implements different behavior
1050 # Bugreport: https://github.com/python/cpython/issues/105888
1051 if origNew is object.__new__: 1051 ↛ 1058line 1051 didn't jump to line 1058 because the condition on line 1051 was always true
1052 @wraps(object.__new__)
1053 def wrapped_new(inst, *_, **__):
1054 return object.__new__(inst)
1056 newClass.__new__ = wrapped_new
1057 else:
1058 newClass.__new__ = origNew
1059 elif newClass.__new__.__isSingleton__:
1060 raise Exception(f"Found a singleton wrapper around an AbstractError raising method. This case is not handled yet.")
1061 except AttributeError as ex:
1062 # WORKAROUND:
1063 # AttributeError.name was added in Python 3.10. For version <3.10 use a string contains operation.
1064 try:
1065 if ex.name != "__raises_abstract_class_error__": 1065 ↛ 1066line 1065 didn't jump to line 1066 because the condition on line 1065 was never true
1066 raise ex
1067 except AttributeError:
1068 if "__raises_abstract_class_error__" not in str(ex):
1069 raise ex
1071 return False
1073 # Additional properties and methods on a class
1074 @readonly
1075 def HasClassAttributes(self) -> bool:
1076 """
1077 Read-only property to check if the class has Attributes (:attr:`__pyattr__`).
1079 :returns: ``True``, if the class has Attributes.
1080 """
1081 try:
1082 return len(self.__pyattr__) > 0
1083 except AttributeError:
1084 return False
1086 @readonly
1087 def HasMethodAttributes(self) -> bool:
1088 """
1089 Read-only property to check if the class has methods with Attributes (:attr:`__methodsWithAttributes__`).
1091 :returns: ``True``, if the class has any method with Attributes.
1092 """
1093 try:
1094 return len(self.__methodsWithAttributes__) > 0
1095 except AttributeError:
1096 return False
1099@export
1100class SlottedObject(metaclass=ExtendedType, slots=True):
1101 """Classes derived from this class will store all members in ``__slots__``."""