Coverage for pyTooling/MetaClasses/__init__.py: 93%
587 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-21 19:41 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-21 19:41 +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.Common import getFullyQualifiedName
60from pyTooling.Exceptions import ToolingException
61from pyTooling.Decorators import export, readonly
62from pyTooling.Warning import Warning, WarningCollector
65__all__ = ["M"]
67TAttr = TypeVar("TAttr") # , bound='Attribute')
68"""A type variable for :class:`~pyTooling.Attributes.Attribute`."""
70TAttributeFilter = Union[TAttr, Iterable[TAttr], None]
71"""A type hint for a predicate parameter that accepts either a single :class:`~pyTooling.Attributes.Attribute` or an
72iterable of those."""
75@export
76class ThisClass:
77 """
78 Sentinel for a class variable whose value is the class declaring it.
80 A class doesn't exist while its body runs, so a class variable can't name it. Setting the variable to this
81 sentinel says *"the class I am declared in"*, and :class:`ExtendedType` rebinds it once the class exists.
83 .. code-block:: python
85 class Workflow(metaclass=ExtendedType, slots=True):
86 _PARENT_TYPE: ClassVar[Nullable[type]] = ThisClass # a workflow is contained in a workflow
88 assert Workflow._PARENT_TYPE is Workflow
90 Only a variable the class **declared** is rebound; an inherited one keeps the value its own class resolved. It is
91 an empty class rather than a bare object, so a variable annotated as a :class:`type` still type-checks.
93 .. note::
95 :pep:`661` adds a ``sentinel`` builtin in Python 3.15, and ``ThisClass = sentinel("ThisClass")`` would give a
96 better :func:`repr` and identity through :mod:`pickle` and :mod:`copy`. It is **not** used here: a sentinel is
97 an *instance*, so a variable annotated :class:`type` would no longer type-check, and this sentinel never
98 survives class creation anyway - it is replaced before anyone can pickle or print it. :class:`ExtendedType`
99 compares by identity, so swapping the definition is a one-line change if that day comes.
100 """
103@export
104class ExtendedTypeError(ToolingException):
105 """The exception is raised by the meta-class :class:`~pyTooling.MetaClasses.ExtendedType`."""
108@export
109class BaseClassWithoutSlotsError(ExtendedTypeError):
110 """
111 This exception is raised when a class using ``__slots__`` inherits from at-least one base-class not using ``__slots__``.
113 .. seealso::
115 :ref:`Python data model for slots <slots>`
116 |rarr| What ``__slots__`` does and which rules Python imposes on it.
117 :term:`Glossary entry __slots__ <__slots__>`
118 |rarr| The glossary's short definition.
119 """
122@export
123class BaseClassWithNonEmptySlotsError(ExtendedTypeError):
124 """
125 This exception is raised when a mixin-class uses slots, but Python prohibits slots.
127 .. important::
129 To fulfill Python's requirements on slots, pyTooling uses slots only on the prinmary inheritance line.
130 Mixin-classes collect slots, which get materialized when the mixin-class (secondary inheritance lines) gets merged
131 into the primary inheritance line.
132 """
135@export
136class BaseClassIsNotAMixinError(ExtendedTypeError):
137 """
138 This exception is raised when a class inherits from a secondary base-class that is not declared as a mixin.
140 Only the primary inheritance line may carry a normal class; every further base-class needs ``mixin=True`` (or the
141 :deco:`~pyTooling.MetaClasses.mixin` decorator), because that is what allows their slots to be merged.
142 """
145@export
146class DuplicateFieldInSlotsError(ExtendedTypeError):
147 """
148 This exception is raised when a slot name is used multiple times within the inheritance hierarchy.
149 """
152@export
153class UnannotatedFieldWarning(Warning):
154 """
155 A class declares a field that was assigned in the class body without a type annotation.
157 An object field is annotated with its type, a class variable with :class:`~typing.ClassVar`. Without an annotation,
158 :class:`ExtendedType` can't tell the two apart, and the field never becomes a slot.
159 """
162@export
163class UnfulfilledExpectationError(ExtendedTypeError):
164 """
165 This exception is raised when a class doesn't provide the members a mixin-class in its hierarchy expects.
167 An expectation is declared in one of two places, and this exception is raised at the matching moment:
169 * A **class** lists what it needs from whichever class it is mixed into, with the ``expects`` class keyword
170 argument. Which members are missing is determined when the class is constructed, and instantiating a class that
171 still misses one raises this exception. A class may stay incomplete as long as nothing instantiates it, so an
172 intermediate class can pass the expectation on to its own subclasses.
173 * A single **method** lists what it needs from its class, with the :deco:`expects` decorator. The class stays
174 usable; only calling that method raises this exception.
176 Either way the alternative is an :exc:`AttributeError` on first access, somewhere else entirely, and only if that
177 code path runs.
179 .. seealso::
181 :exc:`~pyTooling.MetaClasses.AbstractClassError`
182 |rarr| The same mechanism, for a class with methods that still need to be overridden.
183 :deco:`~pyTooling.MetaClasses.expects`
184 |rarr| Mark a *method* as needing members its class provides only in some combinations.
185 :class:`~pyTooling.MetaClasses.ExtendedType`
186 |rarr| The meta-class implementing the check.
187 """
190@export
191class IncompatibleMetaClassError(ExtendedTypeError):
192 """
193 This exception is raised when a class decorated with :deco:`slotted`, :deco:`mixin` or :deco:`singleton` uses a
194 meta-class that is neither :class:`type` nor derived from :class:`~pyTooling.MetaClasses.ExtendedType`.
195 """
198@export
199class AbstractClassError(ExtendedTypeError):
200 """
201 This exception is raised, when a class contains methods marked with *abstractmethod* or *must-override*.
203 .. seealso::
205 :deco:`~pyTooling.MetaClasses.abstractmethod`
206 |rarr| Mark a method as *abstract*.
207 :deco:`~pyTooling.MetaClasses.mustoverride`
208 |rarr| Mark a method as *must overrride*.
209 :exc:`~MustOverrideClassError`
210 |rarr| Exception raised, if a method is marked as *must-override*.
211 """
214@export
215class MustOverrideClassError(AbstractClassError):
216 """
217 This exception is raised, when a class contains methods marked with *must-override*.
219 .. seealso::
221 :deco:`~pyTooling.MetaClasses.abstractmethod`
222 |rarr| Mark a method as *abstract*.
223 :deco:`~pyTooling.MetaClasses.mustoverride`
224 |rarr| Mark a method as *must overrride*.
225 :exc:`~AbstractClassError`
226 |rarr| Exception raised, if a method is marked as *abstract*.
227 """
230# """
231# Metaclass that allows multiple dispatch of methods based on method signatures.
232#
233# .. seealso:
234#
235# `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>`__
236# """
239M = TypeVar("M", bound=Callable[..., Any]) #: A type variable for methods.
240C = TypeVar("C", bound=type) #: A type variable for classes.
243def _recreateClass(cls: type, decoratorName: str, **options: Any) -> type:
244 """
245 Recreate a class with :class:`ExtendedType` (or the class' own compatible meta-class) applying the given options.
247 :param cls: Class to recreate.
248 :param decoratorName: Name of the calling decorator. It's used in the error message.
249 :param options: Meta-class options like ``slots``, ``mixin``, ``singleton`` or ``expects``.
250 :returns: The recreated class.
251 :raises IncompatibleMetaClassError: If the class' meta-class is neither :class:`type`, nor derived from
252 :class:`ExtendedType`. |br|
253 A decorated class must use :class:`type` or a meta-class derived from
254 :class:`~pyTooling.MetaClasses.ExtendedType`.
255 """
256 if cls.__class__ is type:
257 metacls = ExtendedType
258 elif issubclass(cls.__class__, ExtendedType):
259 metacls = cls.__class__
260 for method in cls.__methods__:
261 delattr(method, "__classobj__")
262 else:
263 metaClass = cls.__class__
264 ex = IncompatibleMetaClassError(f"Class '{cls.__name__}' decorated with '@{decoratorName}' uses an incompatible meta-class.")
265 ex.add_note(f"Meta-class is '{metaClass.__module__}.{metaClass.__name__}'.")
266 ex.add_note("A decorated class must use 'type' or a meta-class derived from 'pyTooling.MetaClasses.ExtendedType'.")
267 raise ex
269 bases = tuple(base for base in cls.__bases__ if base is not object)
270 slots = cls.__dict__["__slots__"] if "__slots__" in cls.__dict__ else tuple()
271 members = {
272 "__qualname__": cls.__qualname__
273 }
274 for key, value in cls.__dict__.items():
275 if key not in slots:
276 members[key] = value
278 return metacls(cls.__name__, bases, members, **options)
281@export
282def slotted(cls):
283 """
284 Class decorator recreating a class with slots derived from its annotated fields.
286 It is the decorator form of ``metaclass=ExtendedType, slots=True``, for a class that shouldn't name the
287 meta-class explicitly.
289 :param cls: The class to recreate.
290 :returns: The recreated class, using ``__slots__``.
292 .. seealso::
294 :deco:`~pyTooling.MetaClasses.mixin`
295 |rarr| Recreate a class as a mixin-class.
296 :deco:`~pyTooling.MetaClasses.singleton`
297 |rarr| Recreate a class as a singleton.
298 """
299 return _recreateClass(cls, "slotted", slots=True)
302@export
303def mixin(cls):
304 """
305 Class decorator recreating a class as a mixin-class.
307 A mixin-class collects its slots instead of materializing them; they are merged when the mixin joins a primary
308 inheritance line.
310 :param cls: The class to recreate.
311 :returns: The recreated class, marked as a mixin.
313 .. seealso::
315 :deco:`~pyTooling.MetaClasses.slotted`
316 |rarr| Recreate a class with slots.
317 :deco:`~pyTooling.MetaClasses.singleton`
318 |rarr| Recreate a class as a singleton.
319 """
320 return _recreateClass(cls, "mixin", mixin=True)
323@export
324def singleton(cls):
325 """
326 Class decorator recreating a class as a singleton.
328 Every instantiation of the decorated class returns the same object, including its state.
330 :param cls: The class to recreate.
331 :returns: The recreated class, marked as a singleton.
333 .. seealso::
335 :deco:`~pyTooling.MetaClasses.slotted`
336 |rarr| Recreate a class with slots.
337 :deco:`~pyTooling.MetaClasses.mixin`
338 |rarr| Recreate a class as a mixin-class.
339 """
340 return _recreateClass(cls, "singleton", singleton=True)
343@export
344def abstractclass(cls: C) -> C:
345 """
346 Mark a class as *abstract*, so it cannot be instantiated, although it has no abstract method.
348 Some classes exist only to be derived from - a base-class collecting shared infrastructure, for instance - and
349 have nothing to mark with :deco:`abstractmethod`. This decorator says so directly: it sets ``__abstractClass__``
350 on the class and recomputes ``__isAbstract__``, which replaces ``__new__`` by a method raising an
351 :exc:`~pyTooling.MetaClasses.AbstractClassError`.
353 The marker belongs to the decorated class alone. :class:`ExtendedType` clears it on every class it creates, so a
354 derived class is concrete again unless it is decorated itself or inherits an abstract method.
356 .. warning::
358 This decorator needs meta-class :class:`~pyTooling.MetaClasses.ExtendedType`, which does the computation.
360 .. admonition:: ``example.py``
362 .. code-block:: python
364 @abstractclass
365 class Base(metaclass=ExtendedType):
366 '''This class needs to be inherited.'''
368 :param cls: Class that is marked as *abstract*.
369 :returns: The same class, marked and with its abstractness recomputed.
370 :raises AttributeError: If the class was not created by :class:`ExtendedType`, because nothing would compute it. |br|
371 Add ``metaclass=ExtendedType`` to the class definition, so abstractness is computed.
373 .. seealso::
375 :exc:`~pyTooling.MetaClasses.AbstractClassError`
376 |rarr| The exception raised when a still abstract class gets instantiated.
377 :deco:`~pyTooling.MetaClasses.abstractmethod`
378 |rarr| Mark a method as *abstract* and raise a :exc:`NotImplementedError` when called.
379 :deco:`~pyTooling.MetaClasses.mustoverride`
380 |rarr| Mark a method as *mustoverride* (minimal implementation, but can be called).
381 :deco:`~pyTooling.Decorators.notimplemented`
382 |rarr| Mark a *method* as not implemented and raise a :exc:`NotImplementedError`.
383 """
384 if not isinstance(cls, ExtendedType):
385 ex = AttributeError(f"Class '{cls.__name__}' is not created by meta-class 'ExtendedType'.")
386 ex.add_note("Add 'metaclass=ExtendedType' to the class definition, so abstractness is computed.")
387 raise ex
389 cls.__abstractClass__ = True
391 if not cls.__isAbstract__:
392 cls.__isAbstract__ = ExtendedType._wrapNewMethodIfAbstract(cls)
394 return cls
397@export
398def abstractmethod(method: M) -> M:
399 """
400 Mark a method as *abstract* and replace the implementation with a new method raising a :exc:`NotImplementedError`.
402 The original method is stored in ``<method>.__wrapped__`` and it's doc-string is copied to the replacing method. In
403 additional field ``<method>.__abstract__`` is added.
405 .. warning::
407 This decorator should be used in combination with meta-class :class:`~pyTooling.MetaClasses.ExtendedType`.
408 Otherwise, an abstract class itself doesn't throw a :exc:`~pyTooling.MetaClasses.AbstractClassError` at
409 instantiation.
411 .. admonition:: ``example.py``
413 .. code-block:: python
415 class Data(mataclass=ExtendedType):
416 @abstractmethod
417 def method(self) -> bool:
418 '''This method needs to be implemented.'''
420 :param method: Method that is marked as *abstract*.
421 :returns: Replacement method, which raises a :exc:`NotImplementedError`.
423 .. seealso::
425 :exc:`~pyTooling.MetaClasses.AbstractClassError`
426 |rarr| The exception raised when a still abstract class gets instantiated.
427 :deco:`~pyTooling.MetaClasses.abstractclass`
428 |rarr| Mark a class as *abstract*.
429 :deco:`~pyTooling.MetaClasses.mustoverride`
430 |rarr| Mark a method as *mustoverride* (minimal implementation, but can be called).
431 :deco:`~pyTooling.Decorators.notimplemented`
432 |rarr| Mark a *method* as not implemented and raise a :exc:`NotImplementedError`.
433 """
434 @wraps(method)
435 def func(self) -> NoReturn:
436 """
437 Replacement method, which raises a :exc:`NotImplementedError` when called.
439 :raises NotImplementedError: Always, because an abstract method has no implementation.
440 """
441 raise NotImplementedError(f"Method '{method.__name__}' is abstract and needs to be overridden in a derived class.")
443 func.__abstract__ = True
444 return func
447@export
448def mustoverride(method: M) -> M:
449 """
450 Mark a method as *must-override*.
452 The returned function is the original function, but with an additional field ``<method>.____mustOverride__``, so a
453 meta-class can identify a *must-override* method and raise an error. Such an error is not raised if the method is
454 overridden by an inheriting class.
456 A *must-override* methods can offer a partial implementation, which is called via ``super()...``.
458 .. warning::
460 This decorator needs to be used in combination with meta-class :class:`~pyTooling.MetaClasses.ExtendedType`.
461 Otherwise, an abstract class itself doesn't throw a :exc:`~pyTooling.MetaClasses.MustOverrideClassError` at
462 instantiation.
464 .. admonition:: ``example.py``
466 .. code-block:: python
468 class Data(mataclass=ExtendedType):
469 @mustoverride
470 def method(self):
471 '''This is a very basic implementation.'''
473 :param method: Method that is marked as *must-override*.
474 :returns: Same method, but with additional ``<method>.__mustOverride__`` field.
476 .. seealso::
478 :exc:`~pyTooling.MetaClasses.MustOverrideClassError`
479 |rarr| The exception raised when a class gets instantiated still containing *mustoverride* methods.
480 :deco:`~pyTooling.MetaClasses.abstractclass`
481 |rarr| Mark a class as *abstract*.
482 :deco:`~pyTooling.MetaClasses.abstractmethod`
483 |rarr| Mark a method as *abstract* and raise a :exc:`NotImplementedError` when called.
484 :deco:`~pyTooling.Decorators.notimplemented`
485 |rarr| Mark a *method* as not implemented and raise a :exc:`NotImplementedError`.
486 """
487 method.__mustOverride__ = True
488 return method
491@export
492def expects(*memberNames: str) -> Callable[[M], M]:
493 """
494 Mark a method as needing members its class provides only in some combinations, usually through a mixin-class.
496 The marked method sits on the class in the **primary inheritance line** and waits for a :term:`mixin-class`
497 further along the bases to supply what it reads, while the class itself stays perfectly usable without that
498 mixin. The requirement belongs to the method, not to the class, so ``expects`` as a class keyword argument would
499 be too strict: it would reject a class that never calls the method. It is the opposite direction of
500 ``ExtendedType``'s ``expects`` keyword, where a mixin-class states what it needs from its host.
502 :class:`ExtendedType` checks the marked method against every class it is reachable from. If a class provides the
503 members, the method is left untouched, so a fulfilled expectation costs nothing per call. If it doesn't, the
504 method is replaced by one raising an :exc:`UnfulfilledExpectationError` when called - naming the missing members,
505 instead of an :exc:`AttributeError` from somewhere in the method's body. A replacement inherited from a
506 base-class is removed again as soon as a class provides the missing members, so the mixin-class may join any
507 number of levels further down.
509 .. admonition:: ``example.py``
511 .. code-block:: python
513 class Terminal(metaclass=ExtendedType, slots=True):
514 @expects("MainParser", "SubParsers")
515 def PrintHelp(self) -> None:
516 self.MainParser.print_help()
518 Terminal().PrintHelp() # UnfulfilledExpectationError
520 class Application(Terminal, ArgParseHelperMixin):
521 pass
523 Application().PrintHelp() # fine
525 :param memberNames: Names of the members the method needs from its class.
526 :returns: Decorator marking the method with an ``<method>.__expectedMembers__`` field.
527 :raises TypeError: If an element of parameter 'memberNames' is not a string.
529 .. seealso::
531 :class:`~pyTooling.MetaClasses.ExtendedType`
532 |rarr| The ``expects`` class keyword argument, for a class that needs the members as a whole.
533 :exc:`~pyTooling.MetaClasses.UnfulfilledExpectationError`
534 |rarr| The exception raised when a missing member is reached for.
535 :deco:`~pyTooling.MetaClasses.abstractmethod`
536 |rarr| Mark a method as *abstract*, when the class itself declares what has to be overridden.
537 """
538 for memberName in memberNames:
539 if not isinstance(memberName, str): 539 ↛ 540line 539 didn't jump to line 540 because the condition on line 539 was never true
540 ex = TypeError(f"Parameter 'memberNames' contains an element that is not a string.")
541 ex.add_note(f"Got type '{getFullyQualifiedName(memberName)}'.")
542 raise ex
544 def decorator(method: M) -> M:
545 """
546 Attach the expected member names to the decorated method.
548 :param method: Method that expects members from its class.
549 :returns: Same method, but with additional ``<method>.__expectedMembers__`` field.
550 :raises TypeError: If applied to a class instead of a method. |br|
551 A class states what it expects with the ``expects`` class keyword argument of
552 :class:`ExtendedType`.
553 """
554 if isinstance(method, type): 554 ↛ 555line 554 didn't jump to line 555 because the condition on line 554 was never true
555 ex = TypeError(f"Decorator 'expects' is applied to class '{method.__name__}' instead of a method.")
556 ex.add_note(f"A class names what it expects with the 'expects' class keyword argument of 'ExtendedType'.")
557 raise ex
559 method.__expectedMembers__ = memberNames
560 return method
562 return decorator
565# @export
566# def overloadable(method: M) -> M:
567# method.__overloadable__ = True
568# return method
571# @export
572# class DispatchableMethod:
573# """Represents a single multimethod."""
574#
575# _methods: dict[Tuple, Callable]
576# __name__: str
577# __slots__ = ("_methods", "__name__")
578#
579# def __init__(self, name: str) -> None:
580# self.__name__ = name
581# self._methods = {}
582#
583# def __call__(self, *args: Any):
584# """Call a method based on type signature of the arguments."""
585# types = tuple(type(arg) for arg in args[1:])
586# meth = self._methods.get(types, None)
587# if meth:
588# return meth(*args)
589# else:
590# raise TypeError(f"No matching method for types {types}.")
591#
592# def __get__(self, instance, cls) -> Self:
593# """Descriptor method needed to make calls work in a class."""
594# if instance is not None:
595# return MethodType(self, instance)
596# else:
597# return self
598#
599# def register(self, method: Callable) -> None:
600# """Register a new method as a dispatchable."""
601#
602# # Build a signature from the method's type annotations
603# sig = signature(method)
604# types: list[Type] = []
605#
606# for name, parameter in sig.parameters.items():
607# if name == "self":
608# continue
609#
610# if parameter.annotation is Parameter.empty:
611# raise TypeError(f"Parameter '{name}' in method '{method.__name__}' must be annotated with a type.")
612#
613# if not isinstance(parameter.annotation, type):
614# raise TypeError(f"Parameter '{name}' in method '{method.__name__}' annotation must be a type.")
615#
616# if parameter.default is not Parameter.empty:
617# self._methods[tuple(types)] = method
618#
619# types.append(parameter.annotation)
620#
621# self._methods[tuple(types)] = method
624# @export
625# class DispatchDictionary(dict):
626# """Special dictionary to build dispatchable methods in a metaclass."""
627#
628# def __setitem__(self, key: str, value: Any):
629# if callable(value) and key in self:
630# # If key already exists, it must be a dispatchable method or callable
631# currentValue = self[key]
632# if isinstance(currentValue, DispatchableMethod):
633# currentValue.register(value)
634# else:
635# dispatchable = DispatchableMethod(key)
636# dispatchable.register(currentValue)
637# dispatchable.register(value)
638#
639# super().__setitem__(key, dispatchable)
640# else:
641# super().__setitem__(key, value)
644@export
645class ExtendedType(type):
646 """
647 An updates meta-class to construct new classes with an extended feature set.
649 .. todo:: META::ExtendedType Needs documentation.
650 .. todo:: META::ExtendedType allow __dict__ and __weakref__ if slotted is enabled
652 .. rubric:: Features:
654 * Store object members more efficiently in ``__slots__`` instead of ``_dict__``.
656 * Implement ``__slots__`` only on primary inheritance line.
657 * Collect class variables on secondary inheritance lines (mixin-classes) and defer implementation as ``__slots__``.
658 * Handle object state exporting and importing for slots (:mod:`pickle` support) via ``__getstate__``/``__setstate__``.
660 * Allow only a single instance to be created (:term:`singleton`). |br|
661 Further instantiations will return the previously create instance (identical object).
662 * Define methods as :term:`abstract <abstract method>` or :term:`must-override <mustoverride method>` and prohibit
663 instantiation of :term:`abstract classes <abstract class>`.
664 * Let a mixin-class state which members it expects from its host class (``expects``), or a single method state
665 what it needs from its class (:deco:`expects`), and reject instantiation resp. that call while a member is
666 missing.
668 .. #* Allow method overloading and dispatch overloads based on argument signatures.
670 .. rubric:: Added class fields:
672 :__slotted__: True, if class uses `__slots__`.
673 :__allSlots__: Set of class fields stored in slots for all classes in the inheritance hierarchy.
674 :__slots__: Tuple of class fields stored in slots for current class in the inheritance hierarchy. |br|
675 See :pep:`253` for details.
676 :__isMixin__: True, if class is a mixin-class
677 :__mixinSlots__: List of collected slots from secondary inheritance hierarchy (mixin hierarchy).
678 :__methods__: List of methods.
679 :__methodsWithAttributes__: List of methods with pyTooling attributes.
680 :__abstractMethods__: List of abstract methods, which need to be implemented in the next class hierarchy levels.
681 :__expectedMembers__: Mapping of a member name expected from the host class to the name of the class
682 expecting it.
683 :__missingMembers__: Tuple of expected members this class doesn't provide (yet).
684 :__abstractClass__: True, if this class was decorated with :deco:`abstractclass`.
685 :__isAbstract__: True, if class is abstract.
686 :__isSingleton__: True, if class is a singleton
687 :__singletonInstanceCond__: Condition variable to protect the singleton creation.
688 :__singletonInstanceInit__: Singleton is initialized.
689 :__singletonInstanceCache__: The singleton object, once created.
690 :__pyattr__: List of class attributes.
692 .. rubric:: Added class properties:
694 :HasClassAttributes: Read-only property to check if the class has Attributes.
695 :HasMethodAttributes: Read-only property to check if the class has methods with Attributes.
697 .. rubric:: Added methods:
699 If slots are used, the following methods are added to support :mod:`pickle`:
701 :__getstate__: Export an object's state for serialization. |br|
702 See :pep:`307` for details.
703 :__setstate__: Import an object's state for deserialization. |br|
704 See :pep:`307` for details.
706 .. rubric:: Modified ``__new__`` method:
708 If class is a singleton, ``__new__`` will be replaced by a wrapper method. This wrapper is marked with ``__singleton_wrapper__``.
710 If class is abstract, ``__new__`` will be replaced by a method raising an exception. This replacement is marked with ``__raises_abstract_class_error__``.
712 .. rubric:: Modified ``__init__`` method:
714 If class is a singleton, ``__init__`` will be replaced by a wrapper method. This wrapper is marked by ``__singleton_wrapper__``.
716 .. rubric:: Modified abstract methods:
718 If a method is abstract, its marked with ``__abstract__``. |br|
719 If a method is must override, its marked with ``__mustOverride__``.
720 """
722 # @classmethod
723 # def __prepare__(cls, className, baseClasses, slots: bool = False, mixin: bool = False, singleton: bool = False):
724 # return DispatchDictionary()
726 def __new__(
727 self,
728 className: str,
729 baseClasses: tuple[type],
730 members: dict[str, Any],
731 slots: bool = False,
732 mixin: bool = False,
733 singleton: bool = False,
734 expects: Iterable[str] = (),
735 **kwargs: Any
736 ) -> Self:
737 """
738 Construct a new class using this :term:`meta-class`.
740 :param className: The name of the class to construct.
741 :param baseClasses: The tuple of :term:`base-classes <base-class>` the class is derived from.
742 :param members: The dictionary of members for the constructed class.
743 :param slots: Optional, if ``True``, store object attributes in :term:`__slots__ <slots>` instead of
744 ``__dict__``.
745 :param mixin: Optional, if ``True``, make the class a :term:`Mixin-Class`. If ``False``, create slots if
746 ``slots``
747 is true. If ``None``, preserve behavior of primary base-class.
748 :param singleton: Optional, if ``True``, make the class a :term:`Singleton`.
749 :param expects: Optional, names of members this class needs from whichever class it is mixed into. |br|
750 See :attr:`__expectedMembers__`.
751 :param kwargs: Any further class keyword argument, forwarded to :meth:`~object.__init_subclass__` as
752 :func:`type` does.
753 :returns: The new class.
754 :raises AttributeError: If base-class has no '__slots__' attribute.
755 :raises AttributeError: If slot already exists in base-class.
756 """
757 from pyTooling.Attributes import ATTRIBUTES_MEMBER_NAME, AttributeScope
759 # Inherit 'slots' feature from primary base-class
760 if len(baseClasses) > 0:
761 primaryBaseClass = baseClasses[0]
762 if isinstance(primaryBaseClass, self):
763 slots = primaryBaseClass.__slotted__
765 # Compute slots and mixin-slots from annotated fields as well as class- and object-fields with initial values.
766 classFields, objectFields = self._computeSlots(className, baseClasses, members, slots, mixin)
768 # Compute abstract methods
769 abstractMethods, members = self._checkForAbstractMethods(baseClasses, members)
771 # Create a new class - the remaining keyword arguments belong to '__init_subclass__', which 'type' calls.
772 # Class variables with an initial value are part of 'members', so they are bound before that hook runs and
773 # are not re-assigned afterwards - doing so would overwrite whatever '__init_subclass__' computed from them.
774 newClass = type.__new__(self, className, baseClasses, members, **kwargs)
776 # A class variable set to 'ThisClass' means "the class I am declared in". The class doesn't exist while its body
777 # runs, so the value is resolved here. Only a variable this class declared is rebound - an inherited one keeps
778 # the value its own class resolved - and the current value is read from the class, so an '__init_subclass__'
779 # that replaced it wins.
780 for memberName in members:
781 if getattr(newClass, memberName, None) is ThisClass:
782 setattr(newClass, memberName, newClass)
784 # Search in inheritance tree for abstract methods
785 newClass.__abstractMethods__ = abstractMethods
787 newClass.__abstractClass__ = False
788 newClass.__isAbstract__ = self._wrapNewMethodIfAbstract(newClass)
789 newClass.__isSingleton__ = self._wrapNewMethodIfSingleton(newClass, singleton)
791 # Collect the members expected from the host class and reject instantiation while any of them is missing
792 newClass.__expectedMembers__ = self._collectExpectedMembers(className, baseClasses, members, expects)
793 newClass.__missingMembers__ = self._computeMissingMembers(newClass, mixin)
794 if not newClass.__isAbstract__:
795 self._wrapNewMethodIfExpectationUnfulfilled(newClass)
797 if slots:
798 # If slots are used, implement __getstate__/__setstate__ API to support serialization using pickle.
799 if "__getstate__" not in members:
800 def __getstate__(self) -> dict[str, Any]:
801 """
802 Return the object's state for pickling, collecting every slot of the class hierarchy.
804 :returns: Dictionary of slot names and their values.
805 :raises ExtendedTypeError: If a slot was never assigned, so it has no value to serialize.
806 """
807 try:
808 return {slotName: getattr(self, slotName) for slotName in self.__allSlots__}
809 except AttributeError as ex:
810 raise ExtendedTypeError(f"Unassigned field '{ex.name}' in object '{self}' of type '{self.__class__.__name__}'.") from ex
812 newClass.__getstate__ = __getstate__
814 if "__setstate__" not in members:
815 def __setstate__(self, state: dict[str, Any]) -> None:
816 """
817 Restore the object's state from unpickling, requiring exactly the slots of the class hierarchy.
819 :param state: Dictionary of slot names and their values.
820 :raises ExtendedTypeError: If the given state misses a slot or carries an unexpected one.
821 """
822 if self.__allSlots__ != (slots := set(state.keys())):
823 if len(diff := self.__allSlots__.difference(slots)) > 0:
824 raise ExtendedTypeError(f"""Missing fields in parameter 'state': '{"', '".join(diff)}'""") # WORKAROUND: Python <3.12
825 else:
826 diff = slots.difference(self.__allSlots__)
827 raise ExtendedTypeError(f"""Unexpected fields in parameter 'state': '{"', '".join(diff)}'""") # WORKAROUND: Python <3.12
829 for slotName, value in state.items():
830 setattr(self, slotName, value)
832 newClass.__setstate__ = __setstate__
834 # Check for inherited class attributes
835 attributes: list[Attribute] = []
836 setattr(newClass, ATTRIBUTES_MEMBER_NAME, attributes)
837 for base in baseClasses:
838 if hasattr(base, ATTRIBUTES_MEMBER_NAME):
839 pyAttr = getattr(base, ATTRIBUTES_MEMBER_NAME)
840 for att in pyAttr:
841 if AttributeScope.Class in att.Scope: 841 ↛ 840line 841 didn't jump to line 840 because the condition on line 841 was always true
842 attributes.append(att)
843 att.__class__._classes.append(newClass)
845 # Check methods for attributes
846 methods, methodsWithAttributes = self._findMethods(newClass, baseClasses, members)
848 # Add new fields for found methods
849 newClass.__methods__ = tuple(methods)
850 newClass.__methodsWithAttributes__ = tuple(methodsWithAttributes)
852 # Reject calling a method that expects members this class doesn't provide
853 self._wrapMethodsWithUnfulfilledExpectations(newClass)
855 # Additional methods on a class
856 def GetMethodsWithAttributes(
857 self,
858 predicate: Nullable[TAttributeFilter[TAttr]] = None
859 ) -> dict[Callable[..., Any], tuple[Attribute, ...]]:
860 """
861 Return the class' methods that carry at least one matching attribute.
863 :param predicate: Optional, an attribute class, an iterable of attribute classes, or ``None`` to accept every
864 attribute.
865 :returns: Dictionary of methods and the matching attributes attached to them.
866 :raises ValueError: If an element of parameter 'predicate' is not a sub-class of :class:`~pyTooling.Attributes.Attribute`.
867 :raises ValueError: If parameter 'predicate' is neither an attribute class nor an iterable of those.
868 """
869 from pyTooling.Attributes import Attribute
871 if predicate is None:
872 predicate = Attribute
873 elif isinstance(predicate, Iterable): 873 ↛ 874line 873 didn't jump to line 874 because the condition on line 873 was never true
874 for attribute in predicate:
875 if not issubclass(attribute, Attribute):
876 raise ValueError("Parameter 'predicate' contains an element which is not a sub-class of 'Attribute'.")
878 predicate = tuple(predicate)
879 elif not issubclass(predicate, Attribute): 879 ↛ 880line 879 didn't jump to line 880 because the condition on line 879 was never true
880 raise ValueError("Parameter 'predicate' is not a sub-class of 'Attribute'.")
882 methodAttributePairs = {}
883 for method in newClass.__methodsWithAttributes__:
884 matchingAttributes: list[Attribute] = []
885 for attribute in method.__pyattr__:
886 if isinstance(attribute, predicate):
887 matchingAttributes.append(attribute)
889 if len(matchingAttributes) > 0:
890 methodAttributePairs[method] = tuple(matchingAttributes)
892 return methodAttributePairs
894 newClass.GetMethodsWithAttributes = classmethod(GetMethodsWithAttributes)
895 GetMethodsWithAttributes.__qualname__ = f"{className}.{GetMethodsWithAttributes.__name__}"
897 # GetMethods(predicate) -> dict[method, list[attribute]] / generator
898 # GetClassAtrributes -> list[attributes] / generator
899 # MethodHasAttributes(predicate) -> bool
900 # GetAttribute
902 return newClass
904 @classmethod
905 def _findMethods(
906 self,
907 newClass: ExtendedType,
908 baseClasses: tuple[type],
909 members: dict[str, Any]
910 ) -> tuple[list[MethodType], list[MethodType]]:
911 """
912 Find methods and methods with :mod:`pyTooling.Attributes`.
914 .. todo::
916 Describe algorithm.
918 :param newClass: Newly created class instance.
919 :param baseClasses: The tuple of :term:`base-classes <base-class>` the class is derived from.
920 :param members: Members of the new class.
921 :returns: A 2-tuple of all methods and those methods carrying at least one attribute.
922 :raises TypeError: If a member is neither a method nor a class, so it can't be searched for methods.
923 """
924 from pyTooling.Attributes import Attribute
926 # Embedded bind function due to circular dependencies.
927 def bind(instance: object, func: FunctionType, methodName: Nullable[str] = None):
928 """
929 Nested function binding a function to an object as a method.
931 It exists here rather than in :mod:`pyTooling.Common`, because importing that module would be circular.
933 :param instance: The object the function is bound to.
934 :param func: The function to bind.
935 :param methodName: Optional, name of the method; by default the function's own name.
936 :returns: The bound method.
937 """
938 if methodName is None: 938 ↛ 941line 938 didn't jump to line 941 because the condition on line 938 was always true
939 methodName = func.__name__
941 boundMethod = func.__get__(instance, instance.__class__)
942 setattr(instance, methodName, boundMethod)
944 return boundMethod
946 methods = []
947 methodsWithAttributes = []
948 attributeIndex = {}
950 for base in baseClasses:
951 if hasattr(base, "__methodsWithAttributes__"):
952 methodsWithAttributes.extend(base.__methodsWithAttributes__)
954 for memberName, member in members.items():
955 if isinstance(member, FunctionType):
956 method = newClass.__dict__[memberName]
957 if hasattr(method, "__classobj__") and getattr(method, "__classobj__") is not newClass: 957 ↛ 958line 957 didn't jump to line 958 because the condition on line 957 was never true
958 raise TypeError(f"Method '{memberName}' is used by multiple classes: {method.__classobj__} and {newClass}.")
959 else:
960 setattr(method, "__classobj__", newClass)
962 def GetAttributes(inst: Any, predicate: Nullable[type[Attribute]] = None) -> tuple[Attribute, ...]:
963 """
964 Nested function attached to the class, returning the attributes of one of its methods.
966 :param inst: The method to read the attributes from.
967 :param predicate: Optional, an attribute class, or ``None`` to accept every attribute.
968 :returns: Tuple of the matching attributes.
969 """
970 results = []
971 try:
972 for attribute in inst.__pyattr__: # type: Attribute
973 if isinstance(attribute, predicate):
974 results.append(attribute)
975 return tuple(results)
976 except AttributeError:
977 return tuple()
979 method.GetAttributes = bind(method, GetAttributes)
980 methods.append(method)
982 # print(f" convert function: '{memberName}' to method")
983 # print(f" {member}")
984 if "__pyattr__" in member.__dict__:
985 attributes = member.__pyattr__ # type: list[Attribute]
986 if isinstance(attributes, list) and len(attributes) > 0: 986 ↛ 954line 986 didn't jump to line 954 because the condition on line 986 was always true
987 methodsWithAttributes.append(member)
988 for attribute in attributes:
989 attribute._functions.remove(method)
990 attribute._methods.append(method)
992 # print(f" attributes: {attribute.__class__.__name__}")
993 if attribute not in attributeIndex: 993 ↛ 996line 993 didn't jump to line 996 because the condition on line 993 was always true
994 attributeIndex[attribute] = [member]
995 else:
996 attributeIndex[attribute].append(member)
997 # else:
998 # print(f" But has no attributes.")
999 # else:
1000 # print(f" ?? {memberName}")
1001 return methods, methodsWithAttributes
1003 @classmethod
1004 def _getAnnotations(metacls, members: dict[str, Any]) -> dict[str, Any]:
1005 """
1006 Return the type annotations declared in a class body.
1008 .. important::
1010 Python 3.14 (:pep:`649`) no longer fills ``__annotations__`` while the class body is executed, but installs an
1011 ``__annotate_func__`` instead. The :mod:`annotationlib` module needed to evaluate that function doesn't exist on
1012 older Python versions, therefore both mechanisms are supported.
1014 :param members: Dictionary of class members.
1015 :returns: Dictionary of annotated field names and their type annotations. Empty, if the class body declared
1016 no annotations.
1017 """
1018 if "__annotations__" in members:
1019 # WORKAROUND: LEGACY SUPPORT Python <= 3.13
1020 # Accessing annotations was changed in Python 3.14.
1021 # The necessary 'annotationlib' is not available for older Python versions.
1022 annotations = members["__annotations__"]
1023 elif version_info >= (3, 14) and (annotate := members.get("__annotate_func__", None)) is not None:
1024 from annotationlib import Format
1025 try:
1026 annotations = annotate(Format.VALUE)
1027 except NameError:
1028 # A forward reference the class body cannot resolve yet - PEP 649 offers the source text instead.
1029 annotations = annotate(Format.STRING)
1030 else:
1031 return {}
1033 # 'from __future__ import annotations' (PEP 563) makes every annotation a string, and so does the fallback
1034 # above. Resolve what can be resolved, so a 'ClassVar' is still recognized as one.
1035 return {
1036 name: metacls._resolveAnnotation(typeAnnotation, members)
1037 for name, typeAnnotation in annotations.items()
1038 }
1040 #: Matches a textual annotation denoting a :class:`~typing.ClassVar`, with or without a module qualifier.
1041 _CLASS_VARIABLE_PATTERN = re_compile(r"^\s*(?:\w+\.)*ClassVar\s*(?:\[|$)")
1043 @classmethod
1044 def _resolveAnnotation(metacls, typeAnnotation: Any, members: dict[str, Any]) -> Any:
1045 """
1046 Evaluate a postponed (string) annotation, so it can be inspected like an ordinary one.
1048 :pep:`563` - ``from __future__ import annotations`` - turns **every** annotation in a module into a string, and
1049 :pep:`649` does the same for an annotation :mod:`annotationlib` cannot evaluate yet. A string tells this
1050 meta-class nothing: a ``ClassVar`` reads as ``"ClassVar[int]"`` and would silently become a slot.
1052 The annotation is evaluated in the defining module's namespace plus the class body itself. A name that cannot be
1053 resolved - typically a forward reference to the class being created right now - is **returned unchanged**, which
1054 is harmless: the textual fallback in :meth:`_isClassVariable` still classifies it, and nothing else needs the
1055 type object.
1057 :param typeAnnotation: The annotation to resolve; returned unchanged when it is not a string.
1058 :param members: Dictionary of class members, used as the local namespace.
1059 :returns: The evaluated annotation, or the original string when it cannot be evaluated.
1060 """
1061 if not isinstance(typeAnnotation, str):
1062 return typeAnnotation
1064 module = modules.get(members.get("__module__", ""), None)
1065 try:
1066 # The annotation is source code written in the class being created - the same trust level as importing it.
1067 return eval(typeAnnotation, getattr(module, "__dict__", {}), members)
1068 except Exception: # noqa: BLE001 - any failure means "keep the string", see above
1069 return typeAnnotation
1071 @classmethod
1072 def _isClassVariable(metacls, typeAnnotation: Any) -> bool:
1073 """
1074 Check if a type annotation declares a class variable.
1076 Both forms are recognized: the evaluated :class:`~typing.ClassVar` and its textual form, which is what
1077 ``from __future__ import annotations`` leaves behind when the annotation cannot be evaluated.
1079 :param typeAnnotation: The type annotation to check.
1080 :returns: ``True``, if the annotation is a :class:`~typing.ClassVar`.
1081 """
1082 if isinstance(typeAnnotation, str):
1083 return metacls._CLASS_VARIABLE_PATTERN.match(typeAnnotation) is not None
1085 return isinstance(typeAnnotation, _GenericAlias) and typeAnnotation.__origin__ is ClassVar
1087 @classmethod
1088 def _isField(metacls, member: Any) -> bool:
1089 """
1090 Check if a class member is a field, so a type annotation is expected for it.
1092 Methods, nested classes, properties and any other descriptor carry their type information in their signature or
1093 in their own declaration, therefore they aren't fields.
1095 :param member: The class member to check.
1096 :returns: ``True``, if the member is a field.
1097 """
1098 if isinstance(member, (FunctionType, MethodType, BuiltinFunctionType, classmethod, staticmethod, property, type)):
1099 return False
1101 # Any descriptor (e.g. a custom property implementation) declares its own type information.
1102 return not (hasattr(member, "__get__") or hasattr(member, "__set__"))
1104 @classmethod
1105 def _checkForUnannotatedFields(metacls, className: str, members: dict[str, Any], annotations: dict[str, Any]) -> None:
1106 """
1107 Report fields that were assigned in the class body without a type annotation.
1109 Every field should carry type information: an object field is annotated with its type, a class variable with
1110 ``ClassVar[...]``. Without an annotation, :class:`ExtendedType` can't tell the two apart - an un-annotated
1111 assignment never becomes a slot and silently stays a class attribute.
1113 .. important::
1115 This is reported as a :class:`~pyTooling.Warning.Warning`, so it needs a
1116 :class:`~pyTooling.Warning.WarningCollector` somewhere up the call-hierarchy to be observed. Importing a module
1117 with un-annotated fields doesn't fail.
1119 :param className: The name of the class to construct.
1120 :param members: Dictionary of class members.
1121 :param annotations: Dictionary of annotated field names and their type annotations.
1122 """
1123 unannotatedFields = [
1124 fieldName
1125 for fieldName, member in members.items()
1126 if not (fieldName.startswith("__") and fieldName.endswith("__"))
1127 and fieldName not in annotations
1128 and metacls._isField(member)
1129 ]
1131 if len(unannotatedFields) > 0:
1132 fieldNames = "', '".join(unannotatedFields)
1133 WarningCollector.Raise(
1134 UnannotatedFieldWarning(f"Class '{className}' declares {len(unannotatedFields)} field(s) without a type annotation."),
1135 notes=(
1136 f"Field(s) without a type annotation: '{fieldNames}'.",
1137 "Annotate a class variable as 'ClassVar[...]' or an object field with its type.",
1138 )
1139 )
1141 @classmethod
1142 def _computeSlots(
1143 self,
1144 className: str,
1145 baseClasses: tuple[type],
1146 members: dict[str, Any],
1147 slots: bool,
1148 mixin: bool
1149 ) -> tuple[dict[str, Any], dict[str, Any]]:
1150 """
1151 Compute which field are listed in __slots__ and which need to be initialized in an instance or class.
1153 .. todo::
1155 Describe algorithm.
1157 :param className: The name of the class to construct.
1158 :param baseClasses: Tuple of base-classes.
1159 :param members: Dictionary of class members.
1160 :param slots: Optional, ``True``, if the class should setup ``__slots__``.
1161 :param mixin: Optional, ``True``, if the class should behave as a mixin-class.
1162 :returns: A 2-tuple with a dictionary of class members and object members.
1163 :raises AttributeError: If a field's annotation refers to a name that can't be resolved. |br|
1164 An assignment without a type annotation creates a class attribute, which
1165 hides the slot's descriptor: reading the field works, but assigning it on an
1166 instance raises. Annotate it as ``ClassVar[...]`` to declare a class
1167 variable, or remove the assignment. A slot contributed by a mixin-class is
1168 materialized in this class' ``__slots__``, and Python doesn't allow such a
1169 name to be assigned in the class body.
1170 :raises BaseClassWithoutSlotsError: If a base-class doesn't use slots. |br|
1171 All base-classes of a class using ``__slots__`` must use ``__slots__``
1172 themselves.
1173 :raises DuplicateFieldInSlotsError: If a class member shadows a slot a base-class or mixin-class declares.
1174 |br|
1175 Without an annotation, annotate it as ``ClassVar[...]`` or remove the
1176 assignment. Annotating it does **not** resolve the clash - the slot
1177 exists either way - so a class variable of that name has to be renamed,
1178 or the slot dropped.
1179 """
1180 # Compute which field are listed in __slots__ and which need to be initialized in an instance or class.
1181 slottedFields = []
1182 classFields = {}
1183 objectFields = {}
1184 annotations: dict[str, Any] = self._getAnnotations(members)
1185 if slots or mixin:
1186 # If slots are used, all base classes must use __slots__.
1187 for baseClass in self._iterateBaseClasses(baseClasses):
1188 # Exclude object as a special case
1189 if baseClass is object or baseClass is Generic:
1190 continue
1192 if not hasattr(baseClass, "__slots__"):
1193 ex = BaseClassWithoutSlotsError(f"Base-classes '{baseClass.__name__}' doesn't use '__slots__'.")
1194 ex.add_note("All base-classes of a class using '__slots__' must use '__slots__' itself.")
1195 raise ex
1197 # Non-empty __slots__ on secondary base-classes are rejected by _aggregateMixinSlots below.
1199 # Copy all field names from primary base-class' __slots__, which are later needed for error checking.
1200 inheritedSlottedFields = {}
1201 if len(baseClasses) > 0:
1202 for base in reversed(baseClasses[0].mro()):
1203 # Exclude object as a special case
1204 if base is object or base is Generic:
1205 continue
1207 for annotation in base.__slots__:
1208 inheritedSlottedFields[annotation] = base
1210 # When adding annotated fields to slottedFields, check if name was not used in inheritance hierarchy.
1211 for fieldName, typeAnnotation in annotations.items():
1212 if fieldName in inheritedSlottedFields: 1212 ↛ 1213line 1212 didn't jump to line 1213 because the condition on line 1212 was never true
1213 cls = inheritedSlottedFields[fieldName]
1214 raise AttributeError(f"Slot '{fieldName}' declared in class '{className}' already exists in base-class '{cls.__module__}.{cls.__name__}'.")
1216 # A ClassVar is never a slot, with or without an initial value.
1217 # * If it has an initial value, note the field in classFields and **leave it in members**, so that
1218 # 'type.__new__' binds it before calling '__init_subclass__'. Removing it and assigning it
1219 # afterwards made a derived class' value invisible to that hook, which then read the base class'.
1220 # * Otherwise it's a forward declaration and derived classes assign the actual value.
1221 isClassVariable = self._isClassVariable(typeAnnotation)
1222 hasInitialValue = fieldName in members
1223 if isClassVariable:
1224 if hasInitialValue:
1225 classFields[fieldName] = members[fieldName]
1227 # If an annotated field has an initial value
1228 # * copy field and initial value to objectFields dictionary
1229 # * remove field from members
1230 elif hasInitialValue:
1231 slottedFields.append(fieldName)
1232 objectFields[fieldName] = members[fieldName]
1233 del members[fieldName]
1234 else:
1235 slottedFields.append(fieldName)
1237 mixinSlots = self._aggregateMixinSlots(className, baseClasses)
1239 # A member assigned in the class body without a type annotation stays a class attribute. If it carries the name
1240 # of a slot, that class attribute shadows the slot's descriptor and the field becomes read-only on instances.
1241 # Report it here instead of letting the first assignment fail with a bare AttributeError.
1242 shadowedSlots = {**inheritedSlottedFields, **{fieldName: None for fieldName in mixinSlots}}
1243 for fieldName in shadowedSlots.keys() & members.keys():
1244 ex = DuplicateFieldInSlotsError(f"Slot '{fieldName}' is shadowed by a class member in class '{className}'.")
1245 if (baseClass := shadowedSlots[fieldName]) is not None:
1246 ex.add_note(f"Slot '{fieldName}' is declared in base-class '{baseClass.__module__}.{baseClass.__name__}'.")
1247 ex.add_note("An assignment without a type annotation creates a class attribute, which hides the slot's descriptor.")
1248 ex.add_note("Reading the field works, but assigning it on an instance raises an AttributeError.")
1249 else:
1250 ex.add_note(f"Slot '{fieldName}' is contributed by a mixin-class and materialized in this class' '__slots__'.")
1251 ex.add_note("Python doesn't allow a name to be listed in '__slots__' and assigned in the class body.")
1253 if fieldName in classFields:
1254 ex.add_note("Annotating it as 'ClassVar[...]' doesn't resolve this - the slot exists either way.")
1255 ex.add_note("Rename the class variable, or drop the slot the base-class or mixin-class declares.")
1256 else:
1257 ex.add_note("Annotate it as 'ClassVar[...]' to declare a class variable, or remove the assignment.")
1259 raise ex
1260 else:
1261 # When adding annotated fields to slottedFields, check if name was not used in inheritance hierarchy.
1262 for fieldName, typeAnnotation in annotations.items():
1263 # If annotated field is a ClassVar, and it has an initial value
1264 # * copy field and initial value to classFields dictionary
1265 # * remove field from members
1266 if self._isClassVariable(typeAnnotation) and fieldName in members:
1267 classFields[fieldName] = members[fieldName]
1269 self._checkForUnannotatedFields(className, members, annotations)
1271 if mixin:
1272 mixinSlots.extend(slottedFields)
1273 members["__slotted__"] = True
1274 members["__slots__"] = tuple()
1275 members["__allSlots__"] = set()
1276 members["__isMixin__"] = True
1277 members["__mixinSlots__"] = tuple(mixinSlots)
1278 elif slots:
1279 slottedFields.extend(mixinSlots)
1280 members["__slotted__"] = True
1281 members["__slots__"] = tuple(slottedFields)
1282 members["__allSlots__"] = set(chain(slottedFields, inheritedSlottedFields.keys()))
1283 members["__isMixin__"] = False
1284 members["__mixinSlots__"] = tuple()
1285 else:
1286 members["__slotted__"] = False
1287 # NO __slots__
1288 # members["__allSlots__"] = set()
1289 members["__isMixin__"] = False
1290 members["__mixinSlots__"] = tuple()
1291 return classFields, objectFields
1293 @classmethod
1294 def _aggregateMixinSlots(self, className: str, baseClasses: tuple[type]) -> list[str]:
1295 """
1296 Aggregate slot names requested by mixin-base-classes.
1298 .. todo::
1300 Describe algorithm.
1302 :param className: The name of the class to construct.
1303 :param baseClasses: The tuple of :term:`base-classes <base-class>` the class is derived from.
1304 :returns: A list of slot names.
1305 :raises BaseClassWithNonEmptySlotsError: If a mixin-class uses non-empty slots. |br|
1306 In Python, only one inheritance branch can use non-empty ``__slots__``.
1307 """
1308 mixinSlots = []
1309 if len(baseClasses) > 0:
1310 # If class has base-classes ensure only the primary inheritance path uses slots and all secondary inheritance
1311 # paths have an empty slots tuple. Otherwise, raise a BaseClassWithNonEmptySlotsError.
1312 inheritancePaths = [path for path in self._iterateBaseClassPaths(baseClasses)]
1313 primaryInharitancePath: set[type] = set(inheritancePaths[0])
1314 for typePath in inheritancePaths[1:]:
1315 for t in typePath:
1316 if hasattr(t, "__slots__") and len(t.__slots__) != 0 and t not in primaryInharitancePath:
1317 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}'.")
1318 ex.add_note("In Python, only one inheritance branch can use non-empty __slots__.")
1319 # ex.add_note(f"With ExtendedType, only the primary base-class can use non-empty __slots__.")
1320 # ex.add_note(f"Secondary base-classes should be marked as mixin-classes.")
1321 raise ex
1323 # If current class is set to be a mixin, then aggregate all mixinSlots in a list.
1324 # Ensure all base-classes are either constructed
1325 # * by meta-class ExtendedType, or
1326 # * use no slots, or
1327 # * are typing.Generic
1328 # If it was constructed by ExtendedType, then ensure this class itself is a mixin-class.
1329 for baseClass in baseClasses: # type: ExtendedType
1330 if isinstance(baseClass, _GenericAlias) and baseClass.__origin__ is Generic: 1330 ↛ 1331line 1330 didn't jump to line 1331 because the condition on line 1330 was never true
1331 pass
1332 elif baseClass.__class__ is self and baseClass.__isMixin__:
1333 mixinSlots.extend(baseClass.__mixinSlots__)
1334 elif hasattr(baseClass, "__mixinSlots__"):
1335 mixinSlots.extend(baseClass.__mixinSlots__)
1337 return mixinSlots
1339 @classmethod
1340 def _iterateBaseClasses(metacls, baseClasses: tuple[type]) -> Generator[type, None, None]:
1341 """
1342 Return a generator to iterate (visit) all base-classes ...
1344 .. todo::
1346 Describe iteration order.
1348 :param baseClasses: The tuple of :term:`base-classes <base-class>` the class is derived from.
1349 :returns: Generator to iterate all base-classes.
1350 """
1351 if len(baseClasses) == 0:
1352 return
1354 visited: set[type] = set()
1355 iteratorStack: list[Iterator[type]] = list()
1357 for baseClass in baseClasses:
1358 yield baseClass
1359 visited.add(baseClass)
1360 iteratorStack.append(iter(baseClass.__bases__))
1362 while True:
1363 try:
1364 base = next(iteratorStack[-1]) # type: type
1365 if base not in visited: 1365 ↛ 1370line 1365 didn't jump to line 1370 because the condition on line 1365 was always true
1366 yield base
1367 if len(base.__bases__) > 0:
1368 iteratorStack.append(iter(base.__bases__))
1369 else:
1370 continue
1372 except StopIteration:
1373 iteratorStack.pop()
1375 if len(iteratorStack) == 0:
1376 break
1378 @classmethod
1379 def _iterateBaseClassPaths(metacls, baseClasses: tuple[type]) -> Generator[tuple[type, ...], None, None]:
1380 """
1381 Return a generator to iterate all possible inheritance paths for a given list of base-classes.
1383 An inheritance path is expressed as a tuple of base-classes from current base-class (left-most item) to
1384 :class:`object` (right-most item).
1386 :param baseClasses: The tuple of :term:`base-classes <base-class>` the class is derived from.
1387 :returns: Generator to iterate all inheritance paths. |br|
1388 An inheritance path is a tuple of types (base-classes).
1389 """
1390 if len(baseClasses) == 0: 1390 ↛ 1391line 1390 didn't jump to line 1391 because the condition on line 1390 was never true
1391 return
1393 typeStack: list[type] = list()
1394 iteratorStack: list[Iterator[type]] = list()
1396 for baseClass in baseClasses:
1397 typeStack.append(baseClass)
1398 iteratorStack.append(iter(baseClass.__bases__))
1400 while True:
1401 try:
1402 base = next(iteratorStack[-1]) # type: type
1403 typeStack.append(base)
1404 if len(base.__bases__) == 0:
1405 yield tuple(typeStack)
1406 typeStack.pop()
1407 else:
1408 iteratorStack.append(iter(base.__bases__))
1410 except StopIteration:
1411 typeStack.pop()
1412 iteratorStack.pop()
1414 if len(typeStack) == 0:
1415 break
1417 @classmethod
1418 def _checkForAbstractMethods(
1419 metacls,
1420 baseClasses: tuple[type],
1421 members: dict[str, Any]
1422 ) -> tuple[dict[str, Callable[..., Any]], dict[str, Any]]:
1423 """
1424 Check if the current class contains abstract methods and return a tuple of them.
1426 These abstract methods might be inherited from any base-class. If there are inherited abstract methods, check if
1427 they are now implemented (overridden) by the current class that's right now constructed.
1429 :param baseClasses: The tuple of :term:`base-classes <base-class>` the class is derived from.
1430 :param members: The dictionary of members for the constructed class.
1431 :returns: A tuple of abstract method's names.
1432 """
1433 abstractMethods = {}
1434 if baseClasses:
1435 # Aggregate all abstract methods from all base-classes.
1436 for baseClass in baseClasses:
1437 if hasattr(baseClass, "__abstractMethods__"):
1438 abstractMethods.update(baseClass.__abstractMethods__)
1440 for base in baseClasses:
1441 for memberName, member in base.__dict__.items():
1442 # A method the new class defines itself is the implementation; it must not be replaced by the one an
1443 # inheritance branch happens to carry (pyTooling #297).
1444 if memberName in members:
1445 continue
1447 if (memberName in abstractMethods and isinstance(member, FunctionType) and
1448 not (hasattr(member, "__abstract__") or hasattr(member, "__mustOverride__"))):
1449 def outer(method):
1450 """
1451 Nested function creating a wrapper, so the abstract method of the base-class isn't modified itself.
1453 :param method: The inherited abstract method.
1454 :returns: A wrapper forwarding to that method.
1455 """
1456 @wraps(method)
1457 def inner(cls, *args: Any, **kwargs: Any):
1458 """
1459 Wrapper forwarding to the inherited abstract method.
1461 :param cls: The class the method is called on.
1462 :param args: Positional parameters passed to the method.
1463 :param kwargs: Named parameters passed to the method.
1464 :returns: Whatever the wrapped method returns.
1465 """
1466 return method(cls, *args, **kwargs)
1468 # ':func:`~functools.wraps` copies the wrapped function's '__dict__', which carries the
1469 # bookkeeping ExtendedType attached to it. The wrapper belongs to the class being
1470 # constructed, so the owner is dropped and '_findMethods' assigns the new one.
1471 inner.__dict__.pop("__classobj__", None)
1473 return inner
1475 members[memberName] = outer(member)
1477 # Check if methods are marked:
1478 # * If so, add them to list of abstract methods
1479 # * If not, method is now implemented and removed from list
1480 for memberName, member in members.items():
1481 if callable(member):
1482 if ((hasattr(member, "__abstract__") and member.__abstract__) or
1483 (hasattr(member, "__mustOverride__") and member.__mustOverride__)):
1484 abstractMethods[memberName] = member
1485 elif memberName in abstractMethods:
1486 del abstractMethods[memberName]
1488 return abstractMethods, members
1490 @classmethod
1491 def _wrapNewMethodIfSingleton(metacls, newClass, singleton: bool) -> bool:
1492 """
1493 If a class is a singleton, wrap the ``_new__`` method, so it returns a cached object, if a first object was created.
1495 Only the first object creation initializes the object.
1497 This implementation is threadsafe.
1499 :param newClass: The newly constructed class for further modifications.
1500 :param singleton: Optional, if ``True``, the class allows only a single instance to exist.
1501 :returns: ``True``, if the class is a singleton.
1502 """
1503 if hasattr(newClass, "__isSingleton__"):
1504 singleton = newClass.__isSingleton__
1506 if singleton:
1507 oldnew = newClass.__new__
1508 if hasattr(oldnew, "__singleton_wrapper__"):
1509 oldnew = oldnew.__wrapped__
1511 oldinit = newClass.__init__
1512 if hasattr(oldinit, "__singleton_wrapper__"):
1513 oldinit = oldinit.__wrapped__
1515 @wraps(oldnew)
1516 def singleton_new(cls, *args: Any, **kwargs: Any):
1517 """
1518 Replacement ``__new__`` method, which returns the singleton's one instance.
1520 The first call creates the object and caches it; every further call returns the cached object. The
1521 condition variable makes that safe when several threads instantiate the class at once.
1523 :param cls: The class being instantiated.
1524 :param args: Positional parameters passed to the original ``__new__``.
1525 :param kwargs: Named parameters passed to the original ``__new__``.
1526 :returns: The singleton's instance.
1527 """
1528 with cls.__singletonInstanceCond__:
1529 if cls.__singletonInstanceCache__ is None:
1530 obj = oldnew(cls, *args, **kwargs)
1531 cls.__singletonInstanceCache__ = obj
1532 else:
1533 obj = cls.__singletonInstanceCache__
1535 return obj
1537 @wraps(oldinit)
1538 def singleton_init(self, *args: Any, **kwargs: Any):
1539 """
1540 Replacement ``__init__`` method, which initializes the singleton's instance exactly once.
1542 A further instantiation waits until the first one finished initializing, so it never sees a half-built object.
1544 :param args: Positional parameters passed to the original ``__init__``.
1545 :param kwargs: Named parameters passed to the original ``__init__``.
1546 :raises ValueError: If a further instantiation passes parameters, which would be silently ignored.
1547 """
1548 cls = self.__class__
1549 cv = cls.__singletonInstanceCond__
1550 with cv:
1551 if cls.__singletonInstanceInit__:
1552 oldinit(self, *args, **kwargs)
1553 cls.__singletonInstanceInit__ = False
1554 cv.notify_all()
1555 elif args or kwargs:
1556 raise ValueError("A further instance of a singleton can't be reinitialized with parameters.")
1557 else:
1558 while cls.__singletonInstanceInit__: 1558 ↛ 1559line 1558 didn't jump to line 1559 because the condition on line 1558 was never true
1559 cv.wait()
1561 singleton_new.__singleton_wrapper__ = True
1562 singleton_init.__singleton_wrapper__ = True
1564 newClass.__new__ = singleton_new
1565 newClass.__init__ = singleton_init
1566 newClass.__singletonInstanceCond__ = Condition()
1567 newClass.__singletonInstanceInit__ = True
1568 newClass.__singletonInstanceCache__ = None
1569 return True
1571 return False
1573 @classmethod
1574 def _collectExpectedMembers(
1575 metacls,
1576 className: str,
1577 baseClasses: tuple[type, ...],
1578 members: dict[str, Any],
1579 expects: Iterable[str]
1580 ) -> dict[str, str]:
1581 """
1582 Collect the members expected by this class and by every class in its inheritance hierarchy.
1584 A mixin-class states what it needs from the class it is mixed into, and that expectation has to survive until a
1585 concrete class can satisfy it. The result maps each expected member's name to the name of the class expecting
1586 it, so an error message can name the origin.
1588 :param className: The name of the class being constructed.
1589 :param baseClasses: The tuple of base-classes the class is derived from.
1590 :param members: The dictionary of members for the constructed class.
1591 :param expects: Names of members the class being constructed expects.
1592 :returns: Dictionary mapping an expected member's name to the name of the class expecting it.
1593 :raises TypeError: If parameter 'expects' is not an iterable of strings.
1594 :raises TypeError: If an element of parameter 'expects' is not a string.
1595 """
1596 if isinstance(expects, str) or not isinstance(expects, Iterable): 1596 ↛ 1597line 1596 didn't jump to line 1597 because the condition on line 1596 was never true
1597 ex = TypeError(f"Parameter 'expects' is not an iterable of strings.")
1598 ex.add_note(f"Got type '{getFullyQualifiedName(expects)}'.")
1599 raise ex
1601 expects = tuple(expects)
1602 for memberName in expects:
1603 if not isinstance(memberName, str): 1603 ↛ 1604line 1603 didn't jump to line 1604 because the condition on line 1603 was never true
1604 ex = TypeError(f"Parameter 'expects' contains an element that is not a string.")
1605 ex.add_note(f"Got type '{getFullyQualifiedName(memberName)}'.")
1606 raise ex
1608 expected: dict[str, str] = {}
1609 for baseClass in baseClasses:
1610 for memberName, origin in getattr(baseClass, "__expectedMembers__", {}).items():
1611 expected.setdefault(memberName, origin)
1613 # a class recreated by '@mixin' and friends carries its own expectations in the copied members
1614 for memberName, origin in members.get("__expectedMembers__", {}).items():
1615 expected.setdefault(memberName, origin)
1617 for memberName in expects:
1618 expected.setdefault(memberName, className)
1620 return expected
1622 @classmethod
1623 def _computeMissingMembers(metacls, newClass: type, mixin: bool) -> tuple[str, ...]:
1624 """
1625 Determine which of the expected members the class doesn't provide.
1627 A member is provided when it is reachable on the class: a method, a property, a class variable, or a field, for
1628 which :class:`ExtendedType` created a slot descriptor when the mixin-class joined the primary inheritance line.
1630 A mixin-class is never missing anything, because it can't provide what it expects from its host class.
1632 :param newClass: The newly constructed class.
1633 :param mixin: ``True``, if the class is a mixin-class.
1634 :returns: Tuple of expected member names the class doesn't provide.
1635 """
1636 if mixin:
1637 return tuple()
1639 return tuple(memberName for memberName in newClass.__expectedMembers__ if not hasattr(newClass, memberName))
1641 @classmethod
1642 def _wrapNewMethodIfExpectationUnfulfilled(metacls, newClass) -> bool:
1643 """
1644 If the class doesn't provide every expected member, replace the ``__new__`` method, so it raises an exception.
1646 The diagnosis happens here, at class construction time, but the exception is raised on instantiation - the same
1647 way an abstract class is handled. A class is allowed to stay incomplete as long as nothing instantiates it, so
1648 an intermediate class can pass an expectation on to its own subclasses.
1650 :param newClass: The newly constructed class for further modifications.
1651 :returns: ``True``, if the class has unfulfilled expectations.
1652 """
1653 if len(newClass.__missingMembers__) == 0:
1654 # skip an intermediate 'new' function if the class fulfills its expectations again
1655 oldnew = newClass.__new__
1656 if hasattr(oldnew, "__raises_unfulfilled_expectation_error__"):
1657 newClass.__new__ = oldnew.__wrapped__
1659 return False
1661 oldnew = newClass.__new__
1662 if hasattr(oldnew, "__raises_unfulfilled_expectation_error__"):
1663 oldnew = oldnew.__wrapped__
1665 @wraps(oldnew)
1666 def unfulfilled_new(cls, *_, **__):
1667 """
1668 Replacement ``__new__`` method, which rejects the instantiation of a class with unfulfilled expectations.
1670 :param cls: The class an instance was requested of.
1671 :raises UnfulfilledExpectationError: Always, because the class doesn't provide every expected member.
1672 """
1673 ex = UnfulfilledExpectationError(f"Class '{cls.__name__}' doesn't provide every expected member.")
1674 for memberName in newClass.__missingMembers__:
1675 ex.add_note(f"Missing '{memberName}', expected by '{newClass.__expectedMembers__[memberName]}'.")
1676 raise ex
1678 unfulfilled_new.__raises_unfulfilled_expectation_error__ = True
1680 newClass.__new__ = unfulfilled_new
1681 return True
1683 @classmethod
1684 def _wrapMethodsWithUnfulfilledExpectations(metacls, newClass) -> tuple[str, ...]:
1685 """
1686 Replace every method marked with :deco:`expects` whose expected members the class doesn't provide.
1688 The replacement raises an :exc:`UnfulfilledExpectationError` when it is called, so the class stays usable and
1689 only the method that can't work is rejected. A method whose expectation is fulfilled is left alone - the
1690 original function stays in the class, so a fulfilled expectation costs nothing per call. A replacement
1691 inherited from a base-class is removed again as soon as a class provides the missing members.
1693 :param newClass: The newly constructed class for further modifications.
1694 :returns: Tuple of method names that were replaced.
1695 """
1696 # Collect every marked method reachable on this class, looking underneath a replacement from a base-class
1697 marked: dict[str, Callable[..., Any]] = {}
1698 for baseClass in reversed(newClass.__mro__):
1699 for memberName, member in vars(baseClass).items():
1700 original = getattr(member, "__wrapped__", member)
1701 if hasattr(original, "__expectedMembers__"):
1702 marked[memberName] = original
1704 wrapped: list[str] = []
1705 for memberName, original in marked.items():
1706 missing = tuple(name for name in original.__expectedMembers__ if not hasattr(newClass, name))
1707 inherited = getattr(newClass, memberName)
1708 isReplaced = hasattr(inherited, "__raises_unfulfilled_expectation_error__")
1710 if len(missing) == 0:
1711 # the class provides the members now, so the original method is reachable again
1712 if isReplaced:
1713 setattr(newClass, memberName, original)
1714 continue
1716 wrapped.append(memberName)
1717 if isReplaced and inherited.__missingMembers__ == missing:
1718 # the inherited replacement already reports exactly these members
1719 continue
1721 def unfulfilledMethodFactory(methodName: str, missingMembers: tuple[str, ...]) -> Callable[..., NoReturn]:
1722 """
1723 Create the replacement for one method, binding the names it is missing.
1725 :param methodName: Name of the method that is replaced.
1726 :param missingMembers: Names of the members the method needs and the class doesn't provide.
1727 :returns: Replacement method raising an :exc:`UnfulfilledExpectationError`.
1728 """
1729 @wraps(original)
1730 def unfulfilledMethod(self, *_, **__) -> NoReturn:
1731 """
1732 Replacement method, which rejects a call that would fail on a missing member.
1734 :raises UnfulfilledExpectationError: Always, because the class doesn't provide every expected member.
1735 """
1736 message = f"Method '{type(self).__name__}.{methodName}()' expects members this class doesn't provide."
1737 ex = UnfulfilledExpectationError(message)
1738 for memberName in missingMembers:
1739 ex.add_note(f"Missing '{memberName}'.")
1740 raise ex
1742 return unfulfilledMethod
1744 replacement = unfulfilledMethodFactory(memberName, missing)
1746 replacement.__raises_unfulfilled_expectation_error__ = True
1747 replacement.__missingMembers__ = missing
1749 setattr(newClass, memberName, replacement)
1751 return tuple(wrapped)
1753 @classmethod
1754 def _wrapNewMethodIfAbstract(metacls, newClass) -> bool:
1755 """
1756 If the class is marked ``__abstractClass__`` or has abstract methods, replace the ``_new__`` method, so it
1757 raises an exception.
1759 :param newClass: The newly constructed class for further modifications.
1760 :returns: ``True``, if the class is abstract.
1761 :raises AbstractClassError: If the class is abstract and can't be instantiated.
1762 :raises ExtendedTypeError: If a singleton wrapper was found around a method raising
1763 :exc:`AbstractClassError`, which is not handled yet.
1764 """
1765 # Replace '__new__' by a variant to throw an error on not overridden methods
1766 if newClass.__abstractClass__ or len(newClass.__abstractMethods__) > 0:
1767 oldnew = newClass.__new__
1768 if hasattr(oldnew, "__raises_abstract_class_error__"):
1769 oldnew = oldnew.__wrapped__
1771 @wraps(oldnew)
1772 def abstract_new(cls, *_, **__):
1773 """
1774 Replacement ``__new__`` method, which rejects the instantiation of an abstract class.
1776 The message names the methods to override, or says that the class needs to be derived when it was declared
1777 abstract without having abstract methods.
1779 :param cls: The abstract class an instance was requested of.
1780 :raises AbstractClassError: Always, because an abstract class can't be instantiated.
1781 """
1782 if len(newClass.__abstractMethods__) > 0:
1783 raise AbstractClassError(f"""Class '{cls.__name__}' is abstract. The following methods: '{"', '".join(newClass.__abstractMethods__)}' need to be overridden in a derived class.""")
1784 else:
1785 raise AbstractClassError(f"Class '{cls.__name__}' is abstract and needs to be derived.")
1787 abstract_new.__raises_abstract_class_error__ = True
1789 newClass.__new__ = abstract_new
1790 return True
1792 # Handle classes which are not abstract, especially derived classes, if not abstract anymore
1793 else:
1794 # skip intermediate 'new' function if class isn't abstract anymore
1795 try:
1796 if newClass.__new__.__raises_abstract_class_error__: 1796 ↛ 1818line 1796 didn't jump to line 1818 because the condition on line 1796 was always true
1797 origNew = newClass.__new__.__wrapped__
1799 # WORKAROUND: __new__ checks tp_new and implements different behavior
1800 # Bugreport: https://github.com/python/cpython/issues/105888
1801 if origNew is object.__new__:
1802 @wraps(object.__new__)
1803 def wrapped_new(inst, *_, **__):
1804 """
1805 Replacement ``__new__`` method for a class that isn't abstract anymore.
1807 It calls :meth:`object.__new__` with the class only, because that implementation rejects further
1808 parameters.
1810 :param inst: The class being instantiated.
1811 :returns: The new instance.
1812 """
1813 return object.__new__(inst)
1815 newClass.__new__ = wrapped_new
1816 else:
1817 newClass.__new__ = origNew
1818 elif newClass.__new__.__isSingleton__:
1819 raise ExtendedTypeError(
1820 "Found a singleton wrapper around an AbstractError raising method. This case is not handled yet."
1821 )
1822 except AttributeError as ex:
1823 if ex.name != "__raises_abstract_class_error__": 1823 ↛ 1824line 1823 didn't jump to line 1824 because the condition on line 1823 was never true
1824 raise ex
1826 return False
1828 # Additional properties and methods on a class
1829 @readonly
1830 def HasClassAttributes(self) -> bool:
1831 """
1832 Read-only property to check if the class has Attributes (:attr:`__pyattr__`).
1834 :returns: ``True``, if the class has Attributes.
1835 """
1836 try:
1837 return len(self.__pyattr__) > 0
1838 except AttributeError:
1839 return False
1841 @readonly
1842 def HasMethodAttributes(self) -> bool:
1843 """
1844 Read-only property to check if the class has methods with Attributes (:attr:`__methodsWithAttributes__`).
1846 :returns: ``True``, if the class has any method with Attributes.
1847 """
1848 try:
1849 return len(self.__methodsWithAttributes__) > 0
1850 except AttributeError:
1851 return False
1854@export
1855class SlottedObject(metaclass=ExtendedType, slots=True):
1856 """Classes derived from this class will store all members in ``__slots__``."""