Coverage for pyTooling/MetaClasses/__init__.py: 84%
414 statements
« prev ^ index » next coverage.py v7.8.0, created at 2025-04-25 22:22 +0000
« prev ^ index » next coverage.py v7.8.0, created at 2025-04-25 22:22 +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:: See :ref:`high-level help <META>` for explanations and usage examples.
36"""
37from functools import wraps
38# from inspect import signature, Parameter
39from threading import Condition
40from types import FunctionType #, MethodType
41from typing import Any, Tuple, List, Dict, Callable, Generator, Set, Iterator, Iterable, Union
42from typing import Type, TypeVar, Generic, _GenericAlias, ClassVar, Optional as Nullable
44try:
45 from pyTooling.Exceptions import ToolingException
46 from pyTooling.Decorators import export, readonly
47except (ImportError, ModuleNotFoundError): # pragma: no cover
48 print("[pyTooling.MetaClasses] Could not import from 'pyTooling.*'!")
50 try:
51 from Exceptions import ToolingException
52 from Decorators import export, readonly
53 except (ImportError, ModuleNotFoundError) as ex: # pragma: no cover
54 print("[pyTooling.MetaClasses] Could not import directly!")
55 raise ex
58__all__ = ["M"]
60TAttr = TypeVar("TAttr") # , bound='Attribute')
61"""A type variable for :class:`~pyTooling.Attributes.Attribute`."""
63TAttributeFilter = Union[TAttr, Iterable[TAttr], None]
64"""A type hint for a predicate parameter that accepts either a single :class:`~pyTooling.Attributes.Attribute` or an
65iterable of those."""
68@export
69class ExtendedTypeError(ToolingException):
70 """The exception is raised by the meta-class :class:`~pyTooling.Metaclasses.ExtendedType`."""
73@export
74class BaseClassWithoutSlotsError(ExtendedTypeError):
75 """
76 The exception is raised when a class using ``__slots__`` inherits from at-least one base-class not using ``__slots__``.
78 .. seealso::
80 * :ref:`Python data model for slots <slots>`
81 * :term:`Glossary entry __slots__ <__slots__>`
82 """
85@export
86class BaseClassWithNonEmptySlotsError(ExtendedTypeError):
87 pass
90@export
91class BaseClassIsNotAMixinError(ExtendedTypeError):
92 pass
95@export
96class DuplicateFieldInSlotsError(ExtendedTypeError):
97 pass
100@export
101class AbstractClassError(ExtendedTypeError):
102 """
103 The exception is raised, when a class contains methods marked with *abstractmethod* or *mustoverride*.
105 .. seealso::
107 :func:`@abstractmethod <pyTooling.MetaClasses.abstractmethod>`
108 |rarr| Mark a method as *abstract*.
109 :func:`@mustoverride <pyTooling.MetaClasses.mustoverride>`
110 |rarr| Mark a method as *must overrride*.
111 :exc:`~MustOverrideClassError`
112 |rarr| Exception raised, if a method is marked as *must-override*.
113 """
116@export
117class MustOverrideClassError(AbstractClassError):
118 """
119 The exception is raised, when a class contains methods marked with *must-override*.
121 .. seealso::
123 :func:`@abstractmethod <pyTooling.MetaClasses.abstractmethod>`
124 |rarr| Mark a method as *abstract*.
125 :func:`@mustoverride <pyTooling.MetaClasses.mustoverride>`
126 |rarr| Mark a method as *must overrride*.
127 :exc:`~AbstractClassError`
128 |rarr| Exception raised, if a method is marked as *abstract*.
129 """
132# """
133# Metaclass that allows multiple dispatch of methods based on method signatures.
134#
135# .. seealso:
136#
137# `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>`__
138# """
141M = TypeVar("M", bound=Callable) #: A type variable for methods.
144@export
145def slotted(cls):
146 if cls.__class__ is type:
147 metacls = ExtendedType
148 elif issubclass(cls.__class__, ExtendedType):
149 metacls = cls.__class__
150 for method in cls.__methods__:
151 delattr(method, "__classobj__")
152 else:
153 raise ExtendedTypeError(f"Class uses an incompatible meta-class.") # FIXME: create exception for it?
155 bases = tuple(base for base in cls.__bases__ if base is not object)
156 slots = cls.__dict__["__slots__"] if "__slots__" in cls.__dict__ else tuple()
157 members = {
158 "__qualname__": cls.__qualname__
159 }
160 for key, value in cls.__dict__.items():
161 if key not in slots:
162 members[key] = value
164 return metacls(cls.__name__, bases, members, slots=True)
167@export
168def mixin(cls):
169 if cls.__class__ is type:
170 metacls = ExtendedType
171 elif issubclass(cls.__class__, ExtendedType): 171 ↛ 176line 171 didn't jump to line 176 because the condition on line 171 was always true
172 metacls = cls.__class__
173 for method in cls.__methods__:
174 delattr(method, "__classobj__")
175 else:
176 raise ExtendedTypeError(f"Class uses an incompatible meta-class.") # FIXME: create exception for it?
178 bases = tuple(base for base in cls.__bases__ if base is not object)
179 slots = cls.__dict__["__slots__"] if "__slots__" in cls.__dict__ else tuple()
180 members = {
181 "__qualname__": cls.__qualname__
182 }
183 for key, value in cls.__dict__.items():
184 if key not in slots:
185 members[key] = value
187 return metacls(cls.__name__, bases, members, mixin=True)
190@export
191def singleton(cls):
192 if cls.__class__ is type:
193 metacls = ExtendedType
194 elif issubclass(cls.__class__, ExtendedType):
195 metacls = cls.__class__
196 for method in cls.__methods__:
197 delattr(method, "__classobj__")
198 else:
199 raise ExtendedTypeError(f"Class uses an incompatible meta-class.") # FIXME: create exception for it?
201 bases = tuple(base for base in cls.__bases__ if base is not object)
202 slots = cls.__dict__["__slots__"] if "__slots__" in cls.__dict__ else tuple()
203 members = {
204 "__qualname__": cls.__qualname__
205 }
206 for key, value in cls.__dict__.items():
207 if key not in slots:
208 members[key] = value
210 return metacls(cls.__name__, bases, members, singleton=True)
213@export
214def abstractmethod(method: M) -> M:
215 """
216 Mark a method as *abstract* and replace the implementation with a new method raising a :exc:`NotImplementedError`.
218 The original method is stored in ``<method>.__wrapped__`` and it's doc-string is copied to the replacing method. In
219 additional field ``<method>.__abstract__`` is added.
221 .. warning::
223 This decorator should be used in combination with meta-class :class:`~pyTooling.Metaclasses.ExtendedType`.
224 Otherwise, an abstract class itself doesn't throw a :exc:`~pyTooling.Exceptions.AbstractClassError` at
225 instantiation.
227 .. admonition:: ``example.py``
229 .. code-block:: python
231 class Data(mataclass=ExtendedType):
232 @abstractmethod
233 def method(self) -> bool:
234 '''This method needs to be implemented'''
236 :param method: Method that is marked as *abstract*.
237 :returns: Replacement method, which raises a :exc:`NotImplementedError`.
239 .. seealso::
241 * :exc:`~pyTooling.Exceptions.AbstractClassError`
242 * :func:`~pyTooling.Metaclasses.mustoverride`
243 * :func:`~pyTooling.Metaclasses.notimplemented`
244 """
245 @wraps(method)
246 def func(self):
247 raise NotImplementedError(f"Method '{method.__name__}' is abstract and needs to be overridden in a derived class.")
249 func.__abstract__ = True
250 return func
253@export
254def mustoverride(method: M) -> M:
255 """
256 Mark a method as *must-override*.
258 The returned function is the original function, but with an additional field ``<method>.____mustOverride__``, so a
259 meta-class can identify a *must-override* method and raise an error. Such an error is not raised if the method is
260 overridden by an inheriting class.
262 A *must-override* methods can offer a partial implementation, which is called via ``super()...``.
264 .. warning::
266 This decorator needs to be used in combination with meta-class :class:`~pyTooling.Metaclasses.ExtendedType`.
267 Otherwise, an abstract class itself doesn't throw a :exc:`~pyTooling.Exceptions.MustOverrideClassError` at
268 instantiation.
270 .. admonition:: ``example.py``
272 .. code-block:: python
274 class Data(mataclass=ExtendedType):
275 @mustoverride
276 def method(self):
277 '''This is a very basic implementation'''
279 :param method: Method that is marked as *must-override*.
280 :returns: Same method, but with additional ``<method>.__mustOverride__`` field.
282 .. seealso::
284 * :exc:`~pyTooling.Exceptions.MustOverrideClassError`
285 * :func:`~pyTooling.Metaclasses.abstractmethod`
286 * :func:`~pyTooling.Metaclasses.notimplemented`
287 """
288 method.__mustOverride__ = True
289 return method
292# @export
293# def overloadable(method: M) -> M:
294# method.__overloadable__ = True
295# return method
298# @export
299# class DispatchableMethod:
300# """Represents a single multimethod."""
301#
302# _methods: Dict[Tuple, Callable]
303# __name__: str
304# __slots__ = ("_methods", "__name__")
305#
306# def __init__(self, name: str) -> None:
307# self.__name__ = name
308# self._methods = {}
309#
310# def __call__(self, *args: Any):
311# """Call a method based on type signature of the arguments."""
312# types = tuple(type(arg) for arg in args[1:])
313# meth = self._methods.get(types, None)
314# if meth:
315# return meth(*args)
316# else:
317# raise TypeError(f"No matching method for types {types}.")
318#
319# def __get__(self, instance, cls): # Starting with Python 3.11+, use typing.Self as return type
320# """Descriptor method needed to make calls work in a class."""
321# if instance is not None:
322# return MethodType(self, instance)
323# else:
324# return self
325#
326# def register(self, method: Callable) -> None:
327# """Register a new method as a dispatchable."""
328#
329# # Build a signature from the method's type annotations
330# sig = signature(method)
331# types: List[Type] = []
332#
333# for name, parameter in sig.parameters.items():
334# if name == "self":
335# continue
336#
337# if parameter.annotation is Parameter.empty:
338# raise TypeError(f"Parameter '{name}' in method '{method.__name__}' must be annotated with a type.")
339#
340# if not isinstance(parameter.annotation, type):
341# raise TypeError(f"Parameter '{name}' in method '{method.__name__}' annotation must be a type.")
342#
343# if parameter.default is not Parameter.empty:
344# self._methods[tuple(types)] = method
345#
346# types.append(parameter.annotation)
347#
348# self._methods[tuple(types)] = method
351# @export
352# class DispatchDictionary(dict):
353# """Special dictionary to build dispatchable methods in a metaclass."""
354#
355# def __setitem__(self, key: str, value: Any):
356# if callable(value) and key in self:
357# # If key already exists, it must be a dispatchable method or callable
358# currentValue = self[key]
359# if isinstance(currentValue, DispatchableMethod):
360# currentValue.register(value)
361# else:
362# dispatchable = DispatchableMethod(key)
363# dispatchable.register(currentValue)
364# dispatchable.register(value)
365#
366# super().__setitem__(key, dispatchable)
367# else:
368# super().__setitem__(key, value)
371@export
372class ExtendedType(type):
373 """
374 An updates meta-class to construct new classes with an extended feature set.
376 .. todo:: META::ExtendedType Needs documentation.
377 .. todo:: META::ExtendedType allow __dict__ and __weakref__ if slotted is enabled
379 Features:
381 * Store object members more efficiently in ``__slots__`` instead of ``_dict__``.
382 * Allow only a single instance to be created (:term:`singleton`).
383 * Define methods as :term:`abstract <abstract method>` or :term:`must-override <mustoverride method>` and prohibit
384 instantiation of :term:`abstract classes <abstract class>`.
386 .. #* Allow method overloading and dispatch overloads based on argument signatures.
387 """
389 # @classmethod
390 # def __prepare__(cls, className, baseClasses, slots: bool = False, mixin: bool = False, singleton: bool = False):
391 # return DispatchDictionary()
393 def __new__(self, className: str, baseClasses: Tuple[type], members: Dict[str, Any],
394 slots: bool = False, mixin: bool = False, singleton: bool = False) -> "ExtendedType":
395 """
396 Construct a new class using this :term:`meta-class`.
398 :param className: The name of the class to construct.
399 :param baseClasses: The tuple of :term:`base-classes <base-class>` the class is derived from.
400 :param members: The dictionary of members for the constructed class.
401 :param slots: If true, store object attributes in :term:`__slots__ <slots>` instead of ``__dict__``.
402 :param mixin: If true, make the class a :term:`Mixin-Class`.
403 If false, create slots if ``slots`` is true.
404 If none, preserve behavior of primary base-class.
405 :param singleton: If true, make the class a :term:`Singleton`.
406 :returns: The new class.
407 :raises AttributeError: If base-class has no '__slots__' attribute.
408 :raises AttributeError: If slot already exists in base-class.
409 """
410 try:
411 from pyTooling.Attributes import ATTRIBUTES_MEMBER_NAME, AttributeScope
412 except (ImportError, ModuleNotFoundError) as ex: # pragma: no cover
413 from Attributes import ATTRIBUTES_MEMBER_NAME, AttributeScope
415 # Inherit 'slots' feature from primary base-class
416 if len(baseClasses) > 0:
417 primaryBaseClass = baseClasses[0]
418 if isinstance(primaryBaseClass, self):
419 slots = primaryBaseClass.__slotted__
421 # Compute slots and mixin-slots from annotated fields as well as class- and object-fields with initial values.
422 classFields, objectFields = self._computeSlots(className, baseClasses, members, slots, mixin)
424 # Compute abstract methods
425 abstractMethods, members = self._checkForAbstractMethods(baseClasses, members)
427 # Create a new class
428 newClass = type.__new__(self, className, baseClasses, members)
430 # Apply class fields
431 for fieldName, typeAnnotation in classFields.items():
432 setattr(newClass, fieldName, typeAnnotation)
434 # Search in inheritance tree for abstract methods
435 newClass.__abstractMethods__ = abstractMethods
436 newClass.__isAbstract__ = self._wrapNewMethodIfAbstract(newClass)
437 newClass.__isSingleton__ = self._wrapNewMethodIfSingleton(newClass, singleton)
439 # Check for inherited class attributes
440 attributes = []
441 setattr(newClass, ATTRIBUTES_MEMBER_NAME, attributes)
442 for base in baseClasses:
443 if hasattr(base, ATTRIBUTES_MEMBER_NAME):
444 pyAttr = getattr(base, ATTRIBUTES_MEMBER_NAME)
445 for att in pyAttr:
446 if AttributeScope.Class in att.Scope: 446 ↛ 445line 446 didn't jump to line 445 because the condition on line 446 was always true
447 attributes.append(att)
448 att.__class__._classes.append(newClass)
450 # Check methods for attributes
451 methods, methodsWithAttributes = self._findMethods(newClass, baseClasses, members)
453 # Add new fields for found methods
454 newClass.__methods__ = tuple(methods)
455 newClass.__methodsWithAttributes__ = tuple(methodsWithAttributes)
457 # Additional methods on a class
458 def GetMethodsWithAttributes(self, predicate: Nullable[TAttributeFilter[TAttr]] = None) -> Dict[Callable, Tuple["Attribute", ...]]:
459 """
461 :param predicate:
462 :return:
463 :raises ValueError:
464 :raises ValueError:
465 """
466 try:
467 from ..Attributes import Attribute
468 except (ImportError, ModuleNotFoundError): # pragma: no cover
469 try:
470 from Attributes import Attribute
471 except (ImportError, ModuleNotFoundError) as ex: # pragma: no cover
472 raise ex
474 if predicate is None:
475 predicate = Attribute
476 elif isinstance(predicate, Iterable): 476 ↛ 477line 476 didn't jump to line 477 because the condition on line 476 was never true
477 for attribute in predicate:
478 if not issubclass(attribute, Attribute):
479 raise ValueError(f"Parameter 'predicate' contains an element which is not a sub-class of 'Attribute'.")
481 predicate = tuple(predicate)
482 elif not issubclass(predicate, Attribute): 482 ↛ 483line 482 didn't jump to line 483 because the condition on line 482 was never true
483 raise ValueError(f"Parameter 'predicate' is not a sub-class of 'Attribute'.")
485 methodAttributePairs = {}
486 for method in newClass.__methodsWithAttributes__:
487 matchingAttributes = []
488 for attribute in method.__pyattr__:
489 if isinstance(attribute, predicate):
490 matchingAttributes.append(attribute)
492 if len(matchingAttributes) > 0:
493 methodAttributePairs[method] = tuple(matchingAttributes)
495 return methodAttributePairs
497 newClass.GetMethodsWithAttributes = classmethod(GetMethodsWithAttributes)
498 GetMethodsWithAttributes.__qualname__ = f"{className}.{GetMethodsWithAttributes.__name__}"
500 # GetMethods(predicate) -> dict[method, list[attribute]] / generator
501 # GetClassAtrributes -> list[attributes] / generator
502 # MethodHasAttributes(predicate) -> bool
503 # GetAttribute
505 return newClass
507 @classmethod
508 def _findMethods(self, newClass: "ExtendedType", baseClasses: Tuple[type], members: Dict[str, Any]):
509 try:
510 from ..Attributes import Attribute
511 except (ImportError, ModuleNotFoundError): # pragma: no cover
512 try:
513 from Attributes import Attribute
514 except (ImportError, ModuleNotFoundError) as ex: # pragma: no cover
515 raise ex
517 # Embedded bind function due to circular dependencies.
518 def bind(instance: object, func: FunctionType, methodName: Nullable[str] = None):
519 if methodName is None: 519 ↛ 522line 519 didn't jump to line 522 because the condition on line 519 was always true
520 methodName = func.__name__
522 boundMethod = func.__get__(instance, instance.__class__)
523 setattr(instance, methodName, boundMethod)
525 return boundMethod
527 methods = []
528 methodsWithAttributes = []
529 attributeIndex = {}
531 for base in baseClasses:
532 if hasattr(base, "__methodsWithAttributes__"):
533 methodsWithAttributes.extend(base.__methodsWithAttributes__)
535 for memberName, member in members.items():
536 if isinstance(member, FunctionType):
537 method = newClass.__dict__[memberName]
538 if hasattr(method, "__classobj__") and getattr(method, "__classobj__") is not newClass: 538 ↛ 539line 538 didn't jump to line 539 because the condition on line 538 was never true
539 raise TypeError(f"Method '{memberName}' is used by multiple classes: {method.__classobj__} and {newClass}.")
540 else:
541 setattr(method, "__classobj__", newClass)
543 def GetAttributes(inst: Any, predicate: Nullable[Type[Attribute]] = None) -> Tuple[Attribute, ...]:
544 results = []
545 try:
546 for attribute in inst.__pyattr__: # type: Attribute
547 if isinstance(attribute, predicate):
548 results.append(attribute)
549 return tuple(results)
550 except AttributeError:
551 return tuple()
553 method.GetAttributes = bind(method, GetAttributes)
554 methods.append(method)
556 # print(f" convert function: '{memberName}' to method")
557 # print(f" {member}")
558 if "__pyattr__" in member.__dict__:
559 attributes = member.__pyattr__ # type: List[Attribute]
560 if isinstance(attributes, list) and len(attributes) > 0: 560 ↛ 535line 560 didn't jump to line 535 because the condition on line 560 was always true
561 methodsWithAttributes.append(member)
562 for attribute in attributes:
563 attribute._functions.remove(method)
564 attribute._methods.append(method)
566 # print(f" attributes: {attribute.__class__.__name__}")
567 if attribute not in attributeIndex: 567 ↛ 570line 567 didn't jump to line 570 because the condition on line 567 was always true
568 attributeIndex[attribute] = [member]
569 else:
570 attributeIndex[attribute].append(member)
571 # else:
572 # print(f" But has no attributes.")
573 # else:
574 # print(f" ?? {memberName}")
575 return methods, methodsWithAttributes
577 @classmethod
578 def _computeSlots(self, className, baseClasses, members, slots, mixin):
579 # Compute which field are listed in __slots__ and which need to be initialized in an instance or class.
580 slottedFields = []
581 objectFields = {}
582 classFields = {}
583 if slots or mixin:
584 # If slots are used, all base classes must use __slots__.
585 for baseClass in self._iterateBaseClasses(baseClasses):
586 # Exclude object as a special case
587 if baseClass is object or baseClass is Generic:
588 continue
590 if not hasattr(baseClass, "__slots__"):
591 ex = BaseClassWithoutSlotsError(f"Base-classes '{baseClass.__name__}' doesn't use '__slots__'.")
592 ex.add_note(f"All base-classes of a class using '__slots__' must use '__slots__' itself.")
593 raise ex
595 # FIXME: should have a check for non-empty slots on secondary base-classes too
597 # Copy all field names from primary base-class' __slots__, which are later needed for error checking.
598 inheritedSlottedFields = {}
599 if len(baseClasses) > 0:
600 for base in reversed(baseClasses[0].mro()):
601 # Exclude object as a special case
602 if base is object or base is Generic:
603 continue
605 for annotation in base.__slots__:
606 inheritedSlottedFields[annotation] = base
608 # When adding annotated fields to slottedFields, check if name was not used in inheritance hierarchy.
609 annotations: Dict[str, Any] = members.get("__annotations__", {})
610 for fieldName, typeAnnotation in annotations.items():
611 if fieldName in inheritedSlottedFields: 611 ↛ 612line 611 didn't jump to line 612 because the condition on line 611 was never true
612 cls = inheritedSlottedFields[fieldName]
613 raise AttributeError(f"Slot '{fieldName}' already exists in base-class '{cls.__module__}.{cls.__name__}'.")
615 # If annotated field is a ClassVar, and it has an initial value
616 # * copy field and initial value to classFields dictionary
617 # * remove field from members
618 if isinstance(typeAnnotation, _GenericAlias) and typeAnnotation.__origin__ is ClassVar and fieldName in members:
619 classFields[fieldName] = members[fieldName]
620 del members[fieldName]
622 # If an annotated field has an initial value
623 # * copy field and initial value to objectFields dictionary
624 # * remove field from members
625 elif fieldName in members:
626 slottedFields.append(fieldName)
627 objectFields[fieldName] = members[fieldName]
628 del members[fieldName]
629 else:
630 slottedFields.append(fieldName)
632 mixinSlots = self._aggregateMixinSlots(className, baseClasses)
633 else:
634 # When adding annotated fields to slottedFields, check if name was not used in inheritance hierarchy.
635 annotations: Dict[str, Any] = members.get("__annotations__", {})
636 for fieldName, typeAnnotation in annotations.items():
637 # If annotated field is a ClassVar, and it has an initial value
638 # * copy field and initial value to classFields dictionary
639 # * remove field from members
640 if isinstance(typeAnnotation, _GenericAlias) and typeAnnotation.__origin__ is ClassVar and fieldName in members:
641 classFields[fieldName] = members[fieldName]
642 del members[fieldName]
644 # FIXME: search for fields without annotation
645 if mixin:
646 mixinSlots.extend(slottedFields)
647 members["__slotted__"] = True
648 members["__slots__"] = tuple()
649 members["__isMixin__"] = True
650 members["__mixinSlots__"] = tuple(mixinSlots)
651 elif slots:
652 slottedFields.extend(mixinSlots)
653 members["__slotted__"] = True
654 members["__slots__"] = tuple(slottedFields)
655 members["__isMixin__"] = False
656 members["__mixinSlots__"] = tuple()
657 else:
658 members["__slotted__"] = False
659 # NO __slots__
660 members["__isMixin__"] = False
661 members["__mixinSlots__"] = tuple()
662 return classFields, objectFields
664 @classmethod
665 def _aggregateMixinSlots(self, className, baseClasses):
666 mixinSlots = []
667 if len(baseClasses) > 0:
668 # If class has base-classes ensure only the primary inheritance path uses slots and all secondary inheritance
669 # paths have an empty slots tuple. Otherwise, raise a BaseClassWithNonEmptySlotsError.
670 inheritancePaths = [path for path in self._iterateBaseClassPaths(baseClasses)]
671 primaryInharitancePath: Set[type] = set(inheritancePaths[0])
672 for typePath in inheritancePaths[1:]:
673 for t in typePath:
674 if hasattr(t, "__slots__") and len(t.__slots__) != 0 and t not in primaryInharitancePath:
675 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}'.")
676 ex.add_note(f"In Python, only one inheritance branch can use non-empty __slots__.")
677 # ex.add_note(f"With ExtendedType, only the primary base-class can use non-empty __slots__.")
678 # ex.add_note(f"Secondary base-classes should be marked as mixin-classes.")
679 raise ex
681 # If current class is set to be a mixin, then aggregate all mixinSlots in a list.
682 # Ensure all base-classes are either constructed
683 # * by meta-class ExtendedType, or
684 # * use no slots, or
685 # * are typing.Generic
686 # If it was constructed by ExtendedType, then ensure this class itself is a mixin-class.
687 for baseClass in baseClasses: # type: ExtendedType
688 if isinstance(baseClass, _GenericAlias) and baseClass.__origin__ is Generic: 688 ↛ 689line 688 didn't jump to line 689 because the condition on line 688 was never true
689 pass
690 elif baseClass.__class__ is self and baseClass.__isMixin__:
691 mixinSlots.extend(baseClass.__mixinSlots__)
692 elif hasattr(baseClass, "__mixinSlots__"):
693 mixinSlots.extend(baseClass.__mixinSlots__)
695 return mixinSlots
697 @classmethod
698 def _iterateBaseClasses(metacls, baseClasses: Tuple[type]) -> Generator[type, None, None]:
699 if len(baseClasses) == 0:
700 return
702 visited: Set[type] = set()
703 iteratorStack: List[Iterator[type]] = list()
705 for baseClass in baseClasses:
706 yield baseClass
707 visited.add(baseClass)
708 iteratorStack.append(iter(baseClass.__bases__))
710 while True:
711 try:
712 base = next(iteratorStack[-1]) # type: type
713 if base not in visited: 713 ↛ 718line 713 didn't jump to line 718 because the condition on line 713 was always true
714 yield base
715 if len(base.__bases__) > 0:
716 iteratorStack.append(iter(base.__bases__))
717 else:
718 continue
720 except StopIteration:
721 iteratorStack.pop()
723 if len(iteratorStack) == 0:
724 break
726 @classmethod
727 def _iterateBaseClassPaths(metacls, baseClasses: Tuple[type]) -> Generator[Tuple[type, ...], None, None]:
728 """
729 Return a generator to iterate all possible inheritance paths for a given list of base-classes.
731 An inheritance path is expressed as a tuple of base-classes from current base-class (left-most item) to
732 :class:`object` (right-most item).
734 :param baseClasses: List (tuple) of base-classes.
735 :returns: Generator to iterate all inheritance paths. An inheritance path is a tuple of types (base-classes).
736 """
737 if len(baseClasses) == 0: 737 ↛ 738line 737 didn't jump to line 738 because the condition on line 737 was never true
738 return
740 typeStack: List[type] = list()
741 iteratorStack: List[Iterator[type]] = list()
743 for baseClass in baseClasses:
744 typeStack.append(baseClass)
745 iteratorStack.append(iter(baseClass.__bases__))
747 while True:
748 try:
749 base = next(iteratorStack[-1]) # type: type
750 typeStack.append(base)
751 if len(base.__bases__) == 0:
752 yield tuple(typeStack)
753 typeStack.pop()
754 else:
755 iteratorStack.append(iter(base.__bases__))
757 except StopIteration:
758 typeStack.pop()
759 iteratorStack.pop()
761 if len(typeStack) == 0:
762 break
764 @classmethod
765 def _checkForAbstractMethods(metacls, baseClasses: Tuple[type], members: Dict[str, Any]) -> Tuple[Dict[str, Callable], Dict[str, Any]]:
766 """
767 Check if the current class contains abstract methods and return a tuple of them.
769 These abstract methods might be inherited from any base-class. If there are inherited abstract methods, check if
770 they are now implemented (overridden) by the current class that's right now constructed.
772 :param baseClasses: The tuple of :term:`base-classes <base-class>` the class is derived from.
773 :param members: The dictionary of members for the constructed class.
774 :returns: A tuple of abstract method's names.
775 """
776 abstractMethods = {}
777 if baseClasses:
778 # Aggregate all abstract methods from all base-classes.
779 for baseClass in baseClasses:
780 if hasattr(baseClass, "__abstractMethods__"):
781 abstractMethods.update(baseClass.__abstractMethods__)
783 for base in baseClasses:
784 for key, value in base.__dict__.items():
785 if (key in abstractMethods and isinstance(value, FunctionType) and
786 not (hasattr(value, "__abstract__") or hasattr(value, "__mustOverride__"))):
787 def outer(method):
788 @wraps(method)
789 def inner(cls, *args: Any, **kwargs: Any):
790 return method(cls, *args, **kwargs)
792 return inner
794 members[key] = outer(value)
796 # Check if methods are marked:
797 # * If so, add them to list of abstract methods
798 # * If not, method is now implemented and removed from list
799 for memberName, member in members.items():
800 if callable(member):
801 if ((hasattr(member, "__abstract__") and member.__abstract__) or
802 (hasattr(member, "__mustOverride__") and member.__mustOverride__)):
803 abstractMethods[memberName] = member
804 elif memberName in abstractMethods:
805 del abstractMethods[memberName]
807 return abstractMethods, members
809 @classmethod
810 def _wrapNewMethodIfSingleton(metacls, newClass, singleton: bool) -> bool:
811 """
812 If a class is a singleton, wrap the ``_new__`` method, so it returns a cached object, if a first object was created.
814 Only the first object creation initializes the object.
816 This implementation is threadsafe.
818 :param newClass: The newly constructed class for further modifications.
819 :param singleton: If ``True``, the class allows only a single instance to exist.
820 :returns: ``True``, if the class is a singleton.
821 """
822 if hasattr(newClass, "__isSingleton__"):
823 singleton = newClass.__isSingleton__
825 if singleton:
826 oldnew = newClass.__new__
827 if hasattr(oldnew, "__singleton_wrapper__"):
828 oldnew = oldnew.__wrapped__
830 oldinit = newClass.__init__
831 if hasattr(oldinit, "__singleton_wrapper__"): 831 ↛ 832line 831 didn't jump to line 832 because the condition on line 831 was never true
832 oldinit = oldinit.__wrapped__
834 @wraps(oldnew)
835 def singleton_new(cls, *args: Any, **kwargs: Any):
836 with cls.__singletonInstanceCond__:
837 if cls.__singletonInstanceCache__ is None:
838 obj = oldnew(cls, *args, **kwargs)
839 cls.__singletonInstanceCache__ = obj
840 else:
841 obj = cls.__singletonInstanceCache__
843 return obj
845 @wraps(oldinit)
846 def singleton_init(self, *args: Any, **kwargs: Any):
847 cls = self.__class__
848 cv = cls.__singletonInstanceCond__
849 with cv:
850 if cls.__singletonInstanceInit__:
851 oldinit(self, *args, **kwargs)
852 cls.__singletonInstanceInit__ = False
853 cv.notify_all()
854 elif args or kwargs:
855 raise ValueError(f"A further instance of a singleton can't be reinitialized with parameters.")
856 else:
857 while cls.__singletonInstanceInit__: 857 ↛ 858line 857 didn't jump to line 858 because the condition on line 857 was never true
858 cv.wait()
860 singleton_new.__singleton_wrapper__ = True
861 singleton_init.__singleton_wrapper__ = True
863 newClass.__new__ = singleton_new
864 newClass.__init__ = singleton_init
865 newClass.__singletonInstanceCond__ = Condition()
866 newClass.__singletonInstanceInit__ = True
867 newClass.__singletonInstanceCache__ = None
868 return True
870 return False
872 @classmethod
873 def _wrapNewMethodIfAbstract(metacls, newClass) -> bool:
874 """
875 If the class has abstract methods, replace the ``_new__`` method, so it raises an exception.
877 :param newClass: The newly constructed class for further modifications.
878 :returns: ``True``, if the class is abstract.
879 :raises AbstractClassError: If the class is abstract and can't be instantiated.
880 """
881 # Replace '__new__' by a variant to throw an error on not overridden methods
882 if len(newClass.__abstractMethods__) > 0:
883 oldnew = newClass.__new__
884 if hasattr(oldnew, "__raises_abstract_class_error__"):
885 oldnew = oldnew.__wrapped__
887 @wraps(oldnew)
888 def abstract_new(cls, *_, **__):
889 raise AbstractClassError(f"""Class '{cls.__name__}' is abstract. The following methods: '{"', '".join(newClass.__abstractMethods__)}' need to be overridden in a derived class.""")
891 abstract_new.__raises_abstract_class_error__ = True
893 newClass.__new__ = abstract_new
894 return True
896 # Handle classes which are not abstract, especially derived classes, if not abstract anymore
897 else:
898 # skip intermediate 'new' function if class isn't abstract anymore
899 try:
900 if newClass.__new__.__raises_abstract_class_error__: 900 ↛ 913line 900 didn't jump to line 913 because the condition on line 900 was always true
901 origNew = newClass.__new__.__wrapped__
903 # WORKAROUND: __new__ checks tp_new and implements different behavior
904 # Bugreport: https://github.com/python/cpython/issues/105888
905 if origNew is object.__new__: 905 ↛ 912line 905 didn't jump to line 912 because the condition on line 905 was always true
906 @wraps(object.__new__)
907 def wrapped_new(inst, *_, **__):
908 return object.__new__(inst)
910 newClass.__new__ = wrapped_new
911 else:
912 newClass.__new__ = origNew
913 elif newClass.__new__.__isSingleton__:
914 raise Exception(f"Found a singleton wrapper around an AbstractError raising method. This case is not handled yet.")
915 except AttributeError as ex:
916 # WORKAROUND:
917 # AttributeError.name was added in Python 3.10. For version <3.10 use a string contains operation.
918 try:
919 if ex.name != "__raises_abstract_class_error__": 919 ↛ 920line 919 didn't jump to line 920 because the condition on line 919 was never true
920 raise ex
921 except AttributeError:
922 if "__raises_abstract_class_error__" not in str(ex): 922 ↛ 923line 922 didn't jump to line 923 because the condition on line 922 was never true
923 raise ex
925 return False
927 # Additional properties and methods on a class
928 @property
929 def HasClassAttributes(self) -> bool:
930 """
931 Read-only property to check if the class has Attributes (:attr:`__pyattr__`).
933 :returns: ``True``, if the class has Attributes.
934 """
935 try:
936 return len(self.__pyattr__) > 0
937 except AttributeError:
938 return False
940 @property
941 def HasMethodAttributes(self) -> bool:
942 """
943 Read-only property to check if the class has methods with Attributes (:attr:`__methodsWithAttributes__`).
945 :returns: ``True``, if the class has any method with Attributes.
946 """
947 try:
948 return len(self.__methodsWithAttributes__) > 0
949 except AttributeError:
950 return False
953@export
954class SlottedObject(metaclass=ExtendedType, slots=True):
955 """Classes derived from this class will store all members in ``__slots__``."""