Coverage for pyTooling/MetaClasses/__init__.py: 92%
451 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-31 07:24 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-31 07:24 +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 BuiltinFunctionType, 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
49from pyTooling.Warning import Warning, WarningCollector
52__all__ = ["M"]
54TAttr = TypeVar("TAttr") # , bound='Attribute')
55"""A type variable for :class:`~pyTooling.Attributes.Attribute`."""
57TAttributeFilter = Union[TAttr, Iterable[TAttr], None]
58"""A type hint for a predicate parameter that accepts either a single :class:`~pyTooling.Attributes.Attribute` or an
59iterable of those."""
62@export
63class ExtendedTypeError(ToolingException):
64 """The exception is raised by the meta-class :class:`~pyTooling.Metaclasses.ExtendedType`."""
67@export
68class BaseClassWithoutSlotsError(ExtendedTypeError):
69 """
70 This exception is raised when a class using ``__slots__`` inherits from at-least one base-class not using ``__slots__``.
72 .. seealso::
74 * :ref:`Python data model for slots <slots>`
75 * :term:`Glossary entry __slots__ <__slots__>`
76 """
79@export
80class BaseClassWithNonEmptySlotsError(ExtendedTypeError):
81 """
82 This exception is raised when a mixin-class uses slots, but Python prohibits slots.
84 .. important::
86 To fulfill Python's requirements on slots, pyTooling uses slots only on the prinmary inheritance line.
87 Mixin-classes collect slots, which get materialized when the mixin-class (secondary inheritance lines) gets merged
88 into the primary inheritance line.
89 """
92@export
93class BaseClassIsNotAMixinError(ExtendedTypeError):
94 pass
97@export
98class DuplicateFieldInSlotsError(ExtendedTypeError):
99 """
100 This exception is raised when a slot name is used multiple times within the inheritance hierarchy.
101 """
104@export
105class UnannotatedFieldWarning(Warning):
106 """
107 A class declares a field that was assigned in the class body without a type annotation.
109 An object field is annotated with its type, a class variable with :class:`~typing.ClassVar`. Without an annotation,
110 :class:`ExtendedType` can't tell the two apart, and the field never becomes a slot.
111 """
114@export
115class IncompatibleMetaClassError(ExtendedTypeError):
116 """
117 This exception is raised when a class decorated with :deco:`slotted`, :deco:`mixin` or :deco:`singleton` uses a
118 meta-class that is neither :class:`type` nor derived from :class:`~pyTooling.MetaClasses.ExtendedType`.
119 """
122@export
123class AbstractClassError(ExtendedTypeError):
124 """
125 This exception is raised, when a class contains methods marked with *abstractmethod* or *must-override*.
127 .. seealso::
129 :deco:`~pyTooling.MetaClasses.abstractmethod`
130 |rarr| Mark a method as *abstract*.
131 :deco:`~pyTooling.MetaClasses.mustoverride`
132 |rarr| Mark a method as *must overrride*.
133 :exc:`~MustOverrideClassError`
134 |rarr| Exception raised, if a method is marked as *must-override*.
135 """
138@export
139class MustOverrideClassError(AbstractClassError):
140 """
141 This exception is raised, when a class contains methods marked with *must-override*.
143 .. seealso::
145 :deco:`~pyTooling.MetaClasses.abstractmethod`
146 |rarr| Mark a method as *abstract*.
147 :deco:`~pyTooling.MetaClasses.mustoverride`
148 |rarr| Mark a method as *must overrride*.
149 :exc:`~AbstractClassError`
150 |rarr| Exception raised, if a method is marked as *abstract*.
151 """
154# """
155# Metaclass that allows multiple dispatch of methods based on method signatures.
156#
157# .. seealso:
158#
159# `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>`__
160# """
163M = TypeVar("M", bound=Callable) #: A type variable for methods.
166def _recreateClass(cls: type, decoratorName: str, **options: bool) -> type:
167 """
168 Recreate a class with :class:`ExtendedType` (or the class' own compatible meta-class) applying the given options.
170 :param cls: Class to recreate.
171 :param decoratorName: Name of the calling decorator. It's used in the error message.
172 :param options: Meta-class options like ``slots``, ``mixin`` or ``singleton``.
173 :returns: The recreated class.
174 :raises IncompatibleMetaClassError: If the class' meta-class is neither :class:`type`, nor derived from
175 :class:`ExtendedType`.
176 """
177 if cls.__class__ is type:
178 metacls = ExtendedType
179 elif issubclass(cls.__class__, ExtendedType):
180 metacls = cls.__class__
181 for method in cls.__methods__:
182 delattr(method, "__classobj__")
183 else:
184 metaClass = cls.__class__
185 ex = IncompatibleMetaClassError(f"Class '{cls.__name__}' decorated with '@{decoratorName}' uses an incompatible meta-class.")
186 ex.add_note(f"Meta-class is '{metaClass.__module__}.{metaClass.__name__}'.")
187 ex.add_note(f"A decorated class must use 'type' or a meta-class derived from 'pyTooling.MetaClasses.ExtendedType'.")
188 raise ex
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, **options)
202@export
203def slotted(cls):
204 return _recreateClass(cls, "slotted", slots=True)
207@export
208def mixin(cls):
209 return _recreateClass(cls, "mixin", mixin=True)
212@export
213def singleton(cls):
214 return _recreateClass(cls, "singleton", singleton=True)
217@export
218def abstractmethod(method: M) -> M:
219 """
220 Mark a method as *abstract* and replace the implementation with a new method raising a :exc:`NotImplementedError`.
222 The original method is stored in ``<method>.__wrapped__`` and it's doc-string is copied to the replacing method. In
223 additional field ``<method>.__abstract__`` is added.
225 .. warning::
227 This decorator should be used in combination with meta-class :class:`~pyTooling.Metaclasses.ExtendedType`.
228 Otherwise, an abstract class itself doesn't throw a :exc:`~pyTooling.Exceptions.AbstractClassError` at
229 instantiation.
231 .. admonition:: ``example.py``
233 .. code-block:: python
235 class Data(mataclass=ExtendedType):
236 @abstractmethod
237 def method(self) -> bool:
238 '''This method needs to be implemented'''
240 :param method: Method that is marked as *abstract*.
241 :returns: Replacement method, which raises a :exc:`NotImplementedError`.
243 .. seealso::
245 * :exc:`~pyTooling.Exceptions.AbstractClassError`
246 * :deco:`~pyTooling.MetaClasses.mustoverride`
247 * :deco:`~pyTooling.Decorators.notimplemented`
248 """
249 @wraps(method)
250 def func(self) -> NoReturn:
251 raise NotImplementedError(f"Method '{method.__name__}' is abstract and needs to be overridden in a derived class.")
253 func.__abstract__ = True
254 return func
257@export
258def mustoverride(method: M) -> M:
259 """
260 Mark a method as *must-override*.
262 The returned function is the original function, but with an additional field ``<method>.____mustOverride__``, so a
263 meta-class can identify a *must-override* method and raise an error. Such an error is not raised if the method is
264 overridden by an inheriting class.
266 A *must-override* methods can offer a partial implementation, which is called via ``super()...``.
268 .. warning::
270 This decorator needs to be used in combination with meta-class :class:`~pyTooling.Metaclasses.ExtendedType`.
271 Otherwise, an abstract class itself doesn't throw a :exc:`~pyTooling.Exceptions.MustOverrideClassError` at
272 instantiation.
274 .. admonition:: ``example.py``
276 .. code-block:: python
278 class Data(mataclass=ExtendedType):
279 @mustoverride
280 def method(self):
281 '''This is a very basic implementation'''
283 :param method: Method that is marked as *must-override*.
284 :returns: Same method, but with additional ``<method>.__mustOverride__`` field.
286 .. seealso::
288 * :exc:`~pyTooling.Exceptions.MustOverrideClassError`
289 * :deco:`~pyTooling.MetaClasses.abstractmethod`
290 * :deco:`~pyTooling.Decorators.notimplemented`
291 """
292 method.__mustOverride__ = True
293 return method
296# @export
297# def overloadable(method: M) -> M:
298# method.__overloadable__ = True
299# return method
302# @export
303# class DispatchableMethod:
304# """Represents a single multimethod."""
305#
306# _methods: Dict[Tuple, Callable]
307# __name__: str
308# __slots__ = ("_methods", "__name__")
309#
310# def __init__(self, name: str) -> None:
311# self.__name__ = name
312# self._methods = {}
313#
314# def __call__(self, *args: Any):
315# """Call a method based on type signature of the arguments."""
316# types = tuple(type(arg) for arg in args[1:])
317# meth = self._methods.get(types, None)
318# if meth:
319# return meth(*args)
320# else:
321# raise TypeError(f"No matching method for types {types}.")
322#
323# def __get__(self, instance, cls): # Starting with Python 3.11+, use typing.Self as return type
324# """Descriptor method needed to make calls work in a class."""
325# if instance is not None:
326# return MethodType(self, instance)
327# else:
328# return self
329#
330# def register(self, method: Callable) -> None:
331# """Register a new method as a dispatchable."""
332#
333# # Build a signature from the method's type annotations
334# sig = signature(method)
335# types: List[Type] = []
336#
337# for name, parameter in sig.parameters.items():
338# if name == "self":
339# continue
340#
341# if parameter.annotation is Parameter.empty:
342# raise TypeError(f"Parameter '{name}' in method '{method.__name__}' must be annotated with a type.")
343#
344# if not isinstance(parameter.annotation, type):
345# raise TypeError(f"Parameter '{name}' in method '{method.__name__}' annotation must be a type.")
346#
347# if parameter.default is not Parameter.empty:
348# self._methods[tuple(types)] = method
349#
350# types.append(parameter.annotation)
351#
352# self._methods[tuple(types)] = method
355# @export
356# class DispatchDictionary(dict):
357# """Special dictionary to build dispatchable methods in a metaclass."""
358#
359# def __setitem__(self, key: str, value: Any):
360# if callable(value) and key in self:
361# # If key already exists, it must be a dispatchable method or callable
362# currentValue = self[key]
363# if isinstance(currentValue, DispatchableMethod):
364# currentValue.register(value)
365# else:
366# dispatchable = DispatchableMethod(key)
367# dispatchable.register(currentValue)
368# dispatchable.register(value)
369#
370# super().__setitem__(key, dispatchable)
371# else:
372# super().__setitem__(key, value)
375@export
376class ExtendedType(type):
377 """
378 An updates meta-class to construct new classes with an extended feature set.
380 .. todo:: META::ExtendedType Needs documentation.
381 .. todo:: META::ExtendedType allow __dict__ and __weakref__ if slotted is enabled
383 .. rubric:: Features:
385 * Store object members more efficiently in ``__slots__`` instead of ``_dict__``.
387 * Implement ``__slots__`` only on primary inheritance line.
388 * Collect class variables on secondary inheritance lines (mixin-classes) and defer implementation as ``__slots__``.
389 * Handle object state exporting and importing for slots (:mod:`pickle` support) via ``__getstate__``/``__setstate__``.
391 * Allow only a single instance to be created (:term:`singleton`). |br|
392 Further instantiations will return the previously create instance (identical object).
393 * Define methods as :term:`abstract <abstract method>` or :term:`must-override <mustoverride method>` and prohibit
394 instantiation of :term:`abstract classes <abstract class>`.
396 .. #* Allow method overloading and dispatch overloads based on argument signatures.
398 .. rubric:: Added class fields:
400 :__slotted__: True, if class uses `__slots__`.
401 :__allSlots__: Set of class fields stored in slots for all classes in the inheritance hierarchy.
402 :__slots__: Tuple of class fields stored in slots for current class in the inheritance hierarchy. |br|
403 See :pep:`253` for details.
404 :__isMixin__: True, if class is a mixin-class
405 :__mixinSlots__: List of collected slots from secondary inheritance hierarchy (mixin hierarchy).
406 :__methods__: List of methods.
407 :__methodsWithAttributes__: List of methods with pyTooling attributes.
408 :__abstractMethods__: List of abstract methods, which need to be implemented in the next class hierarchy levels.
409 :__isAbstract__: True, if class is abstract.
410 :__isSingleton__: True, if class is a singleton
411 :__singletonInstanceCond__: Condition variable to protect the singleton creation.
412 :__singletonInstanceInit__: Singleton is initialized.
413 :__singletonInstanceCache__: The singleton object, once created.
414 :__pyattr__: List of class attributes.
416 .. rubric:: Added class properties:
418 :HasClassAttributes: Read-only property to check if the class has Attributes.
419 :HasMethodAttributes: Read-only property to check if the class has methods with Attributes.
421 .. rubric:: Added methods:
423 If slots are used, the following methods are added to support :mod:`pickle`:
425 :__getstate__: Export an object's state for serialization. |br|
426 See :pep:`307` for details.
427 :__setstate__: Import an object's state for deserialization. |br|
428 See :pep:`307` for details.
430 .. rubric:: Modified ``__new__`` method:
432 If class is a singleton, ``__new__`` will be replaced by a wrapper method. This wrapper is marked with ``__singleton_wrapper__``.
434 If class is abstract, ``__new__`` will be replaced by a method raising an exception. This replacement is marked with ``__raises_abstract_class_error__``.
436 .. rubric:: Modified ``__init__`` method:
438 If class is a singleton, ``__init__`` will be replaced by a wrapper method. This wrapper is marked by ``__singleton_wrapper__``.
440 .. rubric:: Modified abstract methods:
442 If a method is abstract, its marked with ``__abstract__``. |br|
443 If a method is must override, its marked with ``__mustOverride__``.
444 """
446 # @classmethod
447 # def __prepare__(cls, className, baseClasses, slots: bool = False, mixin: bool = False, singleton: bool = False):
448 # return DispatchDictionary()
450 def __new__(
451 self,
452 className: str,
453 baseClasses: Tuple[type],
454 members: Dict[str, Any],
455 slots: bool = False,
456 mixin: bool = False,
457 singleton: bool = False
458 ) -> Self:
459 """
460 Construct a new class using this :term:`meta-class`.
462 :param className: The name of the class to construct.
463 :param baseClasses: The tuple of :term:`base-classes <base-class>` the class is derived from.
464 :param members: The dictionary of members for the constructed class.
465 :param slots: If true, store object attributes in :term:`__slots__ <slots>` instead of ``__dict__``.
466 :param mixin: If true, make the class a :term:`Mixin-Class`.
467 If false, create slots if ``slots`` is true.
468 If none, preserve behavior of primary base-class.
469 :param singleton: If true, make the class a :term:`Singleton`.
470 :returns: The new class.
471 :raises AttributeError: If base-class has no '__slots__' attribute.
472 :raises AttributeError: If slot already exists in base-class.
473 """
474 from pyTooling.Attributes import ATTRIBUTES_MEMBER_NAME, AttributeScope
476 # Inherit 'slots' feature from primary base-class
477 if len(baseClasses) > 0:
478 primaryBaseClass = baseClasses[0]
479 if isinstance(primaryBaseClass, self):
480 slots = primaryBaseClass.__slotted__
482 # Compute slots and mixin-slots from annotated fields as well as class- and object-fields with initial values.
483 classFields, objectFields = self._computeSlots(className, baseClasses, members, slots, mixin)
485 # Compute abstract methods
486 abstractMethods, members = self._checkForAbstractMethods(baseClasses, members)
488 # Create a new class
489 newClass = type.__new__(self, className, baseClasses, members)
491 # Apply class fields
492 for fieldName, typeAnnotation in classFields.items():
493 setattr(newClass, fieldName, typeAnnotation)
495 # Search in inheritance tree for abstract methods
496 newClass.__abstractMethods__ = abstractMethods
497 newClass.__isAbstract__ = self._wrapNewMethodIfAbstract(newClass)
498 newClass.__isSingleton__ = self._wrapNewMethodIfSingleton(newClass, singleton)
500 if slots:
501 # If slots are used, implement __getstate__/__setstate__ API to support serialization using pickle.
502 if "__getstate__" not in members:
503 def __getstate__(self) -> Dict[str, Any]:
504 try:
505 return {slotName: getattr(self, slotName) for slotName in self.__allSlots__}
506 except AttributeError as ex:
507 raise ExtendedTypeError(f"Unassigned field '{ex.name}' in object '{self}' of type '{self.__class__.__name__}'.") from ex
509 newClass.__getstate__ = __getstate__
511 if "__setstate__" not in members:
512 def __setstate__(self, state: Dict[str, Any]) -> None:
513 if self.__allSlots__ != (slots := set(state.keys())):
514 if len(diff := self.__allSlots__.difference(slots)) > 0:
515 raise ExtendedTypeError(f"""Missing fields in parameter 'state': '{"', '".join(diff)}'""") # WORKAROUND: Python <3.12
516 else:
517 diff = slots.difference(self.__allSlots__)
518 raise ExtendedTypeError(f"""Unexpected fields in parameter 'state': '{"', '".join(diff)}'""") # WORKAROUND: Python <3.12
520 for slotName, value in state.items():
521 setattr(self, slotName, value)
523 newClass.__setstate__ = __setstate__
525 # Check for inherited class attributes
526 attributes = []
527 setattr(newClass, ATTRIBUTES_MEMBER_NAME, attributes)
528 for base in baseClasses:
529 if hasattr(base, ATTRIBUTES_MEMBER_NAME):
530 pyAttr = getattr(base, ATTRIBUTES_MEMBER_NAME)
531 for att in pyAttr:
532 if AttributeScope.Class in att.Scope: 532 ↛ 531line 532 didn't jump to line 531 because the condition on line 532 was always true
533 attributes.append(att)
534 att.__class__._classes.append(newClass)
536 # Check methods for attributes
537 methods, methodsWithAttributes = self._findMethods(newClass, baseClasses, members)
539 # Add new fields for found methods
540 newClass.__methods__ = tuple(methods)
541 newClass.__methodsWithAttributes__ = tuple(methodsWithAttributes)
543 # Additional methods on a class
544 def GetMethodsWithAttributes(self, predicate: Nullable[TAttributeFilter[TAttr]] = None) -> Dict[Callable, Tuple["Attribute", ...]]:
545 """
547 :param predicate: An attribute class, an iterable of attribute classes, or ``None`` to accept every attribute.
548 :returns: Dictionary of methods and the matching attributes attached to them.
549 :raises ValueError: If an element of parameter 'predicate' is not a sub-class of :class:`~pyTooling.Attributes.Attribute`.
550 :raises ValueError: If parameter 'predicate' is neither an attribute class nor an iterable of those.
551 """
552 from pyTooling.Attributes import Attribute
554 if predicate is None:
555 predicate = Attribute
556 elif isinstance(predicate, Iterable): 556 ↛ 557line 556 didn't jump to line 557 because the condition on line 556 was never true
557 for attribute in predicate:
558 if not issubclass(attribute, Attribute):
559 raise ValueError(f"Parameter 'predicate' contains an element which is not a sub-class of 'Attribute'.")
561 predicate = tuple(predicate)
562 elif not issubclass(predicate, Attribute): 562 ↛ 563line 562 didn't jump to line 563 because the condition on line 562 was never true
563 raise ValueError(f"Parameter 'predicate' is not a sub-class of 'Attribute'.")
565 methodAttributePairs = {}
566 for method in newClass.__methodsWithAttributes__:
567 matchingAttributes = []
568 for attribute in method.__pyattr__:
569 if isinstance(attribute, predicate):
570 matchingAttributes.append(attribute)
572 if len(matchingAttributes) > 0:
573 methodAttributePairs[method] = tuple(matchingAttributes)
575 return methodAttributePairs
577 newClass.GetMethodsWithAttributes = classmethod(GetMethodsWithAttributes)
578 GetMethodsWithAttributes.__qualname__ = f"{className}.{GetMethodsWithAttributes.__name__}"
580 # GetMethods(predicate) -> dict[method, list[attribute]] / generator
581 # GetClassAtrributes -> list[attributes] / generator
582 # MethodHasAttributes(predicate) -> bool
583 # GetAttribute
585 return newClass
587 @classmethod
588 def _findMethods(
589 self,
590 newClass: "ExtendedType",
591 baseClasses: Tuple[type],
592 members: Dict[str, Any]
593 ) -> Tuple[List[MethodType], List[MethodType]]:
594 """
595 Find methods and methods with :mod:`pyTooling.Attributes`.
597 .. todo::
599 Describe algorithm.
601 :param newClass: Newly created class instance.
602 :param baseClasses: The tuple of :term:`base-classes <base-class>` the class is derived from.
603 :param members: Members of the new class.
604 :returns: A 2-tuple of all methods and those methods carrying at least one attribute.
605 """
606 from pyTooling.Attributes import Attribute
608 # Embedded bind function due to circular dependencies.
609 def bind(instance: object, func: FunctionType, methodName: Nullable[str] = None):
610 if methodName is None: 610 ↛ 613line 610 didn't jump to line 613 because the condition on line 610 was always true
611 methodName = func.__name__
613 boundMethod = func.__get__(instance, instance.__class__)
614 setattr(instance, methodName, boundMethod)
616 return boundMethod
618 methods = []
619 methodsWithAttributes = []
620 attributeIndex = {}
622 for base in baseClasses:
623 if hasattr(base, "__methodsWithAttributes__"):
624 methodsWithAttributes.extend(base.__methodsWithAttributes__)
626 for memberName, member in members.items():
627 if isinstance(member, FunctionType):
628 method = newClass.__dict__[memberName]
629 if hasattr(method, "__classobj__") and getattr(method, "__classobj__") is not newClass: 629 ↛ 630line 629 didn't jump to line 630 because the condition on line 629 was never true
630 raise TypeError(f"Method '{memberName}' is used by multiple classes: {method.__classobj__} and {newClass}.")
631 else:
632 setattr(method, "__classobj__", newClass)
634 def GetAttributes(inst: Any, predicate: Nullable[Type[Attribute]] = None) -> Tuple[Attribute, ...]:
635 results = []
636 try:
637 for attribute in inst.__pyattr__: # type: Attribute
638 if isinstance(attribute, predicate):
639 results.append(attribute)
640 return tuple(results)
641 except AttributeError:
642 return tuple()
644 method.GetAttributes = bind(method, GetAttributes)
645 methods.append(method)
647 # print(f" convert function: '{memberName}' to method")
648 # print(f" {member}")
649 if "__pyattr__" in member.__dict__:
650 attributes = member.__pyattr__ # type: List[Attribute]
651 if isinstance(attributes, list) and len(attributes) > 0: 651 ↛ 626line 651 didn't jump to line 626 because the condition on line 651 was always true
652 methodsWithAttributes.append(member)
653 for attribute in attributes:
654 attribute._functions.remove(method)
655 attribute._methods.append(method)
657 # print(f" attributes: {attribute.__class__.__name__}")
658 if attribute not in attributeIndex: 658 ↛ 661line 658 didn't jump to line 661 because the condition on line 658 was always true
659 attributeIndex[attribute] = [member]
660 else:
661 attributeIndex[attribute].append(member)
662 # else:
663 # print(f" But has no attributes.")
664 # else:
665 # print(f" ?? {memberName}")
666 return methods, methodsWithAttributes
668 @classmethod
669 def _getAnnotations(metacls, members: Dict[str, Any]) -> Dict[str, Any]:
670 """
671 Return the type annotations declared in a class body.
673 .. important::
675 Python 3.14 (:pep:`649`) no longer fills ``__annotations__`` while the class body is executed, but installs an
676 ``__annotate_func__`` instead. The :mod:`annotationlib` module needed to evaluate that function doesn't exist on
677 older Python versions, therefore both mechanisms are supported.
679 :param members: Dictionary of class members.
680 :returns: Dictionary of annotated field names and their type annotations. Empty, if the class body declared
681 no annotations.
682 """
683 if "__annotations__" in members:
684 # WORKAROUND: LEGACY SUPPORT Python <= 3.13
685 # Accessing annotations was changed in Python 3.14.
686 # The necessary 'annotationlib' is not available for older Python versions.
687 return members["__annotations__"]
688 elif version_info >= (3, 14) and (annotate := members.get("__annotate_func__", None)) is not None:
689 from annotationlib import Format
690 return annotate(Format.VALUE)
691 else:
692 return {}
694 @classmethod
695 def _isClassVariable(metacls, typeAnnotation: Any) -> bool:
696 """
697 Check if a type annotation declares a class variable.
699 :param typeAnnotation: The type annotation to check.
700 :returns: ``True``, if the annotation is a :class:`~typing.ClassVar`.
701 """
702 return isinstance(typeAnnotation, _GenericAlias) and typeAnnotation.__origin__ is ClassVar
704 @classmethod
705 def _isField(metacls, member: Any) -> bool:
706 """
707 Check if a class member is a field, so a type annotation is expected for it.
709 Methods, nested classes, properties and any other descriptor carry their type information in their signature or
710 in their own declaration, therefore they aren't fields.
712 :param member: The class member to check.
713 :returns: ``True``, if the member is a field.
714 """
715 if isinstance(member, (FunctionType, MethodType, BuiltinFunctionType, classmethod, staticmethod, property, type)):
716 return False
718 # Any descriptor (e.g. a custom property implementation) declares its own type information.
719 return not (hasattr(member, "__get__") or hasattr(member, "__set__"))
721 @classmethod
722 def _checkForUnannotatedFields(metacls, className: str, members: Dict[str, Any], annotations: Dict[str, Any]) -> None:
723 """
724 Report fields that were assigned in the class body without a type annotation.
726 Every field should carry type information: an object field is annotated with its type, a class variable with
727 ``ClassVar[...]``. Without an annotation, :class:`ExtendedType` can't tell the two apart - an un-annotated
728 assignment never becomes a slot and silently stays a class attribute.
730 .. important::
732 This is reported as a :class:`~pyTooling.Warning.Warning`, so it needs a
733 :class:`~pyTooling.Warning.WarningCollector` somewhere up the call-hierarchy to be observed. Importing a module
734 with un-annotated fields doesn't fail.
736 :param className: The name of the class to construct.
737 :param members: Dictionary of class members.
738 :param annotations: Dictionary of annotated field names and their type annotations.
739 """
740 unannotatedFields = [
741 fieldName
742 for fieldName, member in members.items()
743 if not (fieldName.startswith("__") and fieldName.endswith("__"))
744 and fieldName not in annotations
745 and metacls._isField(member)
746 ]
748 if len(unannotatedFields) > 0:
749 fieldNames = "', '".join(unannotatedFields)
750 WarningCollector.Raise(
751 UnannotatedFieldWarning(f"Class '{className}' declares {len(unannotatedFields)} field(s) without a type annotation."),
752 notes=(
753 f"Field(s) without a type annotation: '{fieldNames}'.",
754 f"Annotate a class variable as 'ClassVar[...]' or an object field with its type.",
755 )
756 )
758 @classmethod
759 def _computeSlots(
760 self,
761 className: str,
762 baseClasses: Tuple[type],
763 members: Dict[str, Any],
764 slots: bool,
765 mixin: bool
766 ) -> Tuple[Dict[str, Any], Dict[str, Any]]:
767 """
768 Compute which field are listed in __slots__ and which need to be initialized in an instance or class.
770 .. todo::
772 Describe algorithm.
774 :param className: The name of the class to construct.
775 :param baseClasses: Tuple of base-classes.
776 :param members: Dictionary of class members.
777 :param slots: True, if the class should setup ``__slots__``.
778 :param mixin: True, if the class should behave as a mixin-class.
779 :returns: A 2-tuple with a dictionary of class members and object members.
780 """
781 # Compute which field are listed in __slots__ and which need to be initialized in an instance or class.
782 slottedFields = []
783 classFields = {}
784 objectFields = {}
785 annotations: Dict[str, Any] = self._getAnnotations(members)
786 if slots or mixin:
787 # If slots are used, all base classes must use __slots__.
788 for baseClass in self._iterateBaseClasses(baseClasses):
789 # Exclude object as a special case
790 if baseClass is object or baseClass is Generic:
791 continue
793 if not hasattr(baseClass, "__slots__"):
794 ex = BaseClassWithoutSlotsError(f"Base-classes '{baseClass.__name__}' doesn't use '__slots__'.")
795 ex.add_note(f"All base-classes of a class using '__slots__' must use '__slots__' itself.")
796 raise ex
798 # Non-empty __slots__ on secondary base-classes are rejected by _aggregateMixinSlots below.
800 # Copy all field names from primary base-class' __slots__, which are later needed for error checking.
801 inheritedSlottedFields = {}
802 if len(baseClasses) > 0:
803 for base in reversed(baseClasses[0].mro()):
804 # Exclude object as a special case
805 if base is object or base is Generic:
806 continue
808 for annotation in base.__slots__:
809 inheritedSlottedFields[annotation] = base
811 # When adding annotated fields to slottedFields, check if name was not used in inheritance hierarchy.
812 for fieldName, typeAnnotation in annotations.items():
813 if fieldName in inheritedSlottedFields: 813 ↛ 814line 813 didn't jump to line 814 because the condition on line 813 was never true
814 cls = inheritedSlottedFields[fieldName]
815 raise AttributeError(f"Slot '{fieldName}' declared in class '{className}' already exists in base-class '{cls.__module__}.{cls.__name__}'.")
817 # A ClassVar is never a slot, with or without an initial value.
818 # * If it has an initial value, copy field and initial value to classFields dictionary and remove field from members.
819 # * Otherwise it's a forward declaration and derived classes assign the actual value.
820 isClassVariable = self._isClassVariable(typeAnnotation)
821 hasInitialValue = fieldName in members
822 if isClassVariable:
823 if hasInitialValue:
824 classFields[fieldName] = members[fieldName]
825 del members[fieldName]
827 # If an annotated field has an initial value
828 # * copy field and initial value to objectFields dictionary
829 # * remove field from members
830 elif hasInitialValue:
831 slottedFields.append(fieldName)
832 objectFields[fieldName] = members[fieldName]
833 del members[fieldName]
834 else:
835 slottedFields.append(fieldName)
837 mixinSlots = self._aggregateMixinSlots(className, baseClasses)
839 # A member assigned in the class body without a type annotation stays a class attribute. If it carries the name
840 # of a slot, that class attribute shadows the slot's descriptor and the field becomes read-only on instances.
841 # Report it here instead of letting the first assignment fail with a bare AttributeError.
842 shadowedSlots = {**inheritedSlottedFields, **{fieldName: None for fieldName in mixinSlots}}
843 for fieldName in shadowedSlots.keys() & members.keys():
844 ex = DuplicateFieldInSlotsError(f"Slot '{fieldName}' is shadowed by a class member in class '{className}'.")
845 if (baseClass := shadowedSlots[fieldName]) is not None:
846 ex.add_note(f"Slot '{fieldName}' is declared in base-class '{baseClass.__module__}.{baseClass.__name__}'.")
847 ex.add_note(f"An assignment without a type annotation creates a class attribute, which hides the slot's descriptor.")
848 ex.add_note(f"Reading the field works, but assigning it on an instance raises an AttributeError.")
849 else:
850 ex.add_note(f"Slot '{fieldName}' is contributed by a mixin-class and materialized in this class' '__slots__'.")
851 ex.add_note(f"Python doesn't allow a name to be listed in '__slots__' and assigned in the class body.")
852 ex.add_note(f"Annotate it as 'ClassVar[...]' to declare a class variable, or remove the assignment.")
853 raise ex
854 else:
855 # When adding annotated fields to slottedFields, check if name was not used in inheritance hierarchy.
856 for fieldName, typeAnnotation in annotations.items():
857 # If annotated field is a ClassVar, and it has an initial value
858 # * copy field and initial value to classFields dictionary
859 # * remove field from members
860 if self._isClassVariable(typeAnnotation) and fieldName in members:
861 classFields[fieldName] = members[fieldName]
862 del members[fieldName]
864 self._checkForUnannotatedFields(className, members, annotations)
866 if mixin:
867 mixinSlots.extend(slottedFields)
868 members["__slotted__"] = True
869 members["__slots__"] = tuple()
870 members["__allSlots__"] = set()
871 members["__isMixin__"] = True
872 members["__mixinSlots__"] = tuple(mixinSlots)
873 elif slots:
874 slottedFields.extend(mixinSlots)
875 members["__slotted__"] = True
876 members["__slots__"] = tuple(slottedFields)
877 members["__allSlots__"] = set(chain(slottedFields, inheritedSlottedFields.keys()))
878 members["__isMixin__"] = False
879 members["__mixinSlots__"] = tuple()
880 else:
881 members["__slotted__"] = False
882 # NO __slots__
883 # members["__allSlots__"] = set()
884 members["__isMixin__"] = False
885 members["__mixinSlots__"] = tuple()
886 return classFields, objectFields
888 @classmethod
889 def _aggregateMixinSlots(self, className: str, baseClasses: Tuple[type]) -> List[str]:
890 """
891 Aggregate slot names requested by mixin-base-classes.
893 .. todo::
895 Describe algorithm.
897 :param className: The name of the class to construct.
898 :param baseClasses: The tuple of :term:`base-classes <base-class>` the class is derived from.
899 :returns: A list of slot names.
900 """
901 mixinSlots = []
902 if len(baseClasses) > 0:
903 # If class has base-classes ensure only the primary inheritance path uses slots and all secondary inheritance
904 # paths have an empty slots tuple. Otherwise, raise a BaseClassWithNonEmptySlotsError.
905 inheritancePaths = [path for path in self._iterateBaseClassPaths(baseClasses)]
906 primaryInharitancePath: Set[type] = set(inheritancePaths[0])
907 for typePath in inheritancePaths[1:]:
908 for t in typePath:
909 if hasattr(t, "__slots__") and len(t.__slots__) != 0 and t not in primaryInharitancePath:
910 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}'.")
911 ex.add_note(f"In Python, only one inheritance branch can use non-empty __slots__.")
912 # ex.add_note(f"With ExtendedType, only the primary base-class can use non-empty __slots__.")
913 # ex.add_note(f"Secondary base-classes should be marked as mixin-classes.")
914 raise ex
916 # If current class is set to be a mixin, then aggregate all mixinSlots in a list.
917 # Ensure all base-classes are either constructed
918 # * by meta-class ExtendedType, or
919 # * use no slots, or
920 # * are typing.Generic
921 # If it was constructed by ExtendedType, then ensure this class itself is a mixin-class.
922 for baseClass in baseClasses: # type: ExtendedType
923 if isinstance(baseClass, _GenericAlias) and baseClass.__origin__ is Generic: 923 ↛ 924line 923 didn't jump to line 924 because the condition on line 923 was never true
924 pass
925 elif baseClass.__class__ is self and baseClass.__isMixin__:
926 mixinSlots.extend(baseClass.__mixinSlots__)
927 elif hasattr(baseClass, "__mixinSlots__"):
928 mixinSlots.extend(baseClass.__mixinSlots__)
930 return mixinSlots
932 @classmethod
933 def _iterateBaseClasses(metacls, baseClasses: Tuple[type]) -> Generator[type, None, None]:
934 """
935 Return a generator to iterate (visit) all base-classes ...
937 .. todo::
939 Describe iteration order.
941 :param baseClasses: The tuple of :term:`base-classes <base-class>` the class is derived from.
942 :returns: Generator to iterate all base-classes.
943 """
944 if len(baseClasses) == 0:
945 return
947 visited: Set[type] = set()
948 iteratorStack: List[Iterator[type]] = list()
950 for baseClass in baseClasses:
951 yield baseClass
952 visited.add(baseClass)
953 iteratorStack.append(iter(baseClass.__bases__))
955 while True:
956 try:
957 base = next(iteratorStack[-1]) # type: type
958 if base not in visited: 958 ↛ 963line 958 didn't jump to line 963 because the condition on line 958 was always true
959 yield base
960 if len(base.__bases__) > 0:
961 iteratorStack.append(iter(base.__bases__))
962 else:
963 continue
965 except StopIteration:
966 iteratorStack.pop()
968 if len(iteratorStack) == 0:
969 break
971 @classmethod
972 def _iterateBaseClassPaths(metacls, baseClasses: Tuple[type]) -> Generator[Tuple[type, ...], None, None]:
973 """
974 Return a generator to iterate all possible inheritance paths for a given list of base-classes.
976 An inheritance path is expressed as a tuple of base-classes from current base-class (left-most item) to
977 :class:`object` (right-most item).
979 :param baseClasses: The tuple of :term:`base-classes <base-class>` the class is derived from.
980 :returns: Generator to iterate all inheritance paths. |br|
981 An inheritance path is a tuple of types (base-classes).
982 """
983 if len(baseClasses) == 0: 983 ↛ 984line 983 didn't jump to line 984 because the condition on line 983 was never true
984 return
986 typeStack: List[type] = list()
987 iteratorStack: List[Iterator[type]] = list()
989 for baseClass in baseClasses:
990 typeStack.append(baseClass)
991 iteratorStack.append(iter(baseClass.__bases__))
993 while True:
994 try:
995 base = next(iteratorStack[-1]) # type: type
996 typeStack.append(base)
997 if len(base.__bases__) == 0:
998 yield tuple(typeStack)
999 typeStack.pop()
1000 else:
1001 iteratorStack.append(iter(base.__bases__))
1003 except StopIteration:
1004 typeStack.pop()
1005 iteratorStack.pop()
1007 if len(typeStack) == 0:
1008 break
1010 @classmethod
1011 def _checkForAbstractMethods(metacls, baseClasses: Tuple[type], members: Dict[str, Any]) -> Tuple[Dict[str, Callable], Dict[str, Any]]:
1012 """
1013 Check if the current class contains abstract methods and return a tuple of them.
1015 These abstract methods might be inherited from any base-class. If there are inherited abstract methods, check if
1016 they are now implemented (overridden) by the current class that's right now constructed.
1018 :param baseClasses: The tuple of :term:`base-classes <base-class>` the class is derived from.
1019 :param members: The dictionary of members for the constructed class.
1020 :returns: A tuple of abstract method's names.
1021 """
1022 abstractMethods = {}
1023 if baseClasses:
1024 # Aggregate all abstract methods from all base-classes.
1025 for baseClass in baseClasses:
1026 if hasattr(baseClass, "__abstractMethods__"):
1027 abstractMethods.update(baseClass.__abstractMethods__)
1029 for base in baseClasses:
1030 for memberName, member in base.__dict__.items():
1031 if (memberName in abstractMethods and isinstance(member, FunctionType) and
1032 not (hasattr(member, "__abstract__") or hasattr(member, "__mustOverride__"))):
1033 def outer(method):
1034 @wraps(method)
1035 def inner(cls, *args: Any, **kwargs: Any):
1036 return method(cls, *args, **kwargs)
1038 return inner
1040 members[memberName] = outer(member)
1042 # Check if methods are marked:
1043 # * If so, add them to list of abstract methods
1044 # * If not, method is now implemented and removed from list
1045 for memberName, member in members.items():
1046 if callable(member):
1047 if ((hasattr(member, "__abstract__") and member.__abstract__) or
1048 (hasattr(member, "__mustOverride__") and member.__mustOverride__)):
1049 abstractMethods[memberName] = member
1050 elif memberName in abstractMethods:
1051 del abstractMethods[memberName]
1053 return abstractMethods, members
1055 @classmethod
1056 def _wrapNewMethodIfSingleton(metacls, newClass, singleton: bool) -> bool:
1057 """
1058 If a class is a singleton, wrap the ``_new__`` method, so it returns a cached object, if a first object was created.
1060 Only the first object creation initializes the object.
1062 This implementation is threadsafe.
1064 :param newClass: The newly constructed class for further modifications.
1065 :param singleton: If ``True``, the class allows only a single instance to exist.
1066 :returns: ``True``, if the class is a singleton.
1067 """
1068 if hasattr(newClass, "__isSingleton__"):
1069 singleton = newClass.__isSingleton__
1071 if singleton:
1072 oldnew = newClass.__new__
1073 if hasattr(oldnew, "__singleton_wrapper__"):
1074 oldnew = oldnew.__wrapped__
1076 oldinit = newClass.__init__
1077 if hasattr(oldinit, "__singleton_wrapper__"): 1077 ↛ 1078line 1077 didn't jump to line 1078 because the condition on line 1077 was never true
1078 oldinit = oldinit.__wrapped__
1080 @wraps(oldnew)
1081 def singleton_new(cls, *args: Any, **kwargs: Any):
1082 with cls.__singletonInstanceCond__:
1083 if cls.__singletonInstanceCache__ is None:
1084 obj = oldnew(cls, *args, **kwargs)
1085 cls.__singletonInstanceCache__ = obj
1086 else:
1087 obj = cls.__singletonInstanceCache__
1089 return obj
1091 @wraps(oldinit)
1092 def singleton_init(self, *args: Any, **kwargs: Any):
1093 cls = self.__class__
1094 cv = cls.__singletonInstanceCond__
1095 with cv:
1096 if cls.__singletonInstanceInit__:
1097 oldinit(self, *args, **kwargs)
1098 cls.__singletonInstanceInit__ = False
1099 cv.notify_all()
1100 elif args or kwargs:
1101 raise ValueError(f"A further instance of a singleton can't be reinitialized with parameters.")
1102 else:
1103 while cls.__singletonInstanceInit__: 1103 ↛ 1104line 1103 didn't jump to line 1104 because the condition on line 1103 was never true
1104 cv.wait()
1106 singleton_new.__singleton_wrapper__ = True
1107 singleton_init.__singleton_wrapper__ = True
1109 newClass.__new__ = singleton_new
1110 newClass.__init__ = singleton_init
1111 newClass.__singletonInstanceCond__ = Condition()
1112 newClass.__singletonInstanceInit__ = True
1113 newClass.__singletonInstanceCache__ = None
1114 return True
1116 return False
1118 @classmethod
1119 def _wrapNewMethodIfAbstract(metacls, newClass) -> bool:
1120 """
1121 If the class has abstract methods, replace the ``_new__`` method, so it raises an exception.
1123 :param newClass: The newly constructed class for further modifications.
1124 :returns: ``True``, if the class is abstract.
1125 :raises AbstractClassError: If the class is abstract and can't be instantiated.
1126 """
1127 # Replace '__new__' by a variant to throw an error on not overridden methods
1128 if len(newClass.__abstractMethods__) > 0:
1129 oldnew = newClass.__new__
1130 if hasattr(oldnew, "__raises_abstract_class_error__"):
1131 oldnew = oldnew.__wrapped__
1133 @wraps(oldnew)
1134 def abstract_new(cls, *_, **__):
1135 raise AbstractClassError(f"""Class '{cls.__name__}' is abstract. The following methods: '{"', '".join(newClass.__abstractMethods__)}' need to be overridden in a derived class.""")
1137 abstract_new.__raises_abstract_class_error__ = True
1139 newClass.__new__ = abstract_new
1140 return True
1142 # Handle classes which are not abstract, especially derived classes, if not abstract anymore
1143 else:
1144 # skip intermediate 'new' function if class isn't abstract anymore
1145 try:
1146 if newClass.__new__.__raises_abstract_class_error__: 1146 ↛ 1159line 1146 didn't jump to line 1159 because the condition on line 1146 was always true
1147 origNew = newClass.__new__.__wrapped__
1149 # WORKAROUND: __new__ checks tp_new and implements different behavior
1150 # Bugreport: https://github.com/python/cpython/issues/105888
1151 if origNew is object.__new__: 1151 ↛ 1158line 1151 didn't jump to line 1158 because the condition on line 1151 was always true
1152 @wraps(object.__new__)
1153 def wrapped_new(inst, *_, **__):
1154 return object.__new__(inst)
1156 newClass.__new__ = wrapped_new
1157 else:
1158 newClass.__new__ = origNew
1159 elif newClass.__new__.__isSingleton__:
1160 raise Exception(f"Found a singleton wrapper around an AbstractError raising method. This case is not handled yet.")
1161 except AttributeError as ex:
1162 # WORKAROUND:
1163 # AttributeError.name was added in Python 3.10. For version <3.10 use a string contains operation.
1164 try:
1165 if ex.name != "__raises_abstract_class_error__": 1165 ↛ 1166line 1165 didn't jump to line 1166 because the condition on line 1165 was never true
1166 raise ex
1167 except AttributeError:
1168 if "__raises_abstract_class_error__" not in str(ex):
1169 raise ex
1171 return False
1173 # Additional properties and methods on a class
1174 @readonly
1175 def HasClassAttributes(self) -> bool:
1176 """
1177 Read-only property to check if the class has Attributes (:attr:`__pyattr__`).
1179 :returns: ``True``, if the class has Attributes.
1180 """
1181 try:
1182 return len(self.__pyattr__) > 0
1183 except AttributeError:
1184 return False
1186 @readonly
1187 def HasMethodAttributes(self) -> bool:
1188 """
1189 Read-only property to check if the class has methods with Attributes (:attr:`__methodsWithAttributes__`).
1191 :returns: ``True``, if the class has any method with Attributes.
1192 """
1193 try:
1194 return len(self.__methodsWithAttributes__) > 0
1195 except AttributeError:
1196 return False
1199@export
1200class SlottedObject(metaclass=ExtendedType, slots=True):
1201 """Classes derived from this class will store all members in ``__slots__``."""