Coverage for pyTooling/MetaClasses/__init__.py: 93%
481 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-22 21:29 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-22 21: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.
39.. seealso::
41 :mod:`pyTooling.Decorators`
42 |rarr| The decorator form of the same class options.
43 :mod:`pyTooling.Attributes`
44 |rarr| Attributes, which this meta-class collects per class.
45 :mod:`pyTooling.Exceptions`
46 |rarr| The base-exception of everything this meta-class raises.
47"""
48from __future__ import annotations
50from functools import wraps
51from itertools import chain
52from re import compile as re_compile
53from sys import modules, version_info
54from threading import Condition
55from types import BuiltinFunctionType, FunctionType, MethodType
56from typing import Any, Callable, Generator, Iterator, Iterable, Union, NoReturn, Self
57from typing import TypeVar, Generic, _GenericAlias, ClassVar, Optional as Nullable
59from pyTooling.Exceptions import ToolingException
60from pyTooling.Decorators import export, readonly
61from pyTooling.Warning import Warning, WarningCollector
64__all__ = ["M"]
66TAttr = TypeVar("TAttr") # , bound='Attribute')
67"""A type variable for :class:`~pyTooling.Attributes.Attribute`."""
69TAttributeFilter = Union[TAttr, Iterable[TAttr], None]
70"""A type hint for a predicate parameter that accepts either a single :class:`~pyTooling.Attributes.Attribute` or an
71iterable of those."""
74@export
75class ExtendedTypeError(ToolingException):
76 """The exception is raised by the meta-class :class:`~pyTooling.MetaClasses.ExtendedType`."""
79@export
80class BaseClassWithoutSlotsError(ExtendedTypeError):
81 """
82 This exception is raised when a class using ``__slots__`` inherits from at-least one base-class not using ``__slots__``.
84 .. seealso::
86 :ref:`Python data model for slots <slots>`
87 |rarr| What ``__slots__`` does and which rules Python imposes on it.
88 :term:`Glossary entry __slots__ <__slots__>`
89 |rarr| The glossary's short definition.
90 """
93@export
94class BaseClassWithNonEmptySlotsError(ExtendedTypeError):
95 """
96 This exception is raised when a mixin-class uses slots, but Python prohibits slots.
98 .. important::
100 To fulfill Python's requirements on slots, pyTooling uses slots only on the prinmary inheritance line.
101 Mixin-classes collect slots, which get materialized when the mixin-class (secondary inheritance lines) gets merged
102 into the primary inheritance line.
103 """
106@export
107class BaseClassIsNotAMixinError(ExtendedTypeError):
108 """
109 This exception is raised when a class inherits from a secondary base-class that is not declared as a mixin.
111 Only the primary inheritance line may carry a normal class; every further base-class needs ``mixin=True`` (or the
112 :deco:`~pyTooling.MetaClasses.mixin` decorator), because that is what allows their slots to be merged.
113 """
116@export
117class DuplicateFieldInSlotsError(ExtendedTypeError):
118 """
119 This exception is raised when a slot name is used multiple times within the inheritance hierarchy.
120 """
123@export
124class UnannotatedFieldWarning(Warning):
125 """
126 A class declares a field that was assigned in the class body without a type annotation.
128 An object field is annotated with its type, a class variable with :class:`~typing.ClassVar`. Without an annotation,
129 :class:`ExtendedType` can't tell the two apart, and the field never becomes a slot.
130 """
133@export
134class IncompatibleMetaClassError(ExtendedTypeError):
135 """
136 This exception is raised when a class decorated with :deco:`slotted`, :deco:`mixin` or :deco:`singleton` uses a
137 meta-class that is neither :class:`type` nor derived from :class:`~pyTooling.MetaClasses.ExtendedType`.
138 """
141@export
142class AbstractClassError(ExtendedTypeError):
143 """
144 This exception is raised, when a class contains methods marked with *abstractmethod* or *must-override*.
146 .. seealso::
148 :deco:`~pyTooling.MetaClasses.abstractmethod`
149 |rarr| Mark a method as *abstract*.
150 :deco:`~pyTooling.MetaClasses.mustoverride`
151 |rarr| Mark a method as *must overrride*.
152 :exc:`~MustOverrideClassError`
153 |rarr| Exception raised, if a method is marked as *must-override*.
154 """
157@export
158class MustOverrideClassError(AbstractClassError):
159 """
160 This exception is raised, when a class contains methods marked with *must-override*.
162 .. seealso::
164 :deco:`~pyTooling.MetaClasses.abstractmethod`
165 |rarr| Mark a method as *abstract*.
166 :deco:`~pyTooling.MetaClasses.mustoverride`
167 |rarr| Mark a method as *must overrride*.
168 :exc:`~AbstractClassError`
169 |rarr| Exception raised, if a method is marked as *abstract*.
170 """
173# """
174# Metaclass that allows multiple dispatch of methods based on method signatures.
175#
176# .. seealso:
177#
178# `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>`__
179# """
182M = TypeVar("M", bound=Callable[..., Any]) #: A type variable for methods.
183C = TypeVar("C", bound=type) #: A type variable for classes.
186def _recreateClass(cls: type, decoratorName: str, **options: bool) -> type:
187 """
188 Recreate a class with :class:`ExtendedType` (or the class' own compatible meta-class) applying the given options.
190 :param cls: Class to recreate.
191 :param decoratorName: Name of the calling decorator. It's used in the error message.
192 :param options: Meta-class options like ``slots``, ``mixin`` or ``singleton``.
193 :returns: The recreated class.
194 :raises IncompatibleMetaClassError: If the class' meta-class is neither :class:`type`, nor derived from
195 :class:`ExtendedType`. |br|
196 A decorated class must use :class:`type` or a meta-class derived from
197 :class:`~pyTooling.MetaClasses.ExtendedType`.
198 """
199 if cls.__class__ is type:
200 metacls = ExtendedType
201 elif issubclass(cls.__class__, ExtendedType):
202 metacls = cls.__class__
203 for method in cls.__methods__:
204 delattr(method, "__classobj__")
205 else:
206 metaClass = cls.__class__
207 ex = IncompatibleMetaClassError(f"Class '{cls.__name__}' decorated with '@{decoratorName}' uses an incompatible meta-class.")
208 ex.add_note(f"Meta-class is '{metaClass.__module__}.{metaClass.__name__}'.")
209 ex.add_note(f"A decorated class must use 'type' or a meta-class derived from 'pyTooling.MetaClasses.ExtendedType'.")
210 raise ex
212 bases = tuple(base for base in cls.__bases__ if base is not object)
213 slots = cls.__dict__["__slots__"] if "__slots__" in cls.__dict__ else tuple()
214 members = {
215 "__qualname__": cls.__qualname__
216 }
217 for key, value in cls.__dict__.items():
218 if key not in slots:
219 members[key] = value
221 return metacls(cls.__name__, bases, members, **options)
224@export
225def slotted(cls):
226 """
227 Class decorator recreating a class with slots derived from its annotated fields.
229 It is the decorator form of ``metaclass=ExtendedType, slots=True``, for a class that shouldn't name the
230 meta-class explicitly.
232 :param cls: The class to recreate.
233 :returns: The recreated class, using ``__slots__``.
235 .. seealso::
237 :deco:`~pyTooling.MetaClasses.mixin`
238 |rarr| Recreate a class as a mixin-class.
239 :deco:`~pyTooling.MetaClasses.singleton`
240 |rarr| Recreate a class as a singleton.
241 """
242 return _recreateClass(cls, "slotted", slots=True)
245@export
246def mixin(cls):
247 """
248 Class decorator recreating a class as a mixin-class.
250 A mixin-class collects its slots instead of materializing them; they are merged when the mixin joins a primary
251 inheritance line.
253 :param cls: The class to recreate.
254 :returns: The recreated class, marked as a mixin.
256 .. seealso::
258 :deco:`~pyTooling.MetaClasses.slotted`
259 |rarr| Recreate a class with slots.
260 :deco:`~pyTooling.MetaClasses.singleton`
261 |rarr| Recreate a class as a singleton.
262 """
263 return _recreateClass(cls, "mixin", mixin=True)
266@export
267def singleton(cls):
268 """
269 Class decorator recreating a class as a singleton.
271 Every instantiation of the decorated class returns the same object, including its state.
273 :param cls: The class to recreate.
274 :returns: The recreated class, marked as a singleton.
276 .. seealso::
278 :deco:`~pyTooling.MetaClasses.slotted`
279 |rarr| Recreate a class with slots.
280 :deco:`~pyTooling.MetaClasses.mixin`
281 |rarr| Recreate a class as a mixin-class.
282 """
283 return _recreateClass(cls, "singleton", singleton=True)
286@export
287def abstractclass(cls: C) -> C:
288 """
289 Mark a class as *abstract*, so it cannot be instantiated, although it has no abstract method.
291 Some classes exist only to be derived from - a base-class collecting shared infrastructure, for instance - and
292 have nothing to mark with :deco:`abstractmethod`. This decorator says so directly: it sets ``__abstractClass__``
293 on the class and recomputes ``__isAbstract__``, which replaces ``__new__`` by a method raising an
294 :exc:`~pyTooling.MetaClasses.AbstractClassError`.
296 The marker belongs to the decorated class alone. :class:`ExtendedType` clears it on every class it creates, so a
297 derived class is concrete again unless it is decorated itself or inherits an abstract method.
299 .. warning::
301 This decorator needs meta-class :class:`~pyTooling.MetaClasses.ExtendedType`, which does the computation.
303 .. admonition:: ``example.py``
305 .. code-block:: python
307 @abstractclass
308 class Base(metaclass=ExtendedType):
309 '''This class needs to be inherited.'''
311 :param cls: Class that is marked as *abstract*.
312 :returns: The same class, marked and with its abstractness recomputed.
313 :raises AttributeError: If the class was not created by :class:`ExtendedType`, because nothing would compute it. |br|
314 Add ``metaclass=ExtendedType`` to the class definition, so abstractness is computed.
316 .. seealso::
318 :exc:`~pyTooling.MetaClasses.AbstractClassError`
319 |rarr| The exception raised when a still abstract class gets instantiated.
320 :deco:`~pyTooling.MetaClasses.abstractmethod`
321 |rarr| Mark a method as *abstract* and raise a :exc:`NotImplementedError` when called.
322 :deco:`~pyTooling.MetaClasses.mustoverride`
323 |rarr| Mark a method as *mustoverride* (minimal implementation, but can be called).
324 :deco:`~pyTooling.Decorators.notimplemented`
325 |rarr| Mark a *method* as not implemented and raise a :exc:`NotImplementedError`.
326 """
327 if not isinstance(cls, ExtendedType):
328 ex = AttributeError(f"Class '{cls.__name__}' is not created by meta-class 'ExtendedType'.")
329 ex.add_note(f"Add 'metaclass=ExtendedType' to the class definition, so abstractness is computed.")
330 raise ex
332 cls.__abstractClass__ = True
334 if not cls.__isAbstract__: 334 ↛ 337line 334 didn't jump to line 337 because the condition on line 334 was always true
335 cls.__isAbstract__ = ExtendedType._wrapNewMethodIfAbstract(cls)
337 return cls
340@export
341def abstractmethod(method: M) -> M:
342 """
343 Mark a method as *abstract* and replace the implementation with a new method raising a :exc:`NotImplementedError`.
345 The original method is stored in ``<method>.__wrapped__`` and it's doc-string is copied to the replacing method. In
346 additional field ``<method>.__abstract__`` is added.
348 .. warning::
350 This decorator should be used in combination with meta-class :class:`~pyTooling.MetaClasses.ExtendedType`.
351 Otherwise, an abstract class itself doesn't throw a :exc:`~pyTooling.MetaClasses.AbstractClassError` at
352 instantiation.
354 .. admonition:: ``example.py``
356 .. code-block:: python
358 class Data(mataclass=ExtendedType):
359 @abstractmethod
360 def method(self) -> bool:
361 '''This method needs to be implemented.'''
363 :param method: Method that is marked as *abstract*.
364 :returns: Replacement method, which raises a :exc:`NotImplementedError`.
366 .. seealso::
368 :exc:`~pyTooling.MetaClasses.AbstractClassError`
369 |rarr| The exception raised when a still abstract class gets instantiated.
370 :deco:`~pyTooling.MetaClasses.abstractclass`
371 |rarr| Mark a class as *abstract*.
372 :deco:`~pyTooling.MetaClasses.mustoverride`
373 |rarr| Mark a method as *mustoverride* (minimal implementation, but can be called).
374 :deco:`~pyTooling.Decorators.notimplemented`
375 |rarr| Mark a *method* as not implemented and raise a :exc:`NotImplementedError`.
376 """
377 @wraps(method)
378 def func(self) -> NoReturn:
379 """
380 Replacement method, which raises a :exc:`NotImplementedError` when called.
382 :raises NotImplementedError: Always, because an abstract method has no implementation.
383 """
384 raise NotImplementedError(f"Method '{method.__name__}' is abstract and needs to be overridden in a derived class.")
386 func.__abstract__ = True
387 return func
390@export
391def mustoverride(method: M) -> M:
392 """
393 Mark a method as *must-override*.
395 The returned function is the original function, but with an additional field ``<method>.____mustOverride__``, so a
396 meta-class can identify a *must-override* method and raise an error. Such an error is not raised if the method is
397 overridden by an inheriting class.
399 A *must-override* methods can offer a partial implementation, which is called via ``super()...``.
401 .. warning::
403 This decorator needs to be used in combination with meta-class :class:`~pyTooling.MetaClasses.ExtendedType`.
404 Otherwise, an abstract class itself doesn't throw a :exc:`~pyTooling.MetaClasses.MustOverrideClassError` at
405 instantiation.
407 .. admonition:: ``example.py``
409 .. code-block:: python
411 class Data(mataclass=ExtendedType):
412 @mustoverride
413 def method(self):
414 '''This is a very basic implementation.'''
416 :param method: Method that is marked as *must-override*.
417 :returns: Same method, but with additional ``<method>.__mustOverride__`` field.
419 .. seealso::
421 :exc:`~pyTooling.MetaClasses.MustOverrideClassError`
422 |rarr| The exception raised when a class gets instantiated still containing *mustoverride* methods.
423 :deco:`~pyTooling.MetaClasses.abstractclass`
424 |rarr| Mark a class as *abstract*.
425 :deco:`~pyTooling.MetaClasses.abstractmethod`
426 |rarr| Mark a method as *abstract* and raise a :exc:`NotImplementedError` when called.
427 :deco:`~pyTooling.Decorators.notimplemented`
428 |rarr| Mark a *method* as not implemented and raise a :exc:`NotImplementedError`.
429 """
430 method.__mustOverride__ = True
431 return method
434# @export
435# def overloadable(method: M) -> M:
436# method.__overloadable__ = True
437# return method
440# @export
441# class DispatchableMethod:
442# """Represents a single multimethod."""
443#
444# _methods: dict[Tuple, Callable]
445# __name__: str
446# __slots__ = ("_methods", "__name__")
447#
448# def __init__(self, name: str) -> None:
449# self.__name__ = name
450# self._methods = {}
451#
452# def __call__(self, *args: Any):
453# """Call a method based on type signature of the arguments."""
454# types = tuple(type(arg) for arg in args[1:])
455# meth = self._methods.get(types, None)
456# if meth:
457# return meth(*args)
458# else:
459# raise TypeError(f"No matching method for types {types}.")
460#
461# def __get__(self, instance, cls) -> Self:
462# """Descriptor method needed to make calls work in a class."""
463# if instance is not None:
464# return MethodType(self, instance)
465# else:
466# return self
467#
468# def register(self, method: Callable) -> None:
469# """Register a new method as a dispatchable."""
470#
471# # Build a signature from the method's type annotations
472# sig = signature(method)
473# types: list[Type] = []
474#
475# for name, parameter in sig.parameters.items():
476# if name == "self":
477# continue
478#
479# if parameter.annotation is Parameter.empty:
480# raise TypeError(f"Parameter '{name}' in method '{method.__name__}' must be annotated with a type.")
481#
482# if not isinstance(parameter.annotation, type):
483# raise TypeError(f"Parameter '{name}' in method '{method.__name__}' annotation must be a type.")
484#
485# if parameter.default is not Parameter.empty:
486# self._methods[tuple(types)] = method
487#
488# types.append(parameter.annotation)
489#
490# self._methods[tuple(types)] = method
493# @export
494# class DispatchDictionary(dict):
495# """Special dictionary to build dispatchable methods in a metaclass."""
496#
497# def __setitem__(self, key: str, value: Any):
498# if callable(value) and key in self:
499# # If key already exists, it must be a dispatchable method or callable
500# currentValue = self[key]
501# if isinstance(currentValue, DispatchableMethod):
502# currentValue.register(value)
503# else:
504# dispatchable = DispatchableMethod(key)
505# dispatchable.register(currentValue)
506# dispatchable.register(value)
507#
508# super().__setitem__(key, dispatchable)
509# else:
510# super().__setitem__(key, value)
513@export
514class ExtendedType(type):
515 """
516 An updates meta-class to construct new classes with an extended feature set.
518 .. todo:: META::ExtendedType Needs documentation.
519 .. todo:: META::ExtendedType allow __dict__ and __weakref__ if slotted is enabled
521 .. rubric:: Features:
523 * Store object members more efficiently in ``__slots__`` instead of ``_dict__``.
525 * Implement ``__slots__`` only on primary inheritance line.
526 * Collect class variables on secondary inheritance lines (mixin-classes) and defer implementation as ``__slots__``.
527 * Handle object state exporting and importing for slots (:mod:`pickle` support) via ``__getstate__``/``__setstate__``.
529 * Allow only a single instance to be created (:term:`singleton`). |br|
530 Further instantiations will return the previously create instance (identical object).
531 * Define methods as :term:`abstract <abstract method>` or :term:`must-override <mustoverride method>` and prohibit
532 instantiation of :term:`abstract classes <abstract class>`.
534 .. #* Allow method overloading and dispatch overloads based on argument signatures.
536 .. rubric:: Added class fields:
538 :__slotted__: True, if class uses `__slots__`.
539 :__allSlots__: Set of class fields stored in slots for all classes in the inheritance hierarchy.
540 :__slots__: Tuple of class fields stored in slots for current class in the inheritance hierarchy. |br|
541 See :pep:`253` for details.
542 :__isMixin__: True, if class is a mixin-class
543 :__mixinSlots__: List of collected slots from secondary inheritance hierarchy (mixin hierarchy).
544 :__methods__: List of methods.
545 :__methodsWithAttributes__: List of methods with pyTooling attributes.
546 :__abstractMethods__: List of abstract methods, which need to be implemented in the next class hierarchy levels.
547 :__abstractClass__: True, if this class was decorated with :deco:`abstractclass`.
548 :__isAbstract__: True, if class is abstract.
549 :__isSingleton__: True, if class is a singleton
550 :__singletonInstanceCond__: Condition variable to protect the singleton creation.
551 :__singletonInstanceInit__: Singleton is initialized.
552 :__singletonInstanceCache__: The singleton object, once created.
553 :__pyattr__: List of class attributes.
555 .. rubric:: Added class properties:
557 :HasClassAttributes: Read-only property to check if the class has Attributes.
558 :HasMethodAttributes: Read-only property to check if the class has methods with Attributes.
560 .. rubric:: Added methods:
562 If slots are used, the following methods are added to support :mod:`pickle`:
564 :__getstate__: Export an object's state for serialization. |br|
565 See :pep:`307` for details.
566 :__setstate__: Import an object's state for deserialization. |br|
567 See :pep:`307` for details.
569 .. rubric:: Modified ``__new__`` method:
571 If class is a singleton, ``__new__`` will be replaced by a wrapper method. This wrapper is marked with ``__singleton_wrapper__``.
573 If class is abstract, ``__new__`` will be replaced by a method raising an exception. This replacement is marked with ``__raises_abstract_class_error__``.
575 .. rubric:: Modified ``__init__`` method:
577 If class is a singleton, ``__init__`` will be replaced by a wrapper method. This wrapper is marked by ``__singleton_wrapper__``.
579 .. rubric:: Modified abstract methods:
581 If a method is abstract, its marked with ``__abstract__``. |br|
582 If a method is must override, its marked with ``__mustOverride__``.
583 """
585 # @classmethod
586 # def __prepare__(cls, className, baseClasses, slots: bool = False, mixin: bool = False, singleton: bool = False):
587 # return DispatchDictionary()
589 def __new__(
590 self,
591 className: str,
592 baseClasses: tuple[type],
593 members: dict[str, Any],
594 slots: bool = False,
595 mixin: bool = False,
596 singleton: bool = False,
597 **kwargs: Any
598 ) -> Self:
599 """
600 Construct a new class using this :term:`meta-class`.
602 :param className: The name of the class to construct.
603 :param baseClasses: The tuple of :term:`base-classes <base-class>` the class is derived from.
604 :param members: The dictionary of members for the constructed class.
605 :param slots: Optional, if ``True``, store object attributes in :term:`__slots__ <slots>` instead of
606 ``__dict__``.
607 :param mixin: Optional, if ``True``, make the class a :term:`Mixin-Class`. If ``False``, create slots if
608 ``slots``
609 is true. If ``None``, preserve behavior of primary base-class.
610 :param singleton: Optional, if ``True``, make the class a :term:`Singleton`.
611 :param kwargs: Any further class keyword argument, forwarded to :meth:`~object.__init_subclass__` as
612 :func:`type` does.
613 :returns: The new class.
614 :raises AttributeError: If base-class has no '__slots__' attribute.
615 :raises AttributeError: If slot already exists in base-class.
616 """
617 from pyTooling.Attributes import ATTRIBUTES_MEMBER_NAME, AttributeScope
619 # Inherit 'slots' feature from primary base-class
620 if len(baseClasses) > 0:
621 primaryBaseClass = baseClasses[0]
622 if isinstance(primaryBaseClass, self):
623 slots = primaryBaseClass.__slotted__
625 # Compute slots and mixin-slots from annotated fields as well as class- and object-fields with initial values.
626 classFields, objectFields = self._computeSlots(className, baseClasses, members, slots, mixin)
628 # Compute abstract methods
629 abstractMethods, members = self._checkForAbstractMethods(baseClasses, members)
631 # Create a new class - the remaining keyword arguments belong to '__init_subclass__', which 'type' calls
632 newClass = type.__new__(self, className, baseClasses, members, **kwargs)
634 # Apply class fields
635 for fieldName, typeAnnotation in classFields.items():
636 setattr(newClass, fieldName, typeAnnotation)
638 # Search in inheritance tree for abstract methods
639 newClass.__abstractMethods__ = abstractMethods
641 newClass.__abstractClass__ = False
642 newClass.__isAbstract__ = self._wrapNewMethodIfAbstract(newClass)
643 newClass.__isSingleton__ = self._wrapNewMethodIfSingleton(newClass, singleton)
645 if slots:
646 # If slots are used, implement __getstate__/__setstate__ API to support serialization using pickle.
647 if "__getstate__" not in members:
648 def __getstate__(self) -> dict[str, Any]:
649 """
650 Return the object's state for pickling, collecting every slot of the class hierarchy.
652 :returns: Dictionary of slot names and their values.
653 :raises ExtendedTypeError: If a slot was never assigned, so it has no value to serialize.
654 """
655 try:
656 return {slotName: getattr(self, slotName) for slotName in self.__allSlots__}
657 except AttributeError as ex:
658 raise ExtendedTypeError(f"Unassigned field '{ex.name}' in object '{self}' of type '{self.__class__.__name__}'.") from ex
660 newClass.__getstate__ = __getstate__
662 if "__setstate__" not in members:
663 def __setstate__(self, state: dict[str, Any]) -> None:
664 """
665 Restore the object's state from unpickling, requiring exactly the slots of the class hierarchy.
667 :param state: Dictionary of slot names and their values.
668 :raises ExtendedTypeError: If the given state misses a slot or carries an unexpected one.
669 """
670 if self.__allSlots__ != (slots := set(state.keys())):
671 if len(diff := self.__allSlots__.difference(slots)) > 0:
672 raise ExtendedTypeError(f"""Missing fields in parameter 'state': '{"', '".join(diff)}'""") # WORKAROUND: Python <3.12
673 else:
674 diff = slots.difference(self.__allSlots__)
675 raise ExtendedTypeError(f"""Unexpected fields in parameter 'state': '{"', '".join(diff)}'""") # WORKAROUND: Python <3.12
677 for slotName, value in state.items():
678 setattr(self, slotName, value)
680 newClass.__setstate__ = __setstate__
682 # Check for inherited class attributes
683 attributes: list[Attribute] = []
684 setattr(newClass, ATTRIBUTES_MEMBER_NAME, attributes)
685 for base in baseClasses:
686 if hasattr(base, ATTRIBUTES_MEMBER_NAME):
687 pyAttr = getattr(base, ATTRIBUTES_MEMBER_NAME)
688 for att in pyAttr:
689 if AttributeScope.Class in att.Scope: 689 ↛ 688line 689 didn't jump to line 688 because the condition on line 689 was always true
690 attributes.append(att)
691 att.__class__._classes.append(newClass)
693 # Check methods for attributes
694 methods, methodsWithAttributes = self._findMethods(newClass, baseClasses, members)
696 # Add new fields for found methods
697 newClass.__methods__ = tuple(methods)
698 newClass.__methodsWithAttributes__ = tuple(methodsWithAttributes)
700 # Additional methods on a class
701 def GetMethodsWithAttributes(
702 self,
703 predicate: Nullable[TAttributeFilter[TAttr]] = None
704 ) -> dict[Callable[..., Any], tuple[Attribute, ...]]:
705 """
706 Return the class' methods that carry at least one matching attribute.
708 :param predicate: Optional, an attribute class, an iterable of attribute classes, or ``None`` to accept every
709 attribute.
710 :returns: Dictionary of methods and the matching attributes attached to them.
711 :raises ValueError: If an element of parameter 'predicate' is not a sub-class of :class:`~pyTooling.Attributes.Attribute`.
712 :raises ValueError: If parameter 'predicate' is neither an attribute class nor an iterable of those.
713 """
714 from pyTooling.Attributes import Attribute
716 if predicate is None:
717 predicate = Attribute
718 elif isinstance(predicate, Iterable): 718 ↛ 719line 718 didn't jump to line 719 because the condition on line 718 was never true
719 for attribute in predicate:
720 if not issubclass(attribute, Attribute):
721 raise ValueError(f"Parameter 'predicate' contains an element which is not a sub-class of 'Attribute'.")
723 predicate = tuple(predicate)
724 elif not issubclass(predicate, Attribute): 724 ↛ 725line 724 didn't jump to line 725 because the condition on line 724 was never true
725 raise ValueError(f"Parameter 'predicate' is not a sub-class of 'Attribute'.")
727 methodAttributePairs = {}
728 for method in newClass.__methodsWithAttributes__:
729 matchingAttributes: list[Attribute] = []
730 for attribute in method.__pyattr__:
731 if isinstance(attribute, predicate):
732 matchingAttributes.append(attribute)
734 if len(matchingAttributes) > 0:
735 methodAttributePairs[method] = tuple(matchingAttributes)
737 return methodAttributePairs
739 newClass.GetMethodsWithAttributes = classmethod(GetMethodsWithAttributes)
740 GetMethodsWithAttributes.__qualname__ = f"{className}.{GetMethodsWithAttributes.__name__}"
742 # GetMethods(predicate) -> dict[method, list[attribute]] / generator
743 # GetClassAtrributes -> list[attributes] / generator
744 # MethodHasAttributes(predicate) -> bool
745 # GetAttribute
747 return newClass
749 @classmethod
750 def _findMethods(
751 self,
752 newClass: ExtendedType,
753 baseClasses: tuple[type],
754 members: dict[str, Any]
755 ) -> tuple[list[MethodType], list[MethodType]]:
756 """
757 Find methods and methods with :mod:`pyTooling.Attributes`.
759 .. todo::
761 Describe algorithm.
763 :param newClass: Newly created class instance.
764 :param baseClasses: The tuple of :term:`base-classes <base-class>` the class is derived from.
765 :param members: Members of the new class.
766 :returns: A 2-tuple of all methods and those methods carrying at least one attribute.
767 :raises TypeError: If a member is neither a method nor a class, so it can't be searched for methods.
768 """
769 from pyTooling.Attributes import Attribute
771 # Embedded bind function due to circular dependencies.
772 def bind(instance: object, func: FunctionType, methodName: Nullable[str] = None):
773 """
774 Nested function binding a function to an object as a method.
776 It exists here rather than in :mod:`pyTooling.Common`, because importing that module would be circular.
778 :param instance: The object the function is bound to.
779 :param func: The function to bind.
780 :param methodName: Optional, name of the method; by default the function's own name.
781 :returns: The bound method.
782 """
783 if methodName is None: 783 ↛ 786line 783 didn't jump to line 786 because the condition on line 783 was always true
784 methodName = func.__name__
786 boundMethod = func.__get__(instance, instance.__class__)
787 setattr(instance, methodName, boundMethod)
789 return boundMethod
791 methods = []
792 methodsWithAttributes = []
793 attributeIndex = {}
795 for base in baseClasses:
796 if hasattr(base, "__methodsWithAttributes__"):
797 methodsWithAttributes.extend(base.__methodsWithAttributes__)
799 for memberName, member in members.items():
800 if isinstance(member, FunctionType):
801 method = newClass.__dict__[memberName]
802 if hasattr(method, "__classobj__") and getattr(method, "__classobj__") is not newClass: 802 ↛ 803line 802 didn't jump to line 803 because the condition on line 802 was never true
803 raise TypeError(f"Method '{memberName}' is used by multiple classes: {method.__classobj__} and {newClass}.")
804 else:
805 setattr(method, "__classobj__", newClass)
807 def GetAttributes(inst: Any, predicate: Nullable[type[Attribute]] = None) -> tuple[Attribute, ...]:
808 """
809 Nested function attached to the class, returning the attributes of one of its methods.
811 :param inst: The method to read the attributes from.
812 :param predicate: Optional, an attribute class, or ``None`` to accept every attribute.
813 :returns: Tuple of the matching attributes.
814 """
815 results = []
816 try:
817 for attribute in inst.__pyattr__: # type: Attribute
818 if isinstance(attribute, predicate):
819 results.append(attribute)
820 return tuple(results)
821 except AttributeError:
822 return tuple()
824 method.GetAttributes = bind(method, GetAttributes)
825 methods.append(method)
827 # print(f" convert function: '{memberName}' to method")
828 # print(f" {member}")
829 if "__pyattr__" in member.__dict__:
830 attributes = member.__pyattr__ # type: list[Attribute]
831 if isinstance(attributes, list) and len(attributes) > 0: 831 ↛ 799line 831 didn't jump to line 799 because the condition on line 831 was always true
832 methodsWithAttributes.append(member)
833 for attribute in attributes:
834 attribute._functions.remove(method)
835 attribute._methods.append(method)
837 # print(f" attributes: {attribute.__class__.__name__}")
838 if attribute not in attributeIndex: 838 ↛ 841line 838 didn't jump to line 841 because the condition on line 838 was always true
839 attributeIndex[attribute] = [member]
840 else:
841 attributeIndex[attribute].append(member)
842 # else:
843 # print(f" But has no attributes.")
844 # else:
845 # print(f" ?? {memberName}")
846 return methods, methodsWithAttributes
848 @classmethod
849 def _getAnnotations(metacls, members: dict[str, Any]) -> dict[str, Any]:
850 """
851 Return the type annotations declared in a class body.
853 .. important::
855 Python 3.14 (:pep:`649`) no longer fills ``__annotations__`` while the class body is executed, but installs an
856 ``__annotate_func__`` instead. The :mod:`annotationlib` module needed to evaluate that function doesn't exist on
857 older Python versions, therefore both mechanisms are supported.
859 :param members: Dictionary of class members.
860 :returns: Dictionary of annotated field names and their type annotations. Empty, if the class body declared
861 no annotations.
862 """
863 if "__annotations__" in members:
864 # WORKAROUND: LEGACY SUPPORT Python <= 3.13
865 # Accessing annotations was changed in Python 3.14.
866 # The necessary 'annotationlib' is not available for older Python versions.
867 annotations = members["__annotations__"]
868 elif version_info >= (3, 14) and (annotate := members.get("__annotate_func__", None)) is not None:
869 from annotationlib import Format
870 try:
871 annotations = annotate(Format.VALUE)
872 except NameError:
873 # A forward reference the class body cannot resolve yet - PEP 649 offers the source text instead.
874 annotations = annotate(Format.STRING)
875 else:
876 return {}
878 # 'from __future__ import annotations' (PEP 563) makes every annotation a string, and so does the fallback
879 # above. Resolve what can be resolved, so a 'ClassVar' is still recognized as one.
880 return {
881 name: metacls._resolveAnnotation(typeAnnotation, members)
882 for name, typeAnnotation in annotations.items()
883 }
885 #: Matches a textual annotation denoting a :class:`~typing.ClassVar`, with or without a module qualifier.
886 _CLASS_VARIABLE_PATTERN = re_compile(r"^\s*(?:\w+\.)*ClassVar\s*(?:\[|$)")
888 @classmethod
889 def _resolveAnnotation(metacls, typeAnnotation: Any, members: dict[str, Any]) -> Any:
890 """
891 Evaluate a postponed (string) annotation, so it can be inspected like an ordinary one.
893 :pep:`563` - ``from __future__ import annotations`` - turns **every** annotation in a module into a string, and
894 :pep:`649` does the same for an annotation :mod:`annotationlib` cannot evaluate yet. A string tells this
895 meta-class nothing: a ``ClassVar`` reads as ``"ClassVar[int]"`` and would silently become a slot.
897 The annotation is evaluated in the defining module's namespace plus the class body itself. A name that cannot be
898 resolved - typically a forward reference to the class being created right now - is **returned unchanged**, which
899 is harmless: the textual fallback in :meth:`_isClassVariable` still classifies it, and nothing else needs the
900 type object.
902 :param typeAnnotation: The annotation to resolve; returned unchanged when it is not a string.
903 :param members: Dictionary of class members, used as the local namespace.
904 :returns: The evaluated annotation, or the original string when it cannot be evaluated.
905 """
906 if not isinstance(typeAnnotation, str):
907 return typeAnnotation
909 module = modules.get(members.get("__module__", ""), None)
910 try:
911 # The annotation is source code written in the class being created - the same trust level as importing it.
912 return eval(typeAnnotation, getattr(module, "__dict__", {}), members)
913 except Exception: # noqa: BLE001 - any failure means "keep the string", see above
914 return typeAnnotation
916 @classmethod
917 def _isClassVariable(metacls, typeAnnotation: Any) -> bool:
918 """
919 Check if a type annotation declares a class variable.
921 Both forms are recognized: the evaluated :class:`~typing.ClassVar` and its textual form, which is what
922 ``from __future__ import annotations`` leaves behind when the annotation cannot be evaluated.
924 :param typeAnnotation: The type annotation to check.
925 :returns: ``True``, if the annotation is a :class:`~typing.ClassVar`.
926 """
927 if isinstance(typeAnnotation, str):
928 return metacls._CLASS_VARIABLE_PATTERN.match(typeAnnotation) is not None
930 return isinstance(typeAnnotation, _GenericAlias) and typeAnnotation.__origin__ is ClassVar
932 @classmethod
933 def _isField(metacls, member: Any) -> bool:
934 """
935 Check if a class member is a field, so a type annotation is expected for it.
937 Methods, nested classes, properties and any other descriptor carry their type information in their signature or
938 in their own declaration, therefore they aren't fields.
940 :param member: The class member to check.
941 :returns: ``True``, if the member is a field.
942 """
943 if isinstance(member, (FunctionType, MethodType, BuiltinFunctionType, classmethod, staticmethod, property, type)):
944 return False
946 # Any descriptor (e.g. a custom property implementation) declares its own type information.
947 return not (hasattr(member, "__get__") or hasattr(member, "__set__"))
949 @classmethod
950 def _checkForUnannotatedFields(metacls, className: str, members: dict[str, Any], annotations: dict[str, Any]) -> None:
951 """
952 Report fields that were assigned in the class body without a type annotation.
954 Every field should carry type information: an object field is annotated with its type, a class variable with
955 ``ClassVar[...]``. Without an annotation, :class:`ExtendedType` can't tell the two apart - an un-annotated
956 assignment never becomes a slot and silently stays a class attribute.
958 .. important::
960 This is reported as a :class:`~pyTooling.Warning.Warning`, so it needs a
961 :class:`~pyTooling.Warning.WarningCollector` somewhere up the call-hierarchy to be observed. Importing a module
962 with un-annotated fields doesn't fail.
964 :param className: The name of the class to construct.
965 :param members: Dictionary of class members.
966 :param annotations: Dictionary of annotated field names and their type annotations.
967 """
968 unannotatedFields = [
969 fieldName
970 for fieldName, member in members.items()
971 if not (fieldName.startswith("__") and fieldName.endswith("__"))
972 and fieldName not in annotations
973 and metacls._isField(member)
974 ]
976 if len(unannotatedFields) > 0:
977 fieldNames = "', '".join(unannotatedFields)
978 WarningCollector.Raise(
979 UnannotatedFieldWarning(f"Class '{className}' declares {len(unannotatedFields)} field(s) without a type annotation."),
980 notes=(
981 f"Field(s) without a type annotation: '{fieldNames}'.",
982 f"Annotate a class variable as 'ClassVar[...]' or an object field with its type.",
983 )
984 )
986 @classmethod
987 def _computeSlots(
988 self,
989 className: str,
990 baseClasses: tuple[type],
991 members: dict[str, Any],
992 slots: bool,
993 mixin: bool
994 ) -> tuple[dict[str, Any], dict[str, Any]]:
995 """
996 Compute which field are listed in __slots__ and which need to be initialized in an instance or class.
998 .. todo::
1000 Describe algorithm.
1002 :param className: The name of the class to construct.
1003 :param baseClasses: Tuple of base-classes.
1004 :param members: Dictionary of class members.
1005 :param slots: Optional, ``True``, if the class should setup ``__slots__``.
1006 :param mixin: Optional, ``True``, if the class should behave as a mixin-class.
1007 :returns: A 2-tuple with a dictionary of class members and object members.
1008 :raises AttributeError: If a field's annotation refers to a name that can't be resolved. |br|
1009 An assignment without a type annotation creates a class attribute, which
1010 hides the slot's descriptor: reading the field works, but assigning it on an
1011 instance raises. Annotate it as ``ClassVar[...]`` to declare a class
1012 variable, or remove the assignment. A slot contributed by a mixin-class is
1013 materialized in this class' ``__slots__``, and Python doesn't allow such a
1014 name to be assigned in the class body.
1015 :raises BaseClassWithoutSlotsError: If a base-class doesn't use slots. |br|
1016 All base-classes of a class using ``__slots__`` must use ``__slots__``
1017 themselves.
1018 """
1019 # Compute which field are listed in __slots__ and which need to be initialized in an instance or class.
1020 slottedFields = []
1021 classFields = {}
1022 objectFields = {}
1023 annotations: dict[str, Any] = self._getAnnotations(members)
1024 if slots or mixin:
1025 # If slots are used, all base classes must use __slots__.
1026 for baseClass in self._iterateBaseClasses(baseClasses):
1027 # Exclude object as a special case
1028 if baseClass is object or baseClass is Generic:
1029 continue
1031 if not hasattr(baseClass, "__slots__"):
1032 ex = BaseClassWithoutSlotsError(f"Base-classes '{baseClass.__name__}' doesn't use '__slots__'.")
1033 ex.add_note(f"All base-classes of a class using '__slots__' must use '__slots__' itself.")
1034 raise ex
1036 # Non-empty __slots__ on secondary base-classes are rejected by _aggregateMixinSlots below.
1038 # Copy all field names from primary base-class' __slots__, which are later needed for error checking.
1039 inheritedSlottedFields = {}
1040 if len(baseClasses) > 0:
1041 for base in reversed(baseClasses[0].mro()):
1042 # Exclude object as a special case
1043 if base is object or base is Generic:
1044 continue
1046 for annotation in base.__slots__:
1047 inheritedSlottedFields[annotation] = base
1049 # When adding annotated fields to slottedFields, check if name was not used in inheritance hierarchy.
1050 for fieldName, typeAnnotation in annotations.items():
1051 if fieldName in inheritedSlottedFields: 1051 ↛ 1052line 1051 didn't jump to line 1052 because the condition on line 1051 was never true
1052 cls = inheritedSlottedFields[fieldName]
1053 raise AttributeError(f"Slot '{fieldName}' declared in class '{className}' already exists in base-class '{cls.__module__}.{cls.__name__}'.")
1055 # A ClassVar is never a slot, with or without an initial value.
1056 # * If it has an initial value, copy field and initial value to classFields dictionary and remove field from members.
1057 # * Otherwise it's a forward declaration and derived classes assign the actual value.
1058 isClassVariable = self._isClassVariable(typeAnnotation)
1059 hasInitialValue = fieldName in members
1060 if isClassVariable:
1061 if hasInitialValue:
1062 classFields[fieldName] = members[fieldName]
1063 del members[fieldName]
1065 # If an annotated field has an initial value
1066 # * copy field and initial value to objectFields dictionary
1067 # * remove field from members
1068 elif hasInitialValue:
1069 slottedFields.append(fieldName)
1070 objectFields[fieldName] = members[fieldName]
1071 del members[fieldName]
1072 else:
1073 slottedFields.append(fieldName)
1075 mixinSlots = self._aggregateMixinSlots(className, baseClasses)
1077 # A member assigned in the class body without a type annotation stays a class attribute. If it carries the name
1078 # of a slot, that class attribute shadows the slot's descriptor and the field becomes read-only on instances.
1079 # Report it here instead of letting the first assignment fail with a bare AttributeError.
1080 shadowedSlots = {**inheritedSlottedFields, **{fieldName: None for fieldName in mixinSlots}}
1081 for fieldName in shadowedSlots.keys() & members.keys():
1082 ex = DuplicateFieldInSlotsError(f"Slot '{fieldName}' is shadowed by a class member in class '{className}'.")
1083 if (baseClass := shadowedSlots[fieldName]) is not None:
1084 ex.add_note(f"Slot '{fieldName}' is declared in base-class '{baseClass.__module__}.{baseClass.__name__}'.")
1085 ex.add_note(f"An assignment without a type annotation creates a class attribute, which hides the slot's descriptor.")
1086 ex.add_note(f"Reading the field works, but assigning it on an instance raises an AttributeError.")
1087 else:
1088 ex.add_note(f"Slot '{fieldName}' is contributed by a mixin-class and materialized in this class' '__slots__'.")
1089 ex.add_note(f"Python doesn't allow a name to be listed in '__slots__' and assigned in the class body.")
1090 ex.add_note(f"Annotate it as 'ClassVar[...]' to declare a class variable, or remove the assignment.")
1091 raise ex
1092 else:
1093 # When adding annotated fields to slottedFields, check if name was not used in inheritance hierarchy.
1094 for fieldName, typeAnnotation in annotations.items():
1095 # If annotated field is a ClassVar, and it has an initial value
1096 # * copy field and initial value to classFields dictionary
1097 # * remove field from members
1098 if self._isClassVariable(typeAnnotation) and fieldName in members:
1099 classFields[fieldName] = members[fieldName]
1100 del members[fieldName]
1102 self._checkForUnannotatedFields(className, members, annotations)
1104 if mixin:
1105 mixinSlots.extend(slottedFields)
1106 members["__slotted__"] = True
1107 members["__slots__"] = tuple()
1108 members["__allSlots__"] = set()
1109 members["__isMixin__"] = True
1110 members["__mixinSlots__"] = tuple(mixinSlots)
1111 elif slots:
1112 slottedFields.extend(mixinSlots)
1113 members["__slotted__"] = True
1114 members["__slots__"] = tuple(slottedFields)
1115 members["__allSlots__"] = set(chain(slottedFields, inheritedSlottedFields.keys()))
1116 members["__isMixin__"] = False
1117 members["__mixinSlots__"] = tuple()
1118 else:
1119 members["__slotted__"] = False
1120 # NO __slots__
1121 # members["__allSlots__"] = set()
1122 members["__isMixin__"] = False
1123 members["__mixinSlots__"] = tuple()
1124 return classFields, objectFields
1126 @classmethod
1127 def _aggregateMixinSlots(self, className: str, baseClasses: tuple[type]) -> list[str]:
1128 """
1129 Aggregate slot names requested by mixin-base-classes.
1131 .. todo::
1133 Describe algorithm.
1135 :param className: The name of the class to construct.
1136 :param baseClasses: The tuple of :term:`base-classes <base-class>` the class is derived from.
1137 :returns: A list of slot names.
1138 :raises BaseClassWithNonEmptySlotsError: If a mixin-class uses non-empty slots. |br|
1139 In Python, only one inheritance branch can use non-empty ``__slots__``.
1140 """
1141 mixinSlots = []
1142 if len(baseClasses) > 0:
1143 # If class has base-classes ensure only the primary inheritance path uses slots and all secondary inheritance
1144 # paths have an empty slots tuple. Otherwise, raise a BaseClassWithNonEmptySlotsError.
1145 inheritancePaths = [path for path in self._iterateBaseClassPaths(baseClasses)]
1146 primaryInharitancePath: set[type] = set(inheritancePaths[0])
1147 for typePath in inheritancePaths[1:]:
1148 for t in typePath:
1149 if hasattr(t, "__slots__") and len(t.__slots__) != 0 and t not in primaryInharitancePath:
1150 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}'.")
1151 ex.add_note(f"In Python, only one inheritance branch can use non-empty __slots__.")
1152 # ex.add_note(f"With ExtendedType, only the primary base-class can use non-empty __slots__.")
1153 # ex.add_note(f"Secondary base-classes should be marked as mixin-classes.")
1154 raise ex
1156 # If current class is set to be a mixin, then aggregate all mixinSlots in a list.
1157 # Ensure all base-classes are either constructed
1158 # * by meta-class ExtendedType, or
1159 # * use no slots, or
1160 # * are typing.Generic
1161 # If it was constructed by ExtendedType, then ensure this class itself is a mixin-class.
1162 for baseClass in baseClasses: # type: ExtendedType
1163 if isinstance(baseClass, _GenericAlias) and baseClass.__origin__ is Generic: 1163 ↛ 1164line 1163 didn't jump to line 1164 because the condition on line 1163 was never true
1164 pass
1165 elif baseClass.__class__ is self and baseClass.__isMixin__:
1166 mixinSlots.extend(baseClass.__mixinSlots__)
1167 elif hasattr(baseClass, "__mixinSlots__"):
1168 mixinSlots.extend(baseClass.__mixinSlots__)
1170 return mixinSlots
1172 @classmethod
1173 def _iterateBaseClasses(metacls, baseClasses: tuple[type]) -> Generator[type, None, None]:
1174 """
1175 Return a generator to iterate (visit) all base-classes ...
1177 .. todo::
1179 Describe iteration order.
1181 :param baseClasses: The tuple of :term:`base-classes <base-class>` the class is derived from.
1182 :returns: Generator to iterate all base-classes.
1183 """
1184 if len(baseClasses) == 0:
1185 return
1187 visited: set[type] = set()
1188 iteratorStack: list[Iterator[type]] = list()
1190 for baseClass in baseClasses:
1191 yield baseClass
1192 visited.add(baseClass)
1193 iteratorStack.append(iter(baseClass.__bases__))
1195 while True:
1196 try:
1197 base = next(iteratorStack[-1]) # type: type
1198 if base not in visited: 1198 ↛ 1203line 1198 didn't jump to line 1203 because the condition on line 1198 was always true
1199 yield base
1200 if len(base.__bases__) > 0:
1201 iteratorStack.append(iter(base.__bases__))
1202 else:
1203 continue
1205 except StopIteration:
1206 iteratorStack.pop()
1208 if len(iteratorStack) == 0:
1209 break
1211 @classmethod
1212 def _iterateBaseClassPaths(metacls, baseClasses: tuple[type]) -> Generator[tuple[type, ...], None, None]:
1213 """
1214 Return a generator to iterate all possible inheritance paths for a given list of base-classes.
1216 An inheritance path is expressed as a tuple of base-classes from current base-class (left-most item) to
1217 :class:`object` (right-most item).
1219 :param baseClasses: The tuple of :term:`base-classes <base-class>` the class is derived from.
1220 :returns: Generator to iterate all inheritance paths. |br|
1221 An inheritance path is a tuple of types (base-classes).
1222 """
1223 if len(baseClasses) == 0: 1223 ↛ 1224line 1223 didn't jump to line 1224 because the condition on line 1223 was never true
1224 return
1226 typeStack: list[type] = list()
1227 iteratorStack: list[Iterator[type]] = list()
1229 for baseClass in baseClasses:
1230 typeStack.append(baseClass)
1231 iteratorStack.append(iter(baseClass.__bases__))
1233 while True:
1234 try:
1235 base = next(iteratorStack[-1]) # type: type
1236 typeStack.append(base)
1237 if len(base.__bases__) == 0:
1238 yield tuple(typeStack)
1239 typeStack.pop()
1240 else:
1241 iteratorStack.append(iter(base.__bases__))
1243 except StopIteration:
1244 typeStack.pop()
1245 iteratorStack.pop()
1247 if len(typeStack) == 0:
1248 break
1250 @classmethod
1251 def _checkForAbstractMethods(
1252 metacls,
1253 baseClasses: tuple[type],
1254 members: dict[str, Any]
1255 ) -> tuple[dict[str, Callable[..., Any]], dict[str, Any]]:
1256 """
1257 Check if the current class contains abstract methods and return a tuple of them.
1259 These abstract methods might be inherited from any base-class. If there are inherited abstract methods, check if
1260 they are now implemented (overridden) by the current class that's right now constructed.
1262 :param baseClasses: The tuple of :term:`base-classes <base-class>` the class is derived from.
1263 :param members: The dictionary of members for the constructed class.
1264 :returns: A tuple of abstract method's names.
1265 """
1266 abstractMethods = {}
1267 if baseClasses:
1268 # Aggregate all abstract methods from all base-classes.
1269 for baseClass in baseClasses:
1270 if hasattr(baseClass, "__abstractMethods__"):
1271 abstractMethods.update(baseClass.__abstractMethods__)
1273 for base in baseClasses:
1274 for memberName, member in base.__dict__.items():
1275 # A method the new class defines itself is the implementation; it must not be replaced by the one an
1276 # inheritance branch happens to carry (pyTooling #297).
1277 if memberName in members:
1278 continue
1280 if (memberName in abstractMethods and isinstance(member, FunctionType) and
1281 not (hasattr(member, "__abstract__") or hasattr(member, "__mustOverride__"))):
1282 def outer(method):
1283 """
1284 Nested function creating a wrapper, so the abstract method of the base-class isn't modified itself.
1286 :param method: The inherited abstract method.
1287 :returns: A wrapper forwarding to that method.
1288 """
1289 @wraps(method)
1290 def inner(cls, *args: Any, **kwargs: Any):
1291 """
1292 Wrapper forwarding to the inherited abstract method.
1294 :param cls: The class the method is called on.
1295 :param args: Positional parameters passed to the method.
1296 :param kwargs: Named parameters passed to the method.
1297 :returns: Whatever the wrapped method returns.
1298 """
1299 return method(cls, *args, **kwargs)
1301 # ':func:`~functools.wraps` copies the wrapped function's '__dict__', which carries the
1302 # bookkeeping ExtendedType attached to it. The wrapper belongs to the class being
1303 # constructed, so the owner is dropped and '_findMethods' assigns the new one.
1304 inner.__dict__.pop("__classobj__", None)
1306 return inner
1308 members[memberName] = outer(member)
1310 # Check if methods are marked:
1311 # * If so, add them to list of abstract methods
1312 # * If not, method is now implemented and removed from list
1313 for memberName, member in members.items():
1314 if callable(member):
1315 if ((hasattr(member, "__abstract__") and member.__abstract__) or
1316 (hasattr(member, "__mustOverride__") and member.__mustOverride__)):
1317 abstractMethods[memberName] = member
1318 elif memberName in abstractMethods:
1319 del abstractMethods[memberName]
1321 return abstractMethods, members
1323 @classmethod
1324 def _wrapNewMethodIfSingleton(metacls, newClass, singleton: bool) -> bool:
1325 """
1326 If a class is a singleton, wrap the ``_new__`` method, so it returns a cached object, if a first object was created.
1328 Only the first object creation initializes the object.
1330 This implementation is threadsafe.
1332 :param newClass: The newly constructed class for further modifications.
1333 :param singleton: Optional, if ``True``, the class allows only a single instance to exist.
1334 :returns: ``True``, if the class is a singleton.
1335 """
1336 if hasattr(newClass, "__isSingleton__"):
1337 singleton = newClass.__isSingleton__
1339 if singleton:
1340 oldnew = newClass.__new__
1341 if hasattr(oldnew, "__singleton_wrapper__"):
1342 oldnew = oldnew.__wrapped__
1344 oldinit = newClass.__init__
1345 if hasattr(oldinit, "__singleton_wrapper__"):
1346 oldinit = oldinit.__wrapped__
1348 @wraps(oldnew)
1349 def singleton_new(cls, *args: Any, **kwargs: Any):
1350 """
1351 Replacement ``__new__`` method, which returns the singleton's one instance.
1353 The first call creates the object and caches it; every further call returns the cached object. The
1354 condition variable makes that safe when several threads instantiate the class at once.
1356 :param cls: The class being instantiated.
1357 :param args: Positional parameters passed to the original ``__new__``.
1358 :param kwargs: Named parameters passed to the original ``__new__``.
1359 :returns: The singleton's instance.
1360 """
1361 with cls.__singletonInstanceCond__:
1362 if cls.__singletonInstanceCache__ is None:
1363 obj = oldnew(cls, *args, **kwargs)
1364 cls.__singletonInstanceCache__ = obj
1365 else:
1366 obj = cls.__singletonInstanceCache__
1368 return obj
1370 @wraps(oldinit)
1371 def singleton_init(self, *args: Any, **kwargs: Any):
1372 """
1373 Replacement ``__init__`` method, which initializes the singleton's instance exactly once.
1375 A further instantiation waits until the first one finished initializing, so it never sees a half-built object.
1377 :param args: Positional parameters passed to the original ``__init__``.
1378 :param kwargs: Named parameters passed to the original ``__init__``.
1379 :raises ValueError: If a further instantiation passes parameters, which would be silently ignored.
1380 """
1381 cls = self.__class__
1382 cv = cls.__singletonInstanceCond__
1383 with cv:
1384 if cls.__singletonInstanceInit__:
1385 oldinit(self, *args, **kwargs)
1386 cls.__singletonInstanceInit__ = False
1387 cv.notify_all()
1388 elif args or kwargs:
1389 raise ValueError(f"A further instance of a singleton can't be reinitialized with parameters.")
1390 else:
1391 while cls.__singletonInstanceInit__: 1391 ↛ 1392line 1391 didn't jump to line 1392 because the condition on line 1391 was never true
1392 cv.wait()
1394 singleton_new.__singleton_wrapper__ = True
1395 singleton_init.__singleton_wrapper__ = True
1397 newClass.__new__ = singleton_new
1398 newClass.__init__ = singleton_init
1399 newClass.__singletonInstanceCond__ = Condition()
1400 newClass.__singletonInstanceInit__ = True
1401 newClass.__singletonInstanceCache__ = None
1402 return True
1404 return False
1406 @classmethod
1407 def _wrapNewMethodIfAbstract(metacls, newClass) -> bool:
1408 """
1409 If the class is marked ``__abstractClass__`` or has abstract methods, replace the ``_new__`` method, so it
1410 raises an exception.
1412 :param newClass: The newly constructed class for further modifications.
1413 :returns: ``True``, if the class is abstract.
1414 :raises AbstractClassError: If the class is abstract and can't be instantiated.
1415 :raises ExtendedTypeError: If a singleton wrapper was found around a method raising
1416 :exc:`AbstractClassError`, which is not handled yet.
1417 """
1418 # Replace '__new__' by a variant to throw an error on not overridden methods
1419 if newClass.__abstractClass__ or len(newClass.__abstractMethods__) > 0:
1420 oldnew = newClass.__new__
1421 if hasattr(oldnew, "__raises_abstract_class_error__"):
1422 oldnew = oldnew.__wrapped__
1424 @wraps(oldnew)
1425 def abstract_new(cls, *_, **__):
1426 """
1427 Replacement ``__new__`` method, which rejects the instantiation of an abstract class.
1429 The message names the methods to override, or says that the class needs to be derived when it was declared
1430 abstract without having abstract methods.
1432 :raises AbstractClassError: Always, because an abstract class can't be instantiated.
1433 """
1434 if len(newClass.__abstractMethods__) > 0:
1435 raise AbstractClassError(f"""Class '{cls.__name__}' is abstract. The following methods: '{"', '".join(newClass.__abstractMethods__)}' need to be overridden in a derived class.""")
1436 else:
1437 raise AbstractClassError(f"Class '{cls.__name__}' is abstract and needs to be derived.")
1439 abstract_new.__raises_abstract_class_error__ = True
1441 newClass.__new__ = abstract_new
1442 return True
1444 # Handle classes which are not abstract, especially derived classes, if not abstract anymore
1445 else:
1446 # skip intermediate 'new' function if class isn't abstract anymore
1447 try:
1448 if newClass.__new__.__raises_abstract_class_error__: 1448 ↛ 1470line 1448 didn't jump to line 1470 because the condition on line 1448 was always true
1449 origNew = newClass.__new__.__wrapped__
1451 # WORKAROUND: __new__ checks tp_new and implements different behavior
1452 # Bugreport: https://github.com/python/cpython/issues/105888
1453 if origNew is object.__new__:
1454 @wraps(object.__new__)
1455 def wrapped_new(inst, *_, **__):
1456 """
1457 Replacement ``__new__`` method for a class that isn't abstract anymore.
1459 It calls :meth:`object.__new__` with the class only, because that implementation rejects further
1460 parameters.
1462 :param inst: The class being instantiated.
1463 :returns: The new instance.
1464 """
1465 return object.__new__(inst)
1467 newClass.__new__ = wrapped_new
1468 else:
1469 newClass.__new__ = origNew
1470 elif newClass.__new__.__isSingleton__:
1471 raise ExtendedTypeError(
1472 "Found a singleton wrapper around an AbstractError raising method. This case is not handled yet."
1473 )
1474 except AttributeError as ex:
1475 if ex.name != "__raises_abstract_class_error__": 1475 ↛ 1476line 1475 didn't jump to line 1476 because the condition on line 1475 was never true
1476 raise ex
1478 return False
1480 # Additional properties and methods on a class
1481 @readonly
1482 def HasClassAttributes(self) -> bool:
1483 """
1484 Read-only property to check if the class has Attributes (:attr:`__pyattr__`).
1486 :returns: ``True``, if the class has Attributes.
1487 """
1488 try:
1489 return len(self.__pyattr__) > 0
1490 except AttributeError:
1491 return False
1493 @readonly
1494 def HasMethodAttributes(self) -> bool:
1495 """
1496 Read-only property to check if the class has methods with Attributes (:attr:`__methodsWithAttributes__`).
1498 :returns: ``True``, if the class has any method with Attributes.
1499 """
1500 try:
1501 return len(self.__methodsWithAttributes__) > 0
1502 except AttributeError:
1503 return False
1506@export
1507class SlottedObject(metaclass=ExtendedType, slots=True):
1508 """Classes derived from this class will store all members in ``__slots__``."""