Coverage for pyTooling/MetaClasses/__init__.py: 94%
774 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-25 12:33 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-25 12:33 +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 re import compile as re_compile
52from sys import modules, version_info
53from threading import Condition
54from types import BuiltinFunctionType, CodeType, FunctionType, MemberDescriptorType, MethodType
55from typing import Any, Callable, Generator, Iterator, Iterable, NoReturn, Protocol, Self, TypeVar, \
56 Generic, _GenericAlias, _get_protocol_attrs, ClassVar, Optional as Nullable
58from pyTooling.Common import getFullyQualifiedName, getTypeName
59from pyTooling.Exceptions import ToolingException
60from pyTooling.Decorators import export, readonly
61from pyTooling.Warning import Warning, WarningCollector
62from pyTooling.Attributes import ATTRIBUTES_MEMBER_NAME, Attribute, AttributeScope, TAttr, TAttributeFilter
65__all__ = ["M"]
68@export
69class ThisClass:
70 """
71 Sentinel for a class variable whose value is the class declaring it.
73 A class doesn't exist while its body runs, so a class variable can't name it. Setting the variable to this
74 sentinel says *"the class I am declared in"*, and :class:`ExtendedType` rebinds it once the class exists.
76 .. code-block:: python
78 class Workflow(metaclass=ExtendedType, slots=True):
79 _PARENT_TYPE: ClassVar[Nullable[type]] = ThisClass # a workflow is contained in a workflow
81 assert Workflow._PARENT_TYPE is Workflow
83 Only a variable the class **declared** is rebound; an inherited one keeps the value its own class resolved. It is
84 an empty class rather than a bare object, so a variable annotated as a :class:`type` still type-checks.
86 .. note::
88 :pep:`661` adds a ``sentinel`` builtin in Python 3.15, and ``ThisClass = sentinel("ThisClass")`` would give a
89 better :func:`repr` and identity through :mod:`pickle` and :mod:`copy`. It is **not** used here: a sentinel is
90 an *instance*, so a variable annotated :class:`type` would no longer type-check, and this sentinel never
91 survives class creation anyway - it is replaced before anyone can pickle or print it. :class:`ExtendedType`
92 compares by identity, so swapping the definition is a one-line change if that day comes.
93 """
96@export
97class ExtendedTypeError(ToolingException):
98 """The exception is raised by the meta-class :class:`~pyTooling.MetaClasses.ExtendedType`."""
101@export
102class InvalidBaseClassError(ExtendedTypeError):
103 """
104 Common base of the exceptions raised when a base-class can't be combined with the class under construction.
106 It carries the base-class and the name of the class under construction. The class itself doesn't exist yet when the
107 error is raised, so it is identified by its name.
108 """
110 _baseClass: Nullable[type] #: Base-class that can't be combined with the class under construction.
111 _className: Nullable[str] #: Name of the class under construction.
113 def __init__(self, message: str, /, *, baseClass: Nullable[type] = None, className: Nullable[str] = None) -> None:
114 """
115 Initializes the exception with the base-class and the name of the class under construction.
117 :param message: The exception's message.
118 :param baseClass: Optional, the base-class that can't be combined with the class under construction.
119 :param className: Optional, the name of the class under construction.
120 """
121 super().__init__(message)
122 self._baseClass = baseClass
123 self._className = className
125 @readonly
126 def BaseClass(self) -> Nullable[type]:
127 """
128 Read-only property to access the base-class that can't be combined with the class under construction
129 (:attr:`_baseClass`).
131 :returns: The base-class, or ``None`` if it wasn't recorded.
132 """
133 return self._baseClass
135 @readonly
136 def ClassName(self) -> Nullable[str]:
137 """
138 Read-only property to access the name of the class under construction (:attr:`_className`).
140 :returns: The class' name, or ``None`` if it wasn't recorded.
141 """
142 return self._className
145@export
146class BaseClassWithoutSlotsError(InvalidBaseClassError):
147 """
148 This exception is raised when a class using ``__slots__`` inherits from at-least one base-class not using ``__slots__``.
150 .. seealso::
152 :ref:`Python data model for slots <slots>`
153 |rarr| What ``__slots__`` does and which rules Python imposes on it.
154 :term:`Glossary entry __slots__ <__slots__>`
155 |rarr| The glossary's short definition.
156 """
159@export
160class BaseClassWithNonEmptySlotsError(InvalidBaseClassError):
161 """
162 This exception is raised when a mixin-class uses slots, but Python prohibits slots.
164 .. important::
166 To fulfill Python's requirements on slots, pyTooling uses slots only on the prinmary inheritance line.
167 Mixin-classes collect slots, which get materialized when the mixin-class (secondary inheritance lines) gets merged
168 into the primary inheritance line.
169 """
172@export
173class BaseClassIsNotAMixinError(InvalidBaseClassError):
174 """
175 This exception is raised when a secondary base-class is a slotted class, but not declared as a mixin.
177 Only the primary inheritance line may carry a slotted class; every further base-class needs ``mixin=True`` (or the
178 :deco:`~pyTooling.MetaClasses.mixin` decorator), because that is what allows their slots to be merged.
179 """
182@export
183class DuplicateFieldInSlotsError(ExtendedTypeError):
184 """
185 This exception is raised when a slot name is used multiple times within the inheritance hierarchy.
186 """
188 _fieldName: Nullable[str] #: Name of the slot that is used multiple times.
189 _className: Nullable[str] #: Name of the class under construction, whose hierarchy uses the slot name multiple times.
191 def __init__(self, message: str, /, *, fieldName: Nullable[str] = None, className: Nullable[str] = None) -> None:
192 """
193 Initializes the exception with the slot name used multiple times and the class under construction.
195 :param message: The exception's message.
196 :param fieldName: Optional, the name of the slot that is used multiple times.
197 :param className: Optional, the name of the class under construction.
198 """
199 super().__init__(message)
200 self._fieldName = fieldName
201 self._className = className
203 @readonly
204 def FieldName(self) -> Nullable[str]:
205 """
206 Read-only property to access the name of the slot that is used multiple times (:attr:`_fieldName`).
208 :returns: The slot's name, or ``None`` if it wasn't recorded.
209 """
210 return self._fieldName
212 @readonly
213 def ClassName(self) -> Nullable[str]:
214 """
215 Read-only property to access the name of the class under construction (:attr:`_className`).
217 :returns: The class' name, or ``None`` if it wasn't recorded.
218 """
219 return self._className
222@export
223class UnannotatedFieldWarning(Warning):
224 """
225 A class declares a field that was assigned in the class body without a type annotation.
227 An object field is annotated with its type, a class variable with :class:`~typing.ClassVar`. Without an annotation,
228 :class:`ExtendedType` can't tell the two apart, and the field never becomes a slot.
230 .. attention::
232 It's a non-critical warning: without a :class:`~pyTooling.Warning.WarningCollector` up the call-hierarchy, it's
233 dropped silently.
234 """
236 _className: Nullable[str] #: Name of the class under construction.
237 _fieldNames: tuple[str, ...] #: Names of the fields without a type annotation.
239 def __init__(
240 self,
241 message: str,
242 /, *,
243 className: Nullable[str] = None,
244 fieldNames: Nullable[Iterable[str]] = None
245 ) -> None:
246 """
247 Initializes the warning with the class and its fields without a type annotation.
249 :param message: The warning's message.
250 :param className: Optional, the name of the class under construction.
251 :param fieldNames: Optional, the names of the fields without a type annotation.
252 """
253 super().__init__(message)
254 self._className = className
255 self._fieldNames = () if fieldNames is None else tuple(fieldNames)
257 @readonly
258 def ClassName(self) -> Nullable[str]:
259 """
260 Read-only property to access the name of the class under construction (:attr:`_className`).
262 The class isn't created yet when the warning is reported, so it carries the name.
264 :returns: The class' name, or ``None`` if it wasn't recorded.
265 """
266 return self._className
268 @readonly
269 def FieldNames(self) -> tuple[str, ...]:
270 """
271 Read-only property to access the names of the fields without a type annotation (:attr:`_fieldNames`).
273 :returns: Tuple of field names; empty if they weren't recorded.
274 """
275 return self._fieldNames
278@export
279class UnfulfilledExpectationError(ExtendedTypeError):
280 """
281 This exception is raised when a class doesn't provide the members a mixin-class in its hierarchy expects.
283 An expectation is declared in one of two places, and this exception is raised at the matching moment:
285 * A **class** lists what it needs from whichever class it is mixed into, with the ``expects`` class keyword
286 argument. Which members are missing is determined when the class is constructed, and instantiating a class that
287 still misses one raises this exception. A class may stay incomplete as long as nothing instantiates it, so an
288 intermediate class can pass the expectation on to its own subclasses.
289 * A single **method** lists what it needs from its class, with the :deco:`expects` decorator. The class stays
290 usable; only calling that method raises this exception.
292 Either way the alternative is an :exc:`AttributeError` on first access, somewhere else entirely, and only if that
293 code path runs.
295 .. seealso::
297 :exc:`~pyTooling.MetaClasses.AbstractClassError`
298 |rarr| The same mechanism, for a class with methods that still need to be overridden.
299 :deco:`~pyTooling.MetaClasses.expects`
300 |rarr| Mark a *method* as needing members its class provides only in some combinations.
301 :class:`~pyTooling.MetaClasses.ExtendedType`
302 |rarr| The meta-class implementing the check.
303 """
305 _incompleteClass: Nullable[type] #: Class that doesn't provide every expected member.
306 _missingMembers: Nullable[tuple[str, ...]] #: Names of the expected members the class doesn't provide.
308 def __init__(
309 self,
310 message: str,
311 /, *,
312 incompleteClass: Nullable[type] = None,
313 missingMembers: Nullable[Iterable[str]] = None
314 ) -> None:
315 """
316 Initializes the exception with the class and the names of the members it doesn't provide.
318 :param message: The exception's message.
319 :param incompleteClass: Optional, the class that doesn't provide every expected member.
320 :param missingMembers: Optional, the names of the expected members the class doesn't provide.
321 """
322 super().__init__(message)
323 self._incompleteClass = incompleteClass
324 self._missingMembers = None if missingMembers is None else tuple(missingMembers)
326 @readonly
327 def IncompleteClass(self) -> Nullable[type]:
328 """
329 Read-only property to access the class that doesn't provide every expected member (:attr:`_incompleteClass`).
331 :returns: The class, or ``None`` if it wasn't recorded.
332 """
333 return self._incompleteClass
335 @readonly
336 def MissingMembers(self) -> Nullable[tuple[str, ...]]:
337 """
338 Read-only property to access the names of the expected members the class doesn't provide (:attr:`_missingMembers`).
340 :returns: Tuple of member names, or ``None`` if they weren't recorded.
341 """
342 return self._missingMembers
345@export
346class IncompatibleMetaClassError(ExtendedTypeError):
347 """
348 This exception is raised when a class decorated with :deco:`slotted`, :deco:`mixin` or :deco:`singleton` uses a
349 meta-class that is neither :class:`type` nor derived from :class:`~pyTooling.MetaClasses.ExtendedType`.
350 """
352 _decoratedClass: Nullable[type] #: Class decorated with :deco:`slotted`, :deco:`mixin` or :deco:`singleton`.
353 _metaClass: Nullable[type] #: Incompatible meta-class of the decorated class.
355 def __init__(
356 self,
357 message: str,
358 /, *,
359 decoratedClass: Nullable[type] = None,
360 metaClass: Nullable[type] = None
361 ) -> None:
362 """
363 Initializes the exception with the decorated class and its incompatible meta-class.
365 :param message: The exception's message.
366 :param decoratedClass: Optional, the decorated class.
367 :param metaClass: Optional, the incompatible meta-class of the decorated class.
368 """
369 super().__init__(message)
370 self._decoratedClass = decoratedClass
371 self._metaClass = metaClass
373 @readonly
374 def DecoratedClass(self) -> Nullable[type]:
375 """
376 Read-only property to access the decorated class (:attr:`_decoratedClass`).
378 :returns: The decorated class, or ``None`` if it wasn't recorded.
379 """
380 return self._decoratedClass
382 @readonly
383 def MetaClass(self) -> Nullable[type]:
384 """
385 Read-only property to access the incompatible meta-class of the decorated class (:attr:`_metaClass`).
387 :returns: The meta-class, or ``None`` if it wasn't recorded.
388 """
389 return self._metaClass
392@export
393class AbstractClassError(ExtendedTypeError):
394 """
395 This exception is raised, when a class contains methods marked with *abstractmethod* or *must-override*.
397 .. seealso::
399 :deco:`~pyTooling.MetaClasses.abstractmethod`
400 |rarr| Mark a method as *abstract*.
401 :deco:`~pyTooling.MetaClasses.mustoverride`
402 |rarr| Mark a method as *must overrride*.
403 :exc:`~MustOverrideClassError`
404 |rarr| Exception raised, if a method is marked as *must-override*.
405 """
407 _abstractClass: Nullable[type] #: Abstract class an instance was requested of.
408 _abstractMethods: Nullable[tuple[str, ...]] #: Names of the methods that need to be overridden.
410 def __init__(
411 self,
412 message: str,
413 /, *,
414 abstractClass: Nullable[type] = None,
415 abstractMethods: Nullable[Iterable[str]] = None
416 ) -> None:
417 """
418 Initializes the exception with the abstract class and the names of the methods to override.
420 :param message: The exception's message.
421 :param abstractClass: Optional, the abstract class an instance was requested of.
422 :param abstractMethods: Optional, the names of the methods that need to be overridden. Empty for a class declared
423 abstract without abstract methods.
424 """
425 super().__init__(message)
426 self._abstractClass = abstractClass
427 self._abstractMethods = None if abstractMethods is None else tuple(abstractMethods)
429 @readonly
430 def AbstractClass(self) -> Nullable[type]:
431 """
432 Read-only property to access the abstract class an instance was requested of (:attr:`_abstractClass`).
434 :returns: The abstract class, or ``None`` if it wasn't recorded.
435 """
436 return self._abstractClass
438 @readonly
439 def AbstractMethods(self) -> Nullable[tuple[str, ...]]:
440 """
441 Read-only property to access the names of the methods that need to be overridden (:attr:`_abstractMethods`).
443 :returns: Tuple of method names (empty for a class declared abstract without abstract methods), or ``None`` if they
444 weren't recorded.
445 """
446 return self._abstractMethods
449@export
450class MustOverrideClassError(AbstractClassError):
451 """
452 This exception is raised, when a class contains methods marked with *must-override*.
454 .. seealso::
456 :deco:`~pyTooling.MetaClasses.abstractmethod`
457 |rarr| Mark a method as *abstract*.
458 :deco:`~pyTooling.MetaClasses.mustoverride`
459 |rarr| Mark a method as *must overrride*.
460 :exc:`~AbstractClassError`
461 |rarr| Exception raised, if a method is marked as *abstract*.
462 """
465# """
466# Metaclass that allows multiple dispatch of methods based on method signatures.
467#
468# .. seealso:
469#
470# `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>`__
471# """
474M = TypeVar("M", bound=Callable[..., Any]) #: A type variable for methods.
475C = TypeVar("C", bound=type) #: A type variable for classes.
478def _recreateClass(cls: type, decoratorName: str, **options: Any) -> type:
479 """
480 Recreate a class with :class:`ExtendedType` (or the class' own compatible meta-class) applying the given options.
482 :param cls: Class to recreate.
483 :param decoratorName: Name of the calling decorator. It's used in the error message.
484 :param options: Meta-class options like ``slots``, ``mixin``, ``singleton`` or ``expects``.
485 :returns: The recreated class.
486 :raises IncompatibleMetaClassError: If the class' meta-class is neither :class:`type`, nor derived from
487 :class:`ExtendedType`. |br|
488 A decorated class must use :class:`type` or a meta-class derived from
489 :class:`~pyTooling.MetaClasses.ExtendedType`.
490 """
491 if cls.__class__ is type:
492 metacls = ExtendedType
493 elif issubclass(cls.__class__, ExtendedType):
494 metacls = cls.__class__
495 for method in cls.__methods__:
496 delattr(method, "__classobj__")
497 else:
498 metaClass = cls.__class__
499 ex = IncompatibleMetaClassError(
500 f"Class '{cls.__name__}' decorated with '@{decoratorName}' uses an incompatible meta-class.",
501 decoratedClass=cls,
502 metaClass=metaClass
503 )
504 ex.add_note(f"Meta-class is '{metaClass.__module__}.{metaClass.__name__}'.")
505 ex.add_note("A decorated class must use 'type' or a meta-class derived from 'pyTooling.MetaClasses.ExtendedType'.")
506 raise ex
508 bases = tuple(base for base in cls.__bases__ if base is not object)
509 slots = cls.__dict__["__slots__"] if "__slots__" in cls.__dict__ else tuple()
510 members = {
511 "__qualname__": cls.__qualname__
512 }
513 for key, value in cls.__dict__.items():
514 if key not in slots:
515 members[key] = value
517 return metacls(cls.__name__, bases, members, **options)
520@export
521def slotted(cls):
522 """
523 Class decorator recreating a class with slots derived from its annotated fields.
525 It is the decorator form of ``metaclass=ExtendedType, slots=True``, for a class that shouldn't name the
526 meta-class explicitly.
528 :param cls: The class to recreate.
529 :returns: The recreated class, using ``__slots__``.
531 .. seealso::
533 :deco:`~pyTooling.MetaClasses.mixin`
534 |rarr| Recreate a class as a mixin-class.
535 :deco:`~pyTooling.MetaClasses.singleton`
536 |rarr| Recreate a class as a singleton.
537 """
538 return _recreateClass(cls, "slotted", slots=True)
541@export
542def mixin(cls):
543 """
544 Class decorator recreating a class as a mixin-class.
546 A mixin-class collects its slots instead of materializing them; they are merged when the mixin joins a primary
547 inheritance line.
549 :param cls: The class to recreate.
550 :returns: The recreated class, marked as a mixin.
552 .. seealso::
554 :deco:`~pyTooling.MetaClasses.slotted`
555 |rarr| Recreate a class with slots.
556 :deco:`~pyTooling.MetaClasses.singleton`
557 |rarr| Recreate a class as a singleton.
558 """
559 return _recreateClass(cls, "mixin", mixin=True)
562@export
563def singleton(cls):
564 """
565 Class decorator recreating a class as a singleton.
567 Every instantiation of the decorated class returns the same object, including its state.
569 :param cls: The class to recreate.
570 :returns: The recreated class, marked as a singleton.
572 .. seealso::
574 :deco:`~pyTooling.MetaClasses.slotted`
575 |rarr| Recreate a class with slots.
576 :deco:`~pyTooling.MetaClasses.mixin`
577 |rarr| Recreate a class as a mixin-class.
578 """
579 return _recreateClass(cls, "singleton", singleton=True)
582@export
583def abstractclass(cls: C) -> C:
584 """
585 Mark a class as *abstract*, so it cannot be instantiated, although it has no abstract method.
587 Some classes exist only to be derived from - a base-class collecting shared infrastructure, for instance - and
588 have nothing to mark with :deco:`abstractmethod`. This decorator says so directly: it sets ``__abstractClass__``
589 on the class and recomputes ``__isAbstract__``, which replaces ``__new__`` by a method raising an
590 :exc:`~pyTooling.MetaClasses.AbstractClassError`.
592 The marker belongs to the decorated class alone. :class:`ExtendedType` clears it on every class it creates, so a
593 derived class is concrete again unless it is decorated itself or inherits an abstract method.
595 .. warning::
597 This decorator needs meta-class :class:`~pyTooling.MetaClasses.ExtendedType`, which does the computation.
599 .. admonition:: ``example.py``
601 .. code-block:: python
603 @abstractclass
604 class Base(metaclass=ExtendedType):
605 '''This class needs to be inherited.'''
607 :param cls: Class that is marked as *abstract*.
608 :returns: The same class, marked and with its abstractness recomputed.
609 :raises AttributeError: If the class was not created by :class:`ExtendedType`, because nothing would compute it. |br|
610 Add ``metaclass=ExtendedType`` to the class definition, so abstractness is computed.
612 .. seealso::
614 :exc:`~pyTooling.MetaClasses.AbstractClassError`
615 |rarr| The exception raised when a still abstract class gets instantiated.
616 :deco:`~pyTooling.MetaClasses.abstractmethod`
617 |rarr| Mark a method as *abstract* and raise a :exc:`NotImplementedError` when called.
618 :deco:`~pyTooling.MetaClasses.mustoverride`
619 |rarr| Mark a method as *mustoverride* (minimal implementation, but can be called).
620 :deco:`~pyTooling.Decorators.notimplemented`
621 |rarr| Mark a *method* as not implemented and raise a :exc:`NotImplementedError`.
622 """
623 if not isinstance(cls, ExtendedType):
624 ex = AttributeError(f"Class '{cls.__name__}' is not created by meta-class 'ExtendedType'.")
625 ex.add_note("Add 'metaclass=ExtendedType' to the class definition, so abstractness is computed.")
626 raise ex
628 cls.__abstractClass__ = True
630 if not cls.__isAbstract__:
631 cls.__isAbstract__ = ExtendedType._wrapNewMethodIfAbstract(cls)
633 return cls
636@export
637def abstractmethod(method: M) -> M:
638 """
639 Mark a method as *abstract* and replace the implementation with a new method raising a :exc:`NotImplementedError`.
641 The original method is stored in ``<method>.__wrapped__`` and it's doc-string is copied to the replacing method. In
642 additional field ``<method>.__abstract__`` is added.
644 .. warning::
646 This decorator should be used in combination with meta-class :class:`~pyTooling.MetaClasses.ExtendedType`.
647 Otherwise, an abstract class itself doesn't throw a :exc:`~pyTooling.MetaClasses.AbstractClassError` at
648 instantiation.
650 .. admonition:: ``example.py``
652 .. code-block:: python
654 class Data(mataclass=ExtendedType):
655 @abstractmethod
656 def method(self) -> bool:
657 '''This method needs to be implemented.'''
659 :param method: Method that is marked as *abstract*.
660 :returns: Replacement method, which raises a :exc:`NotImplementedError`.
662 .. seealso::
664 :exc:`~pyTooling.MetaClasses.AbstractClassError`
665 |rarr| The exception raised when a still abstract class gets instantiated.
666 :deco:`~pyTooling.MetaClasses.abstractclass`
667 |rarr| Mark a class as *abstract*.
668 :deco:`~pyTooling.MetaClasses.mustoverride`
669 |rarr| Mark a method as *mustoverride* (minimal implementation, but can be called).
670 :deco:`~pyTooling.Decorators.notimplemented`
671 |rarr| Mark a *method* as not implemented and raise a :exc:`NotImplementedError`.
672 """
673 @wraps(method)
674 def func(self) -> NoReturn:
675 """
676 Replacement method, which raises a :exc:`NotImplementedError` when called.
678 :raises NotImplementedError: Always, because an abstract method has no implementation.
679 """
680 raise NotImplementedError(f"Method '{method.__name__}' is abstract and needs to be overridden in a derived class.")
682 func.__abstract__ = True
683 return func
686@export
687def mustoverride(method: M) -> M:
688 """
689 Mark a method as *must-override*.
691 The returned function is the original function, but with an additional field ``<method>.____mustOverride__``, so a
692 meta-class can identify a *must-override* method and raise an error. Such an error is not raised if the method is
693 overridden by an inheriting class.
695 A *must-override* methods can offer a partial implementation, which is called via ``super()...``.
697 .. warning::
699 This decorator needs to be used in combination with meta-class :class:`~pyTooling.MetaClasses.ExtendedType`.
700 Otherwise, an abstract class itself doesn't throw a :exc:`~pyTooling.MetaClasses.MustOverrideClassError` at
701 instantiation.
703 .. admonition:: ``example.py``
705 .. code-block:: python
707 class Data(mataclass=ExtendedType):
708 @mustoverride
709 def method(self):
710 '''This is a very basic implementation.'''
712 :param method: Method that is marked as *must-override*.
713 :returns: Same method, but with additional ``<method>.__mustOverride__`` field.
715 .. seealso::
717 :exc:`~pyTooling.MetaClasses.MustOverrideClassError`
718 |rarr| The exception raised when a class gets instantiated still containing *mustoverride* methods.
719 :deco:`~pyTooling.MetaClasses.abstractclass`
720 |rarr| Mark a class as *abstract*.
721 :deco:`~pyTooling.MetaClasses.abstractmethod`
722 |rarr| Mark a method as *abstract* and raise a :exc:`NotImplementedError` when called.
723 :deco:`~pyTooling.Decorators.notimplemented`
724 |rarr| Mark a *method* as not implemented and raise a :exc:`NotImplementedError`.
725 """
726 method.__mustOverride__ = True
727 return method
730@export
731def expects(*memberNames: str) -> Callable[[M], M]:
732 """
733 Mark a method as needing members its class provides only in some combinations, usually through a mixin-class.
735 The marked method sits on the class in the **primary inheritance line** and waits for a :term:`mixin-class`
736 further along the bases to supply what it reads, while the class itself stays perfectly usable without that
737 mixin. The requirement belongs to the method, not to the class, so ``expects`` as a class keyword argument would
738 be too strict: it would reject a class that never calls the method. It is the opposite direction of
739 ``ExtendedType``'s ``expects`` keyword, where a mixin-class states what it needs from its host.
741 :class:`ExtendedType` checks the marked method against every class it is reachable from. If a class provides the
742 members, the method is left untouched, so a fulfilled expectation costs nothing per call. If it doesn't, the
743 method is replaced by one raising an :exc:`UnfulfilledExpectationError` when called - naming the missing members,
744 instead of an :exc:`AttributeError` from somewhere in the method's body. A replacement inherited from a
745 base-class is removed again as soon as a class provides the missing members, so the mixin-class may join any
746 number of levels further down. An overriding method expects what its own marker names; without the marker it
747 expects nothing.
749 .. admonition:: ``example.py``
751 .. code-block:: python
753 class Terminal(metaclass=ExtendedType, slots=True):
754 @expects("MainParser", "SubParsers")
755 def PrintHelp(self) -> None:
756 self.MainParser.print_help()
758 Terminal().PrintHelp() # UnfulfilledExpectationError
760 class Application(Terminal, ArgParseHelperMixin):
761 pass
763 Application().PrintHelp() # fine
765 :param memberNames: Names of the members the method needs from its class.
766 :returns: Decorator marking the method with an ``<method>.__expectedMembers__`` field.
767 :raises TypeError: If an element of parameter 'memberNames' is not a string.
769 .. seealso::
771 :class:`~pyTooling.MetaClasses.ExtendedType`
772 |rarr| The ``expects`` class keyword argument, for a class that needs the members as a whole.
773 :exc:`~pyTooling.MetaClasses.UnfulfilledExpectationError`
774 |rarr| The exception raised when a missing member is reached for.
775 :deco:`~pyTooling.MetaClasses.abstractmethod`
776 |rarr| Mark a method as *abstract*, when the class itself declares what has to be overridden.
777 """
778 for memberName in memberNames:
779 if not isinstance(memberName, str): 779 ↛ 780line 779 didn't jump to line 780 because the condition on line 779 was never true
780 ex = TypeError("Parameter 'memberNames' contains an element that is not a string.")
781 ex.add_note(f"Got type '{getFullyQualifiedName(memberName)}'.")
782 raise ex
784 def decorator(method: M) -> M:
785 """
786 Attach the expected member names to the decorated method.
788 :param method: Method that expects members from its class.
789 :returns: Same method, but with additional ``<method>.__expectedMembers__`` field.
790 :raises TypeError: If applied to a class instead of a method. |br|
791 A class states what it expects with the ``expects`` class keyword argument of
792 :class:`ExtendedType`.
793 """
794 if isinstance(method, type): 794 ↛ 795line 794 didn't jump to line 795 because the condition on line 794 was never true
795 ex = TypeError(f"Decorator 'expects' is applied to class '{method.__name__}' instead of a method.")
796 ex.add_note("A class names what it expects with the 'expects' class keyword argument of 'ExtendedType'.")
797 raise ex
799 method.__expectedMembers__ = memberNames
800 return method
802 return decorator
805# @export
806# def overloadable(method: M) -> M:
807# method.__overloadable__ = True
808# return method
811# @export
812# class DispatchableMethod:
813# """Represents a single multimethod."""
814#
815# _methods: dict[Tuple, Callable]
816# __name__: str
817# __slots__ = ("_methods", "__name__")
818#
819# def __init__(self, name: str) -> None:
820# self.__name__ = name
821# self._methods = {}
822#
823# def __call__(self, *args: Any):
824# """Call a method based on type signature of the arguments."""
825# types = tuple(type(arg) for arg in args[1:])
826# meth = self._methods.get(types, None)
827# if meth:
828# return meth(*args)
829# else:
830# raise TypeError(f"No matching method for types {types}.")
831#
832# def __get__(self, instance, cls) -> Self:
833# """Descriptor method needed to make calls work in a class."""
834# if instance is not None:
835# return MethodType(self, instance)
836# else:
837# return self
838#
839# def register(self, method: Callable) -> None:
840# """Register a new method as a dispatchable."""
841#
842# # Build a signature from the method's type annotations
843# sig = signature(method)
844# types: list[Type] = []
845#
846# for name, parameter in sig.parameters.items():
847# if name == "self":
848# continue
849#
850# if parameter.annotation is Parameter.empty:
851# raise TypeError(f"Parameter '{name}' in method '{method.__name__}' must be annotated with a type.")
852#
853# if not isinstance(parameter.annotation, type):
854# raise TypeError(f"Parameter '{name}' in method '{method.__name__}' annotation must be a type.")
855#
856# if parameter.default is not Parameter.empty:
857# self._methods[tuple(types)] = method
858#
859# types.append(parameter.annotation)
860#
861# self._methods[tuple(types)] = method
864# @export
865# class DispatchDictionary(dict):
866# """Special dictionary to build dispatchable methods in a metaclass."""
867#
868# def __setitem__(self, key: str, value: Any):
869# if callable(value) and key in self:
870# # If key already exists, it must be a dispatchable method or callable
871# currentValue = self[key]
872# if isinstance(currentValue, DispatchableMethod):
873# currentValue.register(value)
874# else:
875# dispatchable = DispatchableMethod(key)
876# dispatchable.register(currentValue)
877# dispatchable.register(value)
878#
879# super().__setitem__(key, dispatchable)
880# else:
881# super().__setitem__(key, value)
884@export
885class ExtendedType(type):
886 """
887 An updates meta-class to construct new classes with an extended feature set.
889 .. rubric:: Features:
891 * Store object members more efficiently in ``__slots__`` instead of ``_dict__``.
893 * Let instances be referenced weakly (``weakref=True``), which a slotted class otherwise cannot be.
894 * Implement ``__slots__`` only on primary inheritance line.
895 * Collect class variables on secondary inheritance lines (mixin-classes) and defer implementation as ``__slots__``.
896 * Handle object state exporting and importing for slots (:mod:`pickle` support) via ``__getstate__``/``__setstate__``.
898 * Allow only a single instance to be created (:term:`singleton`). |br|
899 Further instantiations will return the previously create instance (identical object).
900 * Define methods as :term:`abstract <abstract method>` or :term:`must-override <mustoverride method>` and prohibit
901 instantiation of :term:`abstract classes <abstract class>`.
902 * Let a mixin-class state which members it expects from its host class (``expects``), or a single method state
903 what it needs from its class (:deco:`expects`), and reject instantiation resp. that call while a member is
904 missing.
906 .. #* Allow method overloading and dispatch overloads based on argument signatures.
908 .. rubric:: Added class fields:
910 :__slotted__: True, if class uses `__slots__`.
911 :__allSlots__: Set of class fields stored in slots for all classes in the inheritance hierarchy.
912 :__slots__: Tuple of class fields stored in slots for current class in the inheritance hierarchy. |br|
913 See :pep:`253` for details.
914 :__isMixin__: True, if class is a mixin-class
915 :__mixinSlots__: List of collected slots from secondary inheritance hierarchy (mixin hierarchy).
916 :__slotDefaults__: Mapping of a slotted field declared by this class to its initial value.
917 :__allSlotDefaults__: Mapping of every slotted field with an initial value in the inheritance hierarchy to its
918 initial value.
919 :__methods__: List of methods.
920 :__methodsWithAttributes__: List of methods with pyTooling attributes.
921 :__abstractMethods__: List of abstract methods, which need to be implemented in the next class hierarchy levels.
922 :__expectedMembers__: Mapping of a member name expected from the host class to the name of the class
923 expecting it.
924 :__missingMembers__: Tuple of expected members this class doesn't provide (yet).
925 :__abstractClass__: True, if this class was decorated with :deco:`abstractclass`.
926 :__isAbstract__: True, if class is abstract.
927 :__isSingleton__: True, if class is a singleton
928 :__singletonInstanceCond__: Condition variable to protect the singleton creation.
929 :__singletonInstanceInit__: Singleton is initialized.
930 :__singletonInstanceCache__: The singleton object, once created.
931 :__pyattr__: List of class attributes.
933 .. rubric:: Added class properties:
935 :HasClassAttributes: Read-only property to check if the class has Attributes.
936 :HasMethodAttributes: Read-only property to check if the class has methods with Attributes.
938 .. rubric:: Added methods:
940 If slots are used, the following methods are added to support :mod:`pickle`:
942 :__getstate__: Export an object's state for serialization. |br|
943 See :pep:`307` for details.
944 :__setstate__: Import an object's state for deserialization. |br|
945 See :pep:`307` for details.
947 .. rubric:: Modified ``__new__`` method:
949 If class is a singleton, ``__new__`` will be replaced by a wrapper method. This wrapper is marked with ``__singleton_wrapper__``.
951 If class is abstract, ``__new__`` will be replaced by a method raising an exception. This replacement is marked with ``__raises_abstract_class_error__``.
953 .. rubric:: Modified ``__init__`` method:
955 If slotted fields have initial values, ``__init__`` will be replaced by a wrapper method assigning them, before it
956 calls the original ``__init__``. This wrapper is marked with ``__slot_defaults_wrapper__``.
958 If class is a singleton, ``__init__`` will be replaced by a wrapper method. This wrapper is marked by ``__singleton_wrapper__``.
960 .. rubric:: Modified abstract methods:
962 If a method is abstract, its marked with ``__abstract__``. |br|
963 If a method is must override, its marked with ``__mustOverride__``.
965 .. seealso::
967 :ref:`META/ExtendedType`
968 |rarr| Every class keyword argument, its rules and the exceptions it raises.
969 """
971 # @classmethod
972 # def __prepare__(cls, className, baseClasses, slots: bool = False, mixin: bool = False, singleton: bool = False):
973 # return DispatchDictionary()
975 def __new__(
976 metacls,
977 className: str,
978 baseClasses: tuple[type],
979 members: dict[str, Any],
980 slots: bool = False,
981 mixin: bool = False,
982 weakref: bool = False,
983 singleton: bool = False,
984 expects: Iterable[str] | type = (),
985 **kwargs: Any
986 ) -> Self:
987 """
988 Construct a new class using this :term:`meta-class`.
990 A field assigned in the class body without a type annotation is reported as an :exc:`UnannotatedFieldWarning` to
991 a :class:`~pyTooling.Warning.WarningCollector`; without a collector, it's dropped.
993 :param className: The name of the class to construct.
994 :param baseClasses: The tuple of :term:`base-classes <base-class>` the class is derived from.
995 :param members: The dictionary of members for the constructed class.
996 :param slots: Optional, if ``True``, store object attributes in :term:`__slots__ <slots>` instead of
997 ``__dict__``.
998 :param mixin: Optional, if ``True``, make the class a :term:`Mixin-Class`. If ``False``, create slots if
999 ``slots``
1000 is true. If ``None``, preserve behavior of primary base-class.
1001 :param weakref: Optional, if ``True``, let instances of a slotted class be referenced weakly by adding
1002 ``__weakref__`` to :term:`__slots__ <slots>`. A mixin-class passes it on to the class it
1003 is mixed into.
1004 :param singleton: Optional, if ``True``, make the class a :term:`Singleton`.
1005 :param expects: Optional, names of members this class needs from whichever class it is mixed into, or a
1006 :class:`typing.Protocol` class naming them as its members. |br|
1007 See :attr:`__expectedMembers__`.
1008 :param kwargs: Any further class keyword argument, forwarded to :meth:`~object.__init_subclass__` as
1009 :func:`type` does.
1010 :returns: The new class.
1011 :raises AttributeError: If base-class has no '__slots__' attribute.
1012 :raises AttributeError: If slot already exists in base-class.
1013 """
1014 # Inherit 'slots' feature from primary base-class
1015 if len(baseClasses) > 0:
1016 primaryBaseClass = baseClasses[0]
1017 if isinstance(primaryBaseClass, metacls):
1018 slots = primaryBaseClass.__slotted__
1020 # Compute slots and mixin-slots from annotated fields as well as class- and object-fields with initial values.
1021 classFields, objectFields = metacls._computeSlots(className, baseClasses, members, slots, mixin, weakref)
1023 # Compute abstract methods
1024 abstractMethods, members = metacls._checkForAbstractMethods(baseClasses, members)
1026 # Create a new class - the remaining keyword arguments belong to '__init_subclass__', which 'type' calls.
1027 # Class variables with an initial value are part of 'members', so they are bound before that hook runs and
1028 # are not re-assigned afterwards - doing so would overwrite whatever '__init_subclass__' computed from them.
1029 newClass = type.__new__(metacls, className, baseClasses, members, **kwargs)
1031 # A class variable set to 'ThisClass' means "the class I am declared in". The class doesn't exist while its body
1032 # runs, so the value is resolved here. Only a variable this class declared is rebound - an inherited one keeps
1033 # the value its own class resolved - and the current value is read from the class, so an '__init_subclass__'
1034 # that replaced it wins.
1035 for memberName in members:
1036 if getattr(newClass, memberName, None) is ThisClass:
1037 setattr(newClass, memberName, newClass)
1039 # Search in inheritance tree for abstract methods
1040 newClass.__abstractMethods__ = abstractMethods
1042 newClass.__abstractClass__ = False
1043 newClass.__slotDefaults__ = objectFields
1044 metacls._wrapInitMethodIfSlotDefaults(newClass, mixin)
1045 newClass.__isAbstract__ = metacls._wrapNewMethodIfAbstract(newClass)
1046 newClass.__isSingleton__ = metacls._wrapNewMethodIfSingleton(newClass, singleton)
1048 # Collect the members expected from the host class and reject instantiation while any of them is missing
1049 newClass.__expectedMembers__ = metacls._collectExpectedMembers(className, baseClasses, members, expects)
1050 newClass.__missingMembers__ = metacls._computeMissingMembers(newClass, mixin)
1051 if not newClass.__isAbstract__:
1052 metacls._wrapNewMethodIfExpectationUnfulfilled(newClass)
1054 if slots:
1055 # If slots are used, implement __getstate__/__setstate__ API to support serialization using pickle.
1056 if "__getstate__" not in members:
1057 def __getstate__(self) -> dict[str, Any]:
1058 """
1059 Return the object's state for pickling, collecting every slot of the class hierarchy.
1061 :returns: Dictionary of slot names and their values.
1062 :raises ExtendedTypeError: If a slot was never assigned, so it has no value to serialize.
1063 """
1064 try:
1065 return {slotName: getattr(self, slotName) for slotName in self.__allSlots__}
1066 except AttributeError as ex:
1067 raise ExtendedTypeError(
1068 f"Unassigned field '{ex.name}' in object '{self}' of type '{getTypeName(self)}'."
1069 ) from ex
1071 newClass.__getstate__ = __getstate__
1073 if "__setstate__" not in members:
1074 def __setstate__(self, state: dict[str, Any]) -> None:
1075 """
1076 Restore the object's state from unpickling, requiring exactly the slots of the class hierarchy.
1078 :param state: Dictionary of slot names and their values.
1079 :raises ExtendedTypeError: If the given state misses a slot or carries an unexpected one.
1080 """
1081 if self.__allSlots__ != (slots := set(state.keys())):
1082 if len(diff := self.__allSlots__.difference(slots)) > 0:
1083 raise ExtendedTypeError(f"""Missing fields in parameter 'state': '{"', '".join(diff)}'""") # WORKAROUND: Python <3.12
1084 else:
1085 diff = slots.difference(self.__allSlots__)
1086 raise ExtendedTypeError(f"""Unexpected fields in parameter 'state': '{"', '".join(diff)}'""") # WORKAROUND: Python <3.12
1088 for slotName, value in state.items():
1089 setattr(self, slotName, value)
1091 newClass.__setstate__ = __setstate__
1093 # Check for inherited class attributes
1094 attributes: list[Attribute] = []
1095 setattr(newClass, ATTRIBUTES_MEMBER_NAME, attributes)
1096 for base in baseClasses:
1097 if hasattr(base, ATTRIBUTES_MEMBER_NAME):
1098 pyAttr = getattr(base, ATTRIBUTES_MEMBER_NAME)
1099 for att in pyAttr:
1100 if AttributeScope.Class in att.Scope: 1100 ↛ 1099line 1100 didn't jump to line 1099 because the condition on line 1100 was always true
1101 attributes.append(att)
1102 att.__class__._classes.append(newClass)
1104 # Check methods for attributes
1105 methods, methodsWithAttributes = metacls._findMethods(newClass, baseClasses, members)
1107 # Add new fields for found methods
1108 newClass.__methods__ = tuple(methods)
1109 newClass.__methodsWithAttributes__ = tuple(methodsWithAttributes)
1111 # Reject calling a method that expects members this class doesn't provide
1112 metacls._wrapMethodsWithUnfulfilledExpectations(newClass)
1114 # Additional methods on a class
1115 def GetMethodsWithAttributes(
1116 self,
1117 predicate: Nullable[TAttributeFilter[TAttr]] = None
1118 ) -> dict[Callable[..., Any], tuple[Attribute, ...]]:
1119 """
1120 Return the class' methods that carry at least one matching attribute.
1122 :param predicate: Optional, an attribute class, an iterable of attribute classes, or ``None`` to accept every
1123 attribute.
1124 :returns: Dictionary of methods and the matching attributes attached to them.
1125 :raises ValueError: If an element of parameter 'predicate' is not a sub-class of :class:`~pyTooling.Attributes.Attribute`.
1126 :raises ValueError: If parameter 'predicate' is neither an attribute class nor an iterable of those.
1127 """
1128 if predicate is None:
1129 predicate = Attribute
1130 elif isinstance(predicate, Iterable): 1130 ↛ 1131line 1130 didn't jump to line 1131 because the condition on line 1130 was never true
1131 for attribute in predicate:
1132 if not issubclass(attribute, Attribute):
1133 raise ValueError("Parameter 'predicate' contains an element which is not a sub-class of 'Attribute'.")
1135 predicate = tuple(predicate)
1136 elif not issubclass(predicate, Attribute): 1136 ↛ 1137line 1136 didn't jump to line 1137 because the condition on line 1136 was never true
1137 raise ValueError("Parameter 'predicate' is not a sub-class of 'Attribute'.")
1139 methodAttributePairs = {}
1140 for method in newClass.__methodsWithAttributes__:
1141 matchingAttributes: list[Attribute] = []
1142 for attribute in method.__pyattr__:
1143 if isinstance(attribute, predicate):
1144 matchingAttributes.append(attribute)
1146 if len(matchingAttributes) > 0:
1147 methodAttributePairs[method] = tuple(matchingAttributes)
1149 return methodAttributePairs
1151 newClass.GetMethodsWithAttributes = classmethod(GetMethodsWithAttributes)
1152 GetMethodsWithAttributes.__qualname__ = f"{className}.{GetMethodsWithAttributes.__name__}"
1154 # GetMethods(predicate) -> dict[method, list[attribute]] / generator
1155 # GetClassAtrributes -> list[attributes] / generator
1156 # MethodHasAttributes(predicate) -> bool
1157 # GetAttribute
1159 return newClass
1161 @classmethod
1162 def _findMethods(
1163 metacls,
1164 newClass: ExtendedType,
1165 baseClasses: tuple[type],
1166 members: dict[str, Any]
1167 ) -> tuple[list[MethodType], list[MethodType]]:
1168 """
1169 Find methods and methods with :mod:`pyTooling.Attributes`.
1171 .. todo::
1173 Describe algorithm.
1175 :param newClass: Newly created class instance.
1176 :param baseClasses: The tuple of :term:`base-classes <base-class>` the class is derived from.
1177 :param members: Members of the new class.
1178 :returns: A 2-tuple of all methods and those methods carrying at least one attribute.
1179 :raises TypeError: If a member is neither a method nor a class, so it can't be searched for methods.
1180 """
1181 # Embedded bind function due to circular dependencies.
1182 def bind(instance: object, func: FunctionType, methodName: Nullable[str] = None):
1183 """
1184 Nested function binding a function to an object as a method.
1186 It exists here rather than in :mod:`pyTooling.Common`, because importing that module would be circular.
1188 :param instance: The object the function is bound to.
1189 :param func: The function to bind.
1190 :param methodName: Optional, name of the method; by default the function's own name.
1191 :returns: The bound method.
1192 """
1193 if methodName is None: 1193 ↛ 1196line 1193 didn't jump to line 1196 because the condition on line 1193 was always true
1194 methodName = func.__name__
1196 boundMethod = func.__get__(instance, instance.__class__)
1197 setattr(instance, methodName, boundMethod)
1199 return boundMethod
1201 methods = []
1202 methodsWithAttributes = []
1203 attributeIndex = {}
1205 for base in baseClasses:
1206 if hasattr(base, "__methodsWithAttributes__"):
1207 methodsWithAttributes.extend(base.__methodsWithAttributes__)
1209 for memberName, member in members.items():
1210 if isinstance(member, FunctionType):
1211 method = newClass.__dict__[memberName]
1212 if hasattr(method, "__classobj__") and getattr(method, "__classobj__") is not newClass: 1212 ↛ 1213line 1212 didn't jump to line 1213 because the condition on line 1212 was never true
1213 raise TypeError(f"Method '{memberName}' is used by multiple classes: {method.__classobj__} and {newClass}.")
1214 else:
1215 setattr(method, "__classobj__", newClass)
1217 def GetAttributes(inst: Any, predicate: Nullable[type[Attribute]] = None) -> tuple[Attribute, ...]:
1218 """
1219 Nested function attached to the class, returning the attributes of one of its methods.
1221 :param inst: The method to read the attributes from.
1222 :param predicate: Optional, an attribute class, or ``None`` to accept every attribute.
1223 :returns: Tuple of the matching attributes.
1224 """
1225 results = []
1226 try:
1227 for attribute in inst.__pyattr__: # type: Attribute
1228 if isinstance(attribute, predicate):
1229 results.append(attribute)
1230 return tuple(results)
1231 except AttributeError:
1232 return tuple()
1234 method.GetAttributes = bind(method, GetAttributes)
1235 methods.append(method)
1237 # print(f" convert function: '{memberName}' to method")
1238 # print(f" {member}")
1239 if "__pyattr__" in member.__dict__:
1240 attributes = member.__pyattr__ # type: list[Attribute]
1241 if isinstance(attributes, list) and len(attributes) > 0: 1241 ↛ 1209line 1241 didn't jump to line 1209 because the condition on line 1241 was always true
1242 methodsWithAttributes.append(member)
1243 for attribute in attributes:
1244 # print(f" attributes: {attribute.__class__.__name__}")
1245 if attribute not in attributeIndex: 1245 ↛ 1248line 1245 didn't jump to line 1248 because the condition on line 1245 was always true
1246 attributeIndex[attribute] = [member]
1247 else:
1248 attributeIndex[attribute].append(member)
1249 # else:
1250 # print(f" But has no attributes.")
1251 # else:
1252 # print(f" ?? {memberName}")
1253 return methods, methodsWithAttributes
1255 @classmethod
1256 def _getAnnotations(metacls, members: dict[str, Any]) -> dict[str, Any]:
1257 """
1258 Return the type annotations declared in a class body.
1260 .. important::
1262 Python 3.14 (:pep:`649`) no longer fills ``__annotations__`` while the class body is executed, but installs an
1263 ``__annotate_func__`` instead. The :mod:`annotationlib` module needed to evaluate that function doesn't exist on
1264 older Python versions, therefore both mechanisms are supported.
1266 :param members: Dictionary of class members.
1267 :returns: Dictionary of annotated field names and their type annotations. Empty, if the class body declared
1268 no annotations.
1269 """
1270 if "__annotations__" in members:
1271 # WORKAROUND: LEGACY SUPPORT Python <= 3.13
1272 # Accessing annotations was changed in Python 3.14.
1273 # The necessary 'annotationlib' is not available for older Python versions.
1274 annotations = members["__annotations__"]
1275 elif version_info >= (3, 14) and (annotate := members.get("__annotate_func__", None)) is not None:
1276 from annotationlib import Format, call_annotate_function
1277 try:
1278 annotations = annotate(Format.VALUE)
1279 except NameError:
1280 # A forward reference the class body cannot resolve yet - PEP 649 offers the source text instead.
1281 annotations = call_annotate_function(annotate, Format.STRING)
1282 else:
1283 return {}
1285 # 'from __future__ import annotations' (PEP 563) makes every annotation a string, and so does the fallback
1286 # above. Resolve what can be resolved, so a 'ClassVar' is still recognized as one.
1287 return {
1288 name: metacls._resolveAnnotation(typeAnnotation, members)
1289 for name, typeAnnotation in annotations.items()
1290 }
1292 #: Matches a textual annotation denoting a :class:`~typing.ClassVar`, with or without a module qualifier.
1293 _CLASS_VARIABLE_PATTERN = re_compile(r"^\s*(?:\w+\.)*ClassVar\s*(?:\[|$)")
1294 _compiledAnnotations: ClassVar[dict[str, CodeType]] = {} #: Code object per textual annotation, compiled once.
1296 @classmethod
1297 def _resolveAnnotation(metacls, typeAnnotation: Any, members: dict[str, Any]) -> Any:
1298 """
1299 Evaluate a postponed (string) annotation, so it can be inspected like an ordinary one.
1301 :pep:`563` - ``from __future__ import annotations`` - turns **every** annotation in a module into a string, and
1302 :pep:`649` does the same for an annotation :mod:`annotationlib` cannot evaluate yet. A string tells this
1303 meta-class nothing: a ``ClassVar`` reads as ``"ClassVar[int]"`` and would silently become a slot.
1305 The annotation is evaluated in the defining module's namespace plus the class body itself. A name that cannot be
1306 resolved - typically a forward reference to the class being created right now - is **returned unchanged**, which
1307 is harmless: the textual fallback in :meth:`_isClassVariable` still classifies it, and nothing else needs the
1308 type object.
1310 Each distinct annotation text is compiled once and its code object is kept in :attr:`_compiledAnnotations`. Only
1311 the code is shared: it is evaluated in the namespaces of the class being created, every time.
1313 :param typeAnnotation: The annotation to resolve; returned unchanged when it is not a string.
1314 :param members: Dictionary of class members, used as the local namespace.
1315 :returns: The evaluated annotation, or the original string when it cannot be evaluated.
1316 """
1317 if not isinstance(typeAnnotation, str):
1318 return typeAnnotation
1320 module = modules.get(members.get("__module__", ""), None)
1321 try:
1322 if (code := metacls._compiledAnnotations.get(typeAnnotation, None)) is None:
1323 # Like 'eval' does for a string, leading spaces and tabs are stripped.
1324 code = compile(typeAnnotation.lstrip(" \t"), "<string>", "eval")
1325 metacls._compiledAnnotations[typeAnnotation] = code
1327 # The annotation is source code written in the class being created - the same trust level as importing it.
1328 return eval(code, getattr(module, "__dict__", {}), members)
1329 except Exception: # noqa: BLE001 - any failure means "keep the string", see above
1330 return typeAnnotation
1332 @classmethod
1333 def _isClassVariable(metacls, typeAnnotation: Any) -> bool:
1334 """
1335 Check if a type annotation declares a class variable.
1337 Both forms are recognized: the evaluated :class:`~typing.ClassVar` and its textual form, which is what
1338 ``from __future__ import annotations`` leaves behind when the annotation cannot be evaluated.
1340 :param typeAnnotation: The type annotation to check.
1341 :returns: ``True``, if the annotation is a :class:`~typing.ClassVar`.
1342 """
1343 if isinstance(typeAnnotation, str):
1344 return metacls._CLASS_VARIABLE_PATTERN.match(typeAnnotation) is not None
1346 return isinstance(typeAnnotation, _GenericAlias) and typeAnnotation.__origin__ is ClassVar
1348 @classmethod
1349 def _isField(metacls, member: Any) -> bool:
1350 """
1351 Check if a class member is a field, so a type annotation is expected for it.
1353 Methods, nested classes, properties and any other descriptor carry their type information in their signature or
1354 in their own declaration, therefore they aren't fields.
1356 :param member: The class member to check.
1357 :returns: ``True``, if the member is a field.
1358 """
1359 if isinstance(member, (FunctionType, MethodType, BuiltinFunctionType, classmethod, staticmethod, property, type)):
1360 return False
1362 # Any descriptor (e.g. a custom property implementation) declares its own type information.
1363 return not (hasattr(member, "__get__") or hasattr(member, "__set__"))
1365 @classmethod
1366 def _checkForUnannotatedFields(metacls, className: str, members: dict[str, Any], annotations: dict[str, Any]) -> None:
1367 """
1368 Report fields that were assigned in the class body without a type annotation.
1370 Every field should carry type information: an object field is annotated with its type, a class variable with
1371 ``ClassVar[...]``. Without an annotation, :class:`ExtendedType` can't tell the two apart - an un-annotated
1372 assignment never becomes a slot and silently stays a class attribute.
1374 .. important::
1376 This is reported as a :class:`~pyTooling.Warning.Warning`, so it needs a
1377 :class:`~pyTooling.Warning.WarningCollector` somewhere up the call-hierarchy to be observed. Importing a module
1378 with un-annotated fields doesn't fail.
1380 :param className: The name of the class to construct.
1381 :param members: Dictionary of class members.
1382 :param annotations: Dictionary of annotated field names and their type annotations.
1383 """
1384 unannotatedFields = [
1385 fieldName
1386 for fieldName, member in members.items()
1387 if not (fieldName.startswith("__") and fieldName.endswith("__"))
1388 and fieldName not in annotations
1389 and metacls._isField(member)
1390 ]
1392 if len(unannotatedFields) > 0:
1393 fieldNames = "', '".join(unannotatedFields)
1394 WarningCollector.Raise(
1395 UnannotatedFieldWarning(
1396 f"Class '{className}' declares {len(unannotatedFields)} field(s) without a type annotation.",
1397 className=className,
1398 fieldNames=unannotatedFields
1399 ),
1400 notes=(
1401 f"Field(s) without a type annotation: '{fieldNames}'.",
1402 "Annotate a class variable as 'ClassVar[...]' or an object field with its type.",
1403 )
1404 )
1406 @classmethod
1407 def _computeSlots(
1408 metacls,
1409 className: str,
1410 baseClasses: tuple[type],
1411 members: dict[str, Any],
1412 slots: bool,
1413 mixin: bool,
1414 weakref: bool
1415 ) -> tuple[dict[str, Any], dict[str, Any]]:
1416 """
1417 Compute which field are listed in __slots__ and which need to be initialized in an instance or class.
1419 .. todo::
1421 Describe algorithm.
1423 :param className: The name of the class to construct.
1424 :param baseClasses: Tuple of base-classes.
1425 :param members: Dictionary of class members.
1426 :param slots: ``True``, if the class should setup ``__slots__``.
1427 :param mixin: ``True``, if the class should behave as a mixin-class.
1428 :param weakref: ``True``, if instances should be referenced weakly.
1429 :returns: A 2-tuple with a dictionary of class members and object members.
1430 :raises ExtendedTypeError: If a class annotates ``__weakref__`` instead of passing ``weakref=True``.
1431 A class derived from a weak-referenceable class inherits ``__weakref__``.
1432 :raises ExtendedTypeError: If a class annotates ``__dict__``.
1433 :raises AttributeError: If a field's annotation refers to a name that can't be resolved. |br|
1434 An assignment without a type annotation creates a class attribute, which
1435 hides the slot's descriptor: reading the field works, but assigning it on an
1436 instance raises. Annotate it as ``ClassVar[...]`` to declare a class
1437 variable, or remove the assignment. A slot contributed by a mixin-class is
1438 materialized in this class' ``__slots__``, and Python doesn't allow such a
1439 name to be assigned in the class body.
1440 :raises BaseClassWithoutSlotsError: If a base-class doesn't use slots. |br|
1441 All base-classes of a class using ``__slots__`` must use ``__slots__``
1442 themselves.
1443 :raises DuplicateFieldInSlotsError: If a class member shadows a slot a base-class or mixin-class declares.
1444 |br|
1445 Without an annotation, annotate it as ``ClassVar[...]`` or remove the
1446 assignment. Annotating it does **not** resolve the clash - the slot
1447 exists either way - so a class variable of that name has to be renamed,
1448 or the slot dropped.
1449 :raises DuplicateFieldInSlotsError: If a field of the class has the name of a mixin-class' slot. |br|
1450 Remove the annotation; the mixin-class' field becomes a slot of this class.
1451 :raises DuplicateFieldInSlotsError: If a slot of the primary inheritance line has the name of a mixin-class'
1452 slot. |br|
1453 Rename one of the two fields, or declare it in one class only. Set
1454 ``weakref=True`` in one class of a hierarchy; derived classes inherit
1455 ``__weakref__``.
1456 """
1457 # Compute which field are listed in __slots__ and which need to be initialized in an instance or class.
1458 slottedFields = []
1459 classFields = {}
1460 objectFields = {}
1461 annotations: dict[str, Any] = metacls._getAnnotations(members)
1462 if "__weakref__" in annotations:
1463 ex = ExtendedTypeError(f"Class '{className}' annotates '__weakref__' as a field.")
1464 ex.add_note(f"Set 'weakref=True' instead: 'class {className}(..., weakref=True)'.")
1465 ex.add_note("A class derived from a weak-referenceable class inherits '__weakref__' and doesn't ask again.")
1466 raise ex
1468 if "__dict__" in annotations:
1469 ex = ExtendedTypeError(f"Class '{className}' annotates '__dict__' as a field.")
1470 ex.add_note("A '__dict__' slot accepts any attribute on an instance again and gives up what slots save.")
1471 ex.add_note(f"Set 'slots=False' instead, if instances of '{className}' need a '__dict__'.")
1472 raise ex
1474 if slots or mixin:
1475 # If slots are used, all base classes must use __slots__.
1476 # A slotted class or mixin-class built by ExtendedType passed this check for its own base-classes already.
1477 if not all(
1478 baseClass is Generic or (isinstance(baseClass, ExtendedType) and baseClass.__slotted__)
1479 for baseClass in baseClasses
1480 ):
1481 for baseClass in metacls._iterateBaseClasses(baseClasses):
1482 # Exclude object as a special case
1483 if baseClass is object or baseClass is Generic:
1484 continue
1486 if not hasattr(baseClass, "__slots__"):
1487 ex = BaseClassWithoutSlotsError(
1488 f"Base-classes '{baseClass.__name__}' doesn't use '__slots__'.",
1489 baseClass=baseClass,
1490 className=className
1491 )
1492 ex.add_note("All base-classes of a class using '__slots__' must use '__slots__' itself.")
1493 raise ex
1495 # Non-empty __slots__ on secondary base-classes are rejected by _aggregateMixinSlots below.
1497 # Copy all field names from primary base-class' __slots__, which are later needed for error checking.
1498 inheritedSlottedFields = {}
1499 if len(baseClasses) > 0:
1500 for base in reversed(baseClasses[0].mro()):
1501 # Exclude object as a special case
1502 if base is object or base is Generic:
1503 continue
1505 for annotation in base.__slots__:
1506 inheritedSlottedFields[annotation] = base
1508 if weakref:
1509 fields = annotations.copy()
1510 fields["__weakref__"] = None
1511 else:
1512 fields = annotations
1514 # When adding annotated fields to slottedFields, check if name was not used in inheritance hierarchy.
1515 for fieldName, typeAnnotation in fields.items():
1516 if fieldName in inheritedSlottedFields:
1517 cls = inheritedSlottedFields[fieldName]
1518 raise AttributeError(f"Slot '{fieldName}' declared in class '{className}' already exists in base-class '{cls.__module__}.{cls.__name__}'.")
1520 # A ClassVar is never a slot, with or without an initial value.
1521 # * If it has an initial value, note the field in classFields and **leave it in members**, so that
1522 # 'type.__new__' binds it before calling '__init_subclass__'. Removing it and assigning it
1523 # afterwards made a derived class' value invisible to that hook, which then read the base class'.
1524 # * Otherwise it's a forward declaration and derived classes assign the actual value.
1525 isClassVariable = metacls._isClassVariable(typeAnnotation)
1526 hasInitialValue = fieldName in members
1527 if isClassVariable:
1528 if hasInitialValue:
1529 classFields[fieldName] = members[fieldName]
1531 # If an annotated field has an initial value
1532 # * copy field and initial value to objectFields dictionary
1533 # * remove field from members
1534 elif hasInitialValue:
1535 slottedFields.append(fieldName)
1536 objectFields[fieldName] = members[fieldName]
1537 del members[fieldName]
1538 else:
1539 slottedFields.append(fieldName)
1541 mixinSlots = metacls._aggregateMixinSlots(className, baseClasses)
1543 # A mixin-class' slot must not exist a second time: neither as a field of this class, nor as a slot of the
1544 # primary inheritance line. A slot both reach from the same declaring class (a diamond) exists once.
1545 for fieldName, declaringClass in list(mixinSlots.items()):
1546 mixinName = getFullyQualifiedName(declaringClass)
1547 if fieldName in slottedFields:
1548 ex = DuplicateFieldInSlotsError(
1549 f"Slot '{fieldName}' declared in class '{className}' already exists in mixin-class '{mixinName}'.",
1550 fieldName=fieldName,
1551 className=className
1552 )
1553 ex.add_note("Remove the annotation; the mixin-class' field becomes a slot of this class.")
1554 raise ex
1555 elif fieldName in inheritedSlottedFields:
1556 baseClass = metacls._findSlotDeclaration(baseClasses[0], fieldName)
1557 if baseClass is declaringClass:
1558 del mixinSlots[fieldName]
1559 continue
1561 baseName = getFullyQualifiedName(baseClass)
1562 ex = DuplicateFieldInSlotsError(
1563 f"Slot '{fieldName}' of mixin-class '{mixinName}' already exists in base-class '{baseName}'.",
1564 fieldName=fieldName,
1565 className=className
1566 )
1567 if fieldName == "__weakref__":
1568 ex.add_note("Set 'weakref=True' in one class of a hierarchy; derived classes inherit '__weakref__'.")
1569 else:
1570 ex.add_note("Rename one of the two fields, or declare it in one class only.")
1571 raise ex
1573 # A member assigned in the class body without a type annotation stays a class attribute. If it carries the name
1574 # of a slot, that class attribute shadows the slot's descriptor and the field becomes read-only on instances.
1575 # Report it here instead of letting the first assignment fail with a bare AttributeError.
1576 shadowedSlots = {**inheritedSlottedFields, **{fieldName: None for fieldName in mixinSlots}}
1577 for fieldName in shadowedSlots.keys() & members.keys():
1578 ex = DuplicateFieldInSlotsError(
1579 f"Slot '{fieldName}' is shadowed by a class member in class '{className}'.",
1580 fieldName=fieldName,
1581 className=className
1582 )
1583 if (baseClass := shadowedSlots[fieldName]) is not None:
1584 ex.add_note(f"Slot '{fieldName}' is declared in base-class '{baseClass.__module__}.{baseClass.__name__}'.")
1585 ex.add_note("An assignment without a type annotation creates a class attribute, which hides the slot's descriptor.")
1586 ex.add_note("Reading the field works, but assigning it on an instance raises an AttributeError.")
1587 else:
1588 ex.add_note(f"Slot '{fieldName}' is contributed by a mixin-class and materialized in this class' '__slots__'.")
1589 ex.add_note("Python doesn't allow a name to be listed in '__slots__' and assigned in the class body.")
1591 if fieldName in classFields:
1592 ex.add_note("Annotating it as 'ClassVar[...]' doesn't resolve this - the slot exists either way.")
1593 ex.add_note("Rename the class variable, or drop the slot the base-class or mixin-class declares.")
1594 else:
1595 ex.add_note("Annotate it as 'ClassVar[...]' to declare a class variable, or remove the assignment.")
1597 raise ex
1598 else:
1599 # When adding annotated fields to slottedFields, check if name was not used in inheritance hierarchy.
1600 for fieldName, typeAnnotation in annotations.items():
1601 # If annotated field is a ClassVar, and it has an initial value
1602 # * copy field and initial value to classFields dictionary
1603 # * remove field from members
1604 if metacls._isClassVariable(typeAnnotation) and fieldName in members:
1605 classFields[fieldName] = members[fieldName]
1607 metacls._checkForUnannotatedFields(className, members, annotations)
1609 if mixin:
1610 mixinSlots = [*mixinSlots, *slottedFields]
1611 members["__slotted__"] = True
1612 members["__slots__"] = tuple()
1613 members["__allSlots__"] = set()
1614 members["__isMixin__"] = True
1615 members["__mixinSlots__"] = tuple(mixinSlots)
1616 elif slots:
1617 slottedFields.extend(mixinSlots)
1618 members["__slotted__"] = True
1619 members["__slots__"] = tuple(slottedFields)
1620 members["__allSlots__"] = {*slottedFields, *inheritedSlottedFields.keys()}
1621 members["__isMixin__"] = False
1622 members["__mixinSlots__"] = tuple()
1623 else:
1624 members["__slotted__"] = False
1625 # NO __slots__
1626 # members["__allSlots__"] = set()
1627 members["__isMixin__"] = False
1628 members["__mixinSlots__"] = tuple()
1629 return classFields, objectFields
1631 @classmethod
1632 def _aggregateMixinSlots(metacls, className: str, baseClasses: tuple[type]) -> dict[str, type]:
1633 """
1634 Aggregate slot names requested by mixin-base-classes.
1636 .. todo::
1638 Describe algorithm.
1640 :param className: The name of the class to construct.
1641 :param baseClasses: The tuple of :term:`base-classes <base-class>` the class is derived from.
1642 :returns: A dictionary of slot names and the mixin-class declaring each.
1643 :raises BaseClassIsNotAMixinError: If a secondary base-class is a slotted class, but not a mixin-class. |br|
1644 Only the primary base-class may be a slotted class; every further
1645 base-class is a mixin-class. Declare it with ``mixin=True`` or apply
1646 the :deco:`mixin` decorator.
1647 :raises BaseClassWithNonEmptySlotsError: If a mixin-class uses non-empty slots. |br|
1648 In Python, only one inheritance branch can use non-empty ``__slots__``.
1649 :raises DuplicateFieldInSlotsError: If two mixin-classes declare a slot of the same name. |br|
1650 Rename one of the two fields, or move it into a mixin-class both derive
1651 from. Set ``weakref=True`` in one class of a hierarchy; derived classes
1652 inherit ``__weakref__``.
1653 """
1654 mixinSlots: dict[str, type] = {}
1655 if len(baseClasses) > 0:
1656 # A secondary base-class built by ExtendedType with slots contributes its fields as a mixin-class only.
1657 for baseClass in baseClasses[1:]:
1658 if isinstance(baseClass, ExtendedType) and baseClass.__slotted__ and not baseClass.__isMixin__:
1659 ex = BaseClassIsNotAMixinError(
1660 f"Secondary base-class '{getFullyQualifiedName(baseClass)}' of class '{className}' is not a mixin-class.",
1661 baseClass=baseClass,
1662 className=className
1663 )
1664 ex.add_note("Only the primary base-class may be a slotted class; every further base-class is a mixin-class.")
1665 ex.add_note(f"Declare it as 'class {baseClass.__name__}(..., mixin=True)' or apply the '@mixin' decorator.")
1666 raise ex
1668 # If class has base-classes ensure only the primary inheritance path uses slots and all secondary inheritance
1669 # paths have an empty slots tuple. Otherwise, raise a BaseClassWithNonEmptySlotsError.
1670 inheritancePaths = list(metacls._iterateBaseClassPaths(baseClasses))
1671 primaryInharitancePath: set[type] = set(inheritancePaths[0])
1672 for typePath in inheritancePaths[1:]:
1673 for t in typePath:
1674 if t not in primaryInharitancePath and hasattr(t, "__slots__") and len(t.__slots__) != 0:
1675 ex = BaseClassWithNonEmptySlotsError(
1676 f"Base-class '{t.__name__}' has non-empty __slots__ and can't be used as a direct or indirect base-class "
1677 f"for '{className}'.",
1678 baseClass=t,
1679 className=className
1680 )
1681 ex.add_note("In Python, only one inheritance branch can use non-empty __slots__.")
1682 raise ex
1684 # If current class is set to be a mixin, then aggregate all mixinSlots in a list.
1685 # Ensure all base-classes are either constructed
1686 # * by meta-class ExtendedType, or
1687 # * use no slots, or
1688 # * are typing.Generic
1689 # If it was constructed by ExtendedType, then ensure this class itself is a mixin-class.
1690 for baseClass in baseClasses: # type: ExtendedType
1691 if isinstance(baseClass, _GenericAlias) and baseClass.__origin__ is Generic: 1691 ↛ 1692line 1691 didn't jump to line 1692 because the condition on line 1691 was never true
1692 pass
1693 elif hasattr(baseClass, "__mixinSlots__"):
1694 for fieldName in baseClass.__mixinSlots__:
1695 declaringClass = metacls._findSlotDeclaration(baseClass, fieldName)
1696 if fieldName not in mixinSlots:
1697 mixinSlots[fieldName] = declaringClass
1698 elif mixinSlots[fieldName] is not declaringClass:
1699 firstName = getFullyQualifiedName(mixinSlots[fieldName])
1700 secondName = getFullyQualifiedName(declaringClass)
1701 ex = DuplicateFieldInSlotsError(
1702 f"Slot '{fieldName}' is declared by mixin-classes '{firstName}' and '{secondName}'.",
1703 fieldName=fieldName,
1704 className=className
1705 )
1706 ex.add_note(f"Both are base-classes of class '{className}'.")
1707 if fieldName == "__weakref__": 1707 ↛ 1708line 1707 didn't jump to line 1708 because the condition on line 1707 was never true
1708 ex.add_note("Set 'weakref=True' in one class of a hierarchy; derived classes inherit '__weakref__'.")
1709 else:
1710 ex.add_note("Rename one of the two fields, or move it into a mixin-class both derive from.")
1711 raise ex
1713 return mixinSlots
1715 @staticmethod
1716 def _findSlotDeclaration(baseClass: type, fieldName: str) -> type:
1717 """
1718 Find the class in a base-class' hierarchy, which declared a slot.
1720 A mixin-class passes the slots of its mixin-base-classes on in ``__mixinSlots__``, and the slotted class it is mixed
1721 into lists them in its ``__slots__``. Searched from the root of the method resolution order, the first class
1722 listing the name declared it.
1724 :param baseClass: The base-class whose hierarchy is searched.
1725 :param fieldName: The slot's name.
1726 :returns: The class declaring the slot.
1727 """
1728 for cls in reversed(baseClass.__mro__): 1728 ↛ 1736line 1728 didn't jump to line 1736 because the loop on line 1728 didn't complete
1729 slots = cls.__dict__.get("__slots__", ())
1730 if isinstance(slots, str): 1730 ↛ 1731line 1730 didn't jump to line 1731 because the condition on line 1730 was never true
1731 slots = (slots, )
1733 if fieldName in slots or fieldName in cls.__dict__.get("__mixinSlots__", ()):
1734 return cls
1736 return baseClass
1738 @classmethod
1739 def _iterateBaseClasses(metacls, baseClasses: tuple[type]) -> Generator[type, None, None]:
1740 """
1741 Return a generator to iterate (visit) all base-classes ...
1743 .. todo::
1745 Describe iteration order.
1747 :param baseClasses: The tuple of :term:`base-classes <base-class>` the class is derived from.
1748 :returns: Generator to iterate all base-classes.
1749 """
1750 if len(baseClasses) == 0: 1750 ↛ 1751line 1750 didn't jump to line 1751 because the condition on line 1750 was never true
1751 return
1753 visited: set[type] = set()
1754 iteratorStack: list[Iterator[type]] = list()
1756 for baseClass in baseClasses:
1757 yield baseClass
1758 visited.add(baseClass)
1759 iteratorStack.append(iter(baseClass.__bases__))
1761 while True:
1762 try:
1763 base = next(iteratorStack[-1]) # type: type
1764 if base not in visited: 1764 ↛ 1769line 1764 didn't jump to line 1769 because the condition on line 1764 was always true
1765 yield base
1766 if len(base.__bases__) > 0: 1766 ↛ 1767line 1766 didn't jump to line 1767 because the condition on line 1766 was never true
1767 iteratorStack.append(iter(base.__bases__))
1768 else:
1769 continue
1771 except StopIteration:
1772 iteratorStack.pop()
1774 if len(iteratorStack) == 0: 1774 ↛ 1761line 1774 didn't jump to line 1761 because the condition on line 1774 was always true
1775 break
1777 @classmethod
1778 def _iterateBaseClassPaths(metacls, baseClasses: tuple[type]) -> Generator[tuple[type, ...], None, None]:
1779 """
1780 Return a generator to iterate all possible inheritance paths for a given list of base-classes.
1782 An inheritance path is expressed as a tuple of base-classes from current base-class (left-most item) to
1783 :class:`object` (right-most item).
1785 :param baseClasses: The tuple of :term:`base-classes <base-class>` the class is derived from.
1786 :returns: Generator to iterate all inheritance paths. |br|
1787 An inheritance path is a tuple of types (base-classes).
1788 """
1789 if len(baseClasses) == 0: 1789 ↛ 1790line 1789 didn't jump to line 1790 because the condition on line 1789 was never true
1790 return
1792 typeStack: list[type] = list()
1793 iteratorStack: list[Iterator[type]] = list()
1795 for baseClass in baseClasses:
1796 # 'object' as a direct base-class is a path of its own.
1797 if len(baseClass.__bases__) == 0:
1798 yield (baseClass, )
1799 continue
1801 typeStack.append(baseClass)
1802 iteratorStack.append(iter(baseClass.__bases__))
1804 while True:
1805 try:
1806 base = next(iteratorStack[-1]) # type: type
1807 typeStack.append(base)
1808 if len(base.__bases__) == 0:
1809 yield tuple(typeStack)
1810 typeStack.pop()
1811 else:
1812 iteratorStack.append(iter(base.__bases__))
1814 except StopIteration:
1815 typeStack.pop()
1816 iteratorStack.pop()
1818 if len(typeStack) == 0:
1819 break
1821 @classmethod
1822 def _checkForAbstractMethods(
1823 metacls,
1824 baseClasses: tuple[type],
1825 members: dict[str, Any]
1826 ) -> tuple[dict[str, Callable[..., Any]], dict[str, Any]]:
1827 """
1828 Check if the current class contains abstract methods and return a tuple of them.
1830 These abstract methods might be inherited from any base-class. If there are inherited abstract methods, check if
1831 they are now implemented (overridden) by the current class that's right now constructed.
1833 :param baseClasses: The tuple of :term:`base-classes <base-class>` the class is derived from.
1834 :param members: The dictionary of members for the constructed class.
1835 :returns: A tuple of abstract method's names.
1836 """
1837 abstractMethods = {}
1838 # Aggregate all abstract methods from all base-classes.
1839 for baseClass in baseClasses:
1840 if hasattr(baseClass, "__abstractMethods__"):
1841 abstractMethods.update(baseClass.__abstractMethods__)
1843 # Only a member implementing an inherited abstract method needs a wrapper.
1844 if len(abstractMethods) > 0:
1845 for base in baseClasses:
1846 for memberName, member in base.__dict__.items():
1847 # A method the new class defines itself is the implementation; it must not be replaced by the one an
1848 # inheritance branch happens to carry (pyTooling #297).
1849 if memberName in members or memberName not in abstractMethods:
1850 continue
1852 function = metacls._getMarkedFunction(member)
1853 if (isinstance(function, FunctionType) and
1854 not (hasattr(function, "__abstract__") or hasattr(function, "__mustOverride__"))):
1855 def outer(method):
1856 """
1857 Nested function creating a wrapper, so the abstract method of the base-class isn't modified itself.
1859 :param method: The inherited abstract method.
1860 :returns: A wrapper forwarding to that method.
1861 """
1862 @wraps(method)
1863 def inner(*args: Any, **kwargs: Any):
1864 """
1865 Wrapper forwarding to the inherited abstract method.
1867 :param args: Positional parameters passed to the method, the instance or class first, if bound.
1868 :param kwargs: Named parameters passed to the method.
1869 :returns: Whatever the wrapped method returns.
1870 """
1871 return method(*args, **kwargs)
1873 # ':func:`~functools.wraps` copies the wrapped function's '__dict__', which carries the
1874 # bookkeeping ExtendedType attached to it. The wrapper belongs to the class being
1875 # constructed, so the owner is dropped and '_findMethods' assigns the new one.
1876 inner.__dict__.pop("__classobj__", None)
1878 return inner
1879 # Add the wrapper as new class member. Before adding it, apply classmethod/staticmethod to it, or make it
1880 # the getter of a copy of the property.
1881 if function is member:
1882 members[memberName] = outer(member)
1883 elif isinstance(member, property):
1884 members[memberName] = member.getter(outer(function))
1885 else:
1886 members[memberName] = type(member)(outer(function))
1888 # Check if methods are marked:
1889 # * If so, add them to list of abstract methods
1890 # * If not, method is now implemented and removed from list
1891 for memberName, member in members.items():
1892 function = metacls._getMarkedFunction(member)
1893 if callable(function):
1894 if ((hasattr(function, "__abstract__") and function.__abstract__) or
1895 (hasattr(function, "__mustOverride__") and function.__mustOverride__)):
1896 abstractMethods[memberName] = member
1897 elif memberName in abstractMethods:
1898 del abstractMethods[memberName]
1900 return abstractMethods, members
1902 @staticmethod
1903 def _getMarkedFunction(member: Any) -> Any:
1904 """
1905 Return the function, which carries the markers of :deco:`abstractmethod` and :deco:`mustoverride` for a member.
1907 A :class:`classmethod` or :class:`staticmethod` wraps the marked function, a :class:`property` (and so
1908 :deco:`~pyTooling.Decorators.readonly`) holds it as its getter-method.
1910 :param member: A member of a class.
1911 :returns: The wrapped function, the getter-method, or the member itself.
1912 """
1913 if isinstance(member, (classmethod, staticmethod)):
1914 return member.__func__
1915 elif isinstance(member, property):
1916 return member.fget
1918 return member
1920 @classmethod
1921 def _wrapInitMethodIfSlotDefaults(metacls, newClass: ExtendedType, mixin: bool) -> None:
1922 """
1923 If slotted fields have initial values, wrap the ``__init__`` method, so it assigns them before calling it.
1925 A slot can't have a class attribute of the same name, so an initial value of a slotted field (``_name: str =
1926 "root"``) can't stay in the class body. :meth:`_computeSlots` collects these values in :attr:`__slotDefaults__`
1927 instead. The wrapper assigns the initial values of the whole hierarchy - of every base-class and mixin-class - to
1928 the new instance, then calls the class' own (or inherited) ``__init__``.
1930 Only the wrapper of the instance's own class assigns them; a base-class' wrapper, reached by
1931 ``super().__init__()``, calls its ``__init__`` only. So a derived class' ``__init__`` may assign a base-class'
1932 field before calling ``super().__init__()``, and a base-class' initial values are assigned, even if the derived
1933 class' ``__init__`` doesn't call it.
1935 A mixin-class gets no wrapper: its fields are slots of the class it is mixed into, or entries in the instance's
1936 ``__dict__``, if that class has no slots.
1938 The value is assigned, not copied: a mutable initial value is shared by all instances, as a class variable is.
1940 The initial values of the whole hierarchy are kept in :attr:`__allSlotDefaults__`, merged from the direct
1941 base-classes' mappings and this class' own :attr:`__slotDefaults__`, so a class doesn't walk its method resolution
1942 order.
1944 :param newClass: The newly created class.
1945 :param mixin: If ``True``, the class is a mixin-class.
1946 """
1947 allSlotDefaults = {}
1948 for baseClass in reversed(newClass.__bases__):
1949 allSlotDefaults.update(getattr(baseClass, "__allSlotDefaults__", {}))
1950 allSlotDefaults.update(newClass.__slotDefaults__)
1951 newClass.__allSlotDefaults__ = allSlotDefaults
1953 if mixin or len(allSlotDefaults) == 0:
1954 return
1956 setters = []
1957 for fieldName, value in allSlotDefaults.items():
1958 slot = getattr(newClass, fieldName, None)
1959 if isinstance(slot, MemberDescriptorType):
1960 setters.append((slot.__set__, value))
1961 else:
1962 # A mixin-class' field in a class without slots is stored in the instance's '__dict__'.
1963 setters.append((lambda obj, val, name=fieldName: setattr(obj, name, val), value))
1965 init = newClass.__init__
1966 if hasattr(init, "__slot_defaults_wrapper__"):
1967 # The wrapper inherited from a base-class: this class' wrapper assigns the base-class' initial values too.
1968 init = init.__wrapped__
1970 @wraps(init)
1971 def init_withDefaults(self, *args: Any, **kwargs: Any) -> None:
1972 """
1973 Replacement ``__init__`` method, which assigns the initial values of slotted fields, then calls ``__init__``.
1975 :param args: Positional parameters passed to the original ``__init__``.
1976 :param kwargs: Named parameters passed to the original ``__init__``.
1977 """
1978 if self.__class__ is newClass:
1979 for setter, value in setters:
1980 setter(self, value)
1982 init(self, *args, **kwargs)
1984 # ':func:`~functools.wraps` copies the bookkeeping '_findMethods' attached to the original method.
1985 init_withDefaults.__dict__.pop("__classobj__", None)
1986 init_withDefaults.__slot_defaults_wrapper__ = True
1988 newClass.__init__ = init_withDefaults
1990 @classmethod
1991 def _wrapNewMethodIfSingleton(metacls, newClass, singleton: bool) -> bool:
1992 """
1993 If a class is a singleton, wrap the ``_new__`` method, so it returns a cached object, if a first object was created.
1995 Only the first object creation initializes the object.
1997 This implementation is threadsafe.
1999 :param newClass: The newly constructed class for further modifications.
2000 :param singleton: If ``True``, the class allows only a single instance to exist.
2001 :returns: ``True``, if the class is a singleton.
2002 """
2003 if hasattr(newClass, "__isSingleton__"):
2004 singleton = newClass.__isSingleton__
2006 if singleton:
2007 oldnew = newClass.__new__
2008 if hasattr(oldnew, "__singleton_wrapper__"):
2009 oldnew = oldnew.__wrapped__
2011 oldinit = newClass.__init__
2012 if hasattr(oldinit, "__singleton_wrapper__"):
2013 oldinit = oldinit.__wrapped__
2015 @wraps(oldnew)
2016 def singleton_new(cls, *args: Any, **kwargs: Any):
2017 """
2018 Replacement ``__new__`` method, which returns the singleton's one instance.
2020 The first call creates the object and caches it; every further call returns the cached object. The
2021 condition variable makes that safe when several threads instantiate the class at once.
2023 :param cls: The class being instantiated.
2024 :param args: Positional parameters passed to the original ``__new__``.
2025 :param kwargs: Named parameters passed to the original ``__new__``.
2026 :returns: The singleton's instance.
2027 """
2028 with cls.__singletonInstanceCond__:
2029 if cls.__singletonInstanceCache__ is None:
2030 obj = oldnew(cls, *args, **kwargs)
2031 cls.__singletonInstanceCache__ = obj
2032 else:
2033 obj = cls.__singletonInstanceCache__
2035 return obj
2037 @wraps(oldinit)
2038 def singleton_init(self, *args: Any, **kwargs: Any):
2039 """
2040 Replacement ``__init__`` method, which initializes the singleton's instance exactly once.
2042 A further instantiation waits until the first one finished initializing, so it never sees a half-built object.
2044 :param args: Positional parameters passed to the original ``__init__``.
2045 :param kwargs: Named parameters passed to the original ``__init__``.
2046 :raises ValueError: If a further instantiation passes parameters, which would be silently ignored.
2047 """
2048 cls = self.__class__
2049 cv = cls.__singletonInstanceCond__
2050 with cv:
2051 if cls.__singletonInstanceInit__:
2052 oldinit(self, *args, **kwargs)
2053 cls.__singletonInstanceInit__ = False
2054 cv.notify_all()
2055 elif args or kwargs:
2056 raise ValueError("A further instance of a singleton can't be reinitialized with parameters.")
2057 else:
2058 while cls.__singletonInstanceInit__: 2058 ↛ 2059line 2058 didn't jump to line 2059 because the condition on line 2058 was never true
2059 cv.wait()
2061 singleton_new.__singleton_wrapper__ = True
2062 singleton_init.__singleton_wrapper__ = True
2064 newClass.__new__ = singleton_new
2065 newClass.__init__ = singleton_init
2066 newClass.__singletonInstanceCond__ = Condition()
2067 newClass.__singletonInstanceInit__ = True
2068 newClass.__singletonInstanceCache__ = None
2069 return True
2071 return False
2073 @classmethod
2074 def _collectExpectedMembers(
2075 metacls,
2076 className: str,
2077 baseClasses: tuple[type, ...],
2078 members: dict[str, Any],
2079 expects: Iterable[str] | type
2080 ) -> dict[str, str]:
2081 """
2082 Collect the members expected by this class and by every class in its inheritance hierarchy.
2084 A mixin-class states what it needs from the class it is mixed into, and that expectation has to survive until a
2085 concrete class can satisfy it. The result maps each expected member's name to the name of the class expecting
2086 it, so an error message can name the origin.
2088 :param className: The name of the class being constructed.
2089 :param baseClasses: The tuple of base-classes the class is derived from.
2090 :param members: The dictionary of members for the constructed class.
2091 :param expects: Names of members the class being constructed expects, or a :class:`typing.Protocol` class
2092 whose members are these names.
2093 :returns: Dictionary mapping an expected member's name to the name of the class expecting it.
2094 :raises ValueError: If parameter 'expects' is :class:`typing.Protocol` itself, which names no members. |br|
2095 Derive a protocol class naming the expected members.
2096 :raises TypeError: If parameter 'expects' is a class, but not a :class:`typing.Protocol`.
2097 :raises TypeError: If parameter 'expects' is not an iterable of strings.
2098 :raises TypeError: If an element of parameter 'expects' is not a string.
2099 """
2100 if isinstance(expects, type):
2101 if expects is Protocol:
2102 ex = ValueError("Parameter 'expects' is 'typing.Protocol' itself, which names no members.")
2103 ex.add_note("Derive a protocol class naming the expected members.")
2104 raise ex
2105 # WORKAROUND: Python <3.13
2106 # elif not is_protocol(expects):
2107 elif not getattr(expects, "_is_protocol", False):
2108 ex = TypeError("Parameter 'expects' is a class, but not a 'typing.Protocol'.")
2109 ex.add_note(f"Got class '{getFullyQualifiedName(expects)}'.")
2110 raise ex
2112 # WORKAROUND: Python <3.13
2113 # expects = tuple(sorted(get_protocol_members(expects)))
2114 expects = tuple(sorted(_get_protocol_attrs(expects)))
2116 # A tuple (the default) skips the slower isinstance check against the abstract base class 'Iterable'.
2117 elif type(expects) is not tuple and (isinstance(expects, str) or not isinstance(expects, Iterable)):
2118 ex = TypeError("Parameter 'expects' is not an iterable of strings.")
2119 ex.add_note(f"Got type '{getFullyQualifiedName(expects)}'.")
2120 raise ex
2122 expects = tuple(expects)
2123 for memberName in expects:
2124 if not isinstance(memberName, str):
2125 ex = TypeError("Parameter 'expects' contains an element that is not a string.")
2126 ex.add_note(f"Got type '{getFullyQualifiedName(memberName)}'.")
2127 raise ex
2129 expected: dict[str, str] = {}
2130 for baseClass in baseClasses:
2131 for memberName, origin in getattr(baseClass, "__expectedMembers__", {}).items():
2132 expected.setdefault(memberName, origin)
2134 # a class recreated by '@mixin' and friends carries its own expectations in the copied members
2135 for memberName, origin in members.get("__expectedMembers__", {}).items():
2136 expected.setdefault(memberName, origin)
2138 for memberName in expects:
2139 expected.setdefault(memberName, className)
2141 return expected
2143 @classmethod
2144 def _computeMissingMembers(metacls, newClass: type, mixin: bool) -> tuple[str, ...]:
2145 """
2146 Determine which of the expected members the class doesn't provide.
2148 A member is provided when it is reachable on the class: a method, a property, a class variable, or a field, for
2149 which :class:`ExtendedType` created a slot descriptor when the mixin-class joined the primary inheritance line.
2151 A mixin-class is never missing anything, because it can't provide what it expects from its host class.
2153 :param newClass: The newly constructed class.
2154 :param mixin: ``True``, if the class is a mixin-class.
2155 :returns: Tuple of expected member names the class doesn't provide.
2156 """
2157 if mixin:
2158 return tuple()
2160 return tuple(memberName for memberName in newClass.__expectedMembers__ if not hasattr(newClass, memberName))
2162 @classmethod
2163 def _wrapNewMethodIfExpectationUnfulfilled(metacls, newClass) -> bool:
2164 """
2165 If the class doesn't provide every expected member, replace the ``__new__`` method, so it raises an exception.
2167 The diagnosis happens here, at class construction time, but the exception is raised on instantiation - the same
2168 way an abstract class is handled. A class is allowed to stay incomplete as long as nothing instantiates it, so
2169 an intermediate class can pass an expectation on to its own subclasses.
2171 :param newClass: The newly constructed class for further modifications.
2172 :returns: ``True``, if the class has unfulfilled expectations.
2173 """
2174 if len(newClass.__missingMembers__) == 0:
2175 # skip an intermediate 'new' function if the class fulfills its expectations again
2176 oldnew = newClass.__new__
2177 if hasattr(oldnew, "__raises_unfulfilled_expectation_error__"):
2178 metacls._restoreNewMethod(newClass, oldnew.__wrapped__)
2180 return False
2182 oldnew = newClass.__new__
2183 if hasattr(oldnew, "__raises_unfulfilled_expectation_error__"):
2184 oldnew = oldnew.__wrapped__
2186 @wraps(oldnew)
2187 def unfulfilled_new(cls, *_, **__):
2188 """
2189 Replacement ``__new__`` method, which rejects the instantiation of a class with unfulfilled expectations.
2191 :param cls: The class an instance was requested of.
2192 :raises UnfulfilledExpectationError: Always, because the class doesn't provide every expected member.
2193 """
2194 ex = UnfulfilledExpectationError(
2195 f"Class '{cls.__name__}' doesn't provide every expected member.",
2196 incompleteClass=cls,
2197 missingMembers=newClass.__missingMembers__
2198 )
2199 for memberName in newClass.__missingMembers__:
2200 ex.add_note(f"Missing '{memberName}', expected by '{newClass.__expectedMembers__[memberName]}'.")
2201 raise ex
2203 unfulfilled_new.__raises_unfulfilled_expectation_error__ = True
2205 newClass.__new__ = unfulfilled_new
2206 return True
2208 @classmethod
2209 def _wrapMethodsWithUnfulfilledExpectations(metacls, newClass) -> tuple[str, ...]:
2210 """
2211 Replace every method marked with :deco:`expects` whose expected members the class doesn't provide.
2213 The replacement raises an :exc:`UnfulfilledExpectationError` when it is called, so the class stays usable and
2214 only the method that can't work is rejected. A method whose expectation is fulfilled is left alone - the
2215 original function stays in the class, so a fulfilled expectation costs nothing per call. A replacement
2216 inherited from a base-class is removed again as soon as a class provides the missing members.
2218 Only the method the class resolves counts: an override without the marker expects nothing, even if the method it
2219 overrides does.
2221 :param newClass: The newly constructed class for further modifications.
2222 :returns: Tuple of method names that were replaced.
2223 """
2224 # Collect every marked method reachable on this class, looking underneath a replacement from a base-class
2225 marked: dict[str, Callable[..., Any]] = {}
2226 # The last class of every MRO is 'object', whose members are never marked.
2227 for baseClass in reversed(newClass.__mro__[:-1]):
2228 for memberName, member in vars(baseClass).items():
2229 original = getattr(member, "__wrapped__", member)
2230 # A class held by a class variable carries its class-level expectations, but is no marked method.
2231 if not isinstance(original, type) and hasattr(original, "__expectedMembers__"):
2232 marked[memberName] = original
2234 wrapped: list[str] = []
2235 for memberName, original in marked.items():
2236 inherited = getattr(newClass, memberName)
2237 if not hasattr(getattr(inherited, "__wrapped__", inherited), "__expectedMembers__"):
2238 # an override without the marker expects nothing
2239 continue
2241 missing = tuple(name for name in original.__expectedMembers__ if not hasattr(newClass, name))
2242 isReplaced = hasattr(inherited, "__raises_unfulfilled_expectation_error__")
2244 if len(missing) == 0:
2245 # the class provides the members now, so the original method is reachable again
2246 if isReplaced:
2247 setattr(newClass, memberName, original)
2248 continue
2250 wrapped.append(memberName)
2251 if isReplaced and inherited.__missingMembers__ == missing:
2252 # the inherited replacement already reports exactly these members
2253 continue
2255 def unfulfilledMethodFactory(methodName: str, missingMembers: tuple[str, ...]) -> Callable[..., NoReturn]:
2256 """
2257 Create the replacement for one method, binding the names it is missing.
2259 :param methodName: Name of the method that is replaced.
2260 :param missingMembers: Names of the members the method needs and the class doesn't provide.
2261 :returns: Replacement method raising an :exc:`UnfulfilledExpectationError`.
2262 """
2263 @wraps(original)
2264 def unfulfilledMethod(self, *_, **__) -> NoReturn:
2265 """
2266 Replacement method, which rejects a call that would fail on a missing member.
2268 :raises UnfulfilledExpectationError: Always, because the class doesn't provide every expected member.
2269 """
2270 message = f"Method '{type(self).__name__}.{methodName}()' expects members this class doesn't provide."
2271 ex = UnfulfilledExpectationError(message, incompleteClass=type(self), missingMembers=missingMembers)
2272 for memberName in missingMembers:
2273 ex.add_note(f"Missing '{memberName}'.")
2274 raise ex
2276 return unfulfilledMethod
2278 replacement = unfulfilledMethodFactory(memberName, missing)
2280 replacement.__raises_unfulfilled_expectation_error__ = True
2281 replacement.__missingMembers__ = missing
2283 setattr(newClass, memberName, replacement)
2285 return tuple(wrapped)
2287 @staticmethod
2288 def _restoreNewMethod(newClass: type, originalNew: Callable[..., Any]) -> None:
2289 """
2290 Restore the ``__new__`` method, which a replacement raising an exception had wrapped.
2292 :meth:`object.__new__` can't be put back as it is: assigned to a class, it rejects every parameter of the
2293 instantiation. A replacement calling it with the class only is assigned instead.
2295 :param newClass: The newly constructed class for further modifications.
2296 :param originalNew: The ``__new__`` method the replacement had wrapped.
2297 """
2298 # WORKAROUND: __new__ checks tp_new and implements different behavior
2299 # Bugreport: https://github.com/python/cpython/issues/105888
2300 if originalNew is object.__new__:
2301 @wraps(object.__new__)
2302 def wrapped_new(inst, *_, **__):
2303 """
2304 Replacement ``__new__`` method for a class that can be instantiated again.
2306 It calls :meth:`object.__new__` with the class only, because that implementation rejects further
2307 parameters.
2309 :param inst: The class being instantiated.
2310 :returns: The new instance.
2311 """
2312 return object.__new__(inst)
2314 newClass.__new__ = wrapped_new
2315 else:
2316 newClass.__new__ = originalNew
2318 @classmethod
2319 def _wrapNewMethodIfAbstract(metacls, newClass) -> bool:
2320 """
2321 If the class is marked ``__abstractClass__`` or has abstract methods, replace the ``_new__`` method, so it
2322 raises an exception.
2324 :param newClass: The newly constructed class for further modifications.
2325 :returns: ``True``, if the class is abstract.
2326 :raises AbstractClassError: If the class is abstract and can't be instantiated.
2327 :raises ExtendedTypeError: If a singleton wrapper was found around a method raising
2328 :exc:`AbstractClassError`, which is not handled yet.
2329 """
2330 # Replace '__new__' by a variant to throw an error on not overridden methods
2331 if newClass.__abstractClass__ or len(newClass.__abstractMethods__) > 0:
2332 oldnew = newClass.__new__
2333 if hasattr(oldnew, "__raises_abstract_class_error__"):
2334 oldnew = oldnew.__wrapped__
2336 @wraps(oldnew)
2337 def abstract_new(cls, *_, **__):
2338 """
2339 Replacement ``__new__`` method, which rejects the instantiation of an abstract class.
2341 The message names the methods to override, or says that the class needs to be derived when it was declared
2342 abstract without having abstract methods.
2344 :param cls: The abstract class an instance was requested of.
2345 :raises AbstractClassError: Always, because an abstract class can't be instantiated.
2346 """
2347 if len(newClass.__abstractMethods__) > 0:
2348 methodNames = "', '".join(newClass.__abstractMethods__)
2349 raise AbstractClassError(
2350 f"Class '{cls.__name__}' is abstract. The following methods: '{methodNames}' need to be overridden in a "
2351 f"derived class.",
2352 abstractClass=cls,
2353 abstractMethods=newClass.__abstractMethods__
2354 )
2355 else:
2356 raise AbstractClassError(
2357 f"Class '{cls.__name__}' is abstract and needs to be derived.",
2358 abstractClass=cls,
2359 abstractMethods=()
2360 )
2362 abstract_new.__raises_abstract_class_error__ = True
2364 newClass.__new__ = abstract_new
2365 return True
2367 # A function or built-in method without the marker: nothing to unwrap.
2368 elif (isinstance(newClass.__new__, (FunctionType, BuiltinFunctionType)) and
2369 not hasattr(newClass.__new__, "__raises_abstract_class_error__")):
2370 return False
2372 # Handle classes which are not abstract, especially derived classes, if not abstract anymore
2373 else:
2374 # skip intermediate 'new' function if class isn't abstract anymore
2375 try:
2376 if newClass.__new__.__raises_abstract_class_error__: 2376 ↛ 2378line 2376 didn't jump to line 2378 because the condition on line 2376 was always true
2377 metacls._restoreNewMethod(newClass, newClass.__new__.__wrapped__)
2378 elif newClass.__new__.__isSingleton__:
2379 raise ExtendedTypeError(
2380 "Found a singleton wrapper around an AbstractError raising method. This case is not handled yet."
2381 )
2382 except AttributeError as ex:
2383 if ex.name != "__raises_abstract_class_error__":
2384 raise ex
2386 return False
2388 # Additional properties and methods on a class
2389 @readonly
2390 def HasClassAttributes(self) -> bool:
2391 """
2392 Read-only property to check if the class has Attributes (:attr:`__pyattr__`).
2394 :returns: ``True``, if the class has Attributes.
2395 """
2396 try:
2397 return len(self.__pyattr__) > 0
2398 except AttributeError:
2399 return False
2401 @readonly
2402 def HasMethodAttributes(self) -> bool:
2403 """
2404 Read-only property to check if the class has methods with Attributes (:attr:`__methodsWithAttributes__`).
2406 :returns: ``True``, if the class has any method with Attributes.
2407 """
2408 try:
2409 return len(self.__methodsWithAttributes__) > 0
2410 except AttributeError:
2411 return False
2414@export
2415class SlottedObject(metaclass=ExtendedType, slots=True):
2416 """Classes derived from this class will store all members in ``__slots__``."""