Coverage for pyTooling/MetaClasses/__init__.py: 93%

481 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-22 21:29 +0000

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). 

34 

35.. hint:: 

36 

37 See :ref:`high-level help <META>` for explanations and usage examples. 

38 

39.. seealso:: 

40 

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 

49 

50from functools import wraps 

51from itertools import chain 

52from re import compile as re_compile 

53from sys import modules, version_info 

54from threading import Condition 

55from types import BuiltinFunctionType, FunctionType, MethodType 

56from typing import Any, Callable, Generator, Iterator, Iterable, Union, NoReturn, Self 

57from typing import TypeVar, Generic, _GenericAlias, ClassVar, Optional as Nullable 

58 

59from pyTooling.Exceptions import ToolingException 

60from pyTooling.Decorators import export, readonly 

61from pyTooling.Warning import Warning, WarningCollector 

62 

63 

64__all__ = ["M"] 

65 

66TAttr = TypeVar("TAttr") # , bound='Attribute') 

67"""A type variable for :class:`~pyTooling.Attributes.Attribute`.""" 

68 

69TAttributeFilter = Union[TAttr, Iterable[TAttr], None] 

70"""A type hint for a predicate parameter that accepts either a single :class:`~pyTooling.Attributes.Attribute` or an 

71iterable of those.""" 

72 

73 

74@export 

75class ExtendedTypeError(ToolingException): 

76 """The exception is raised by the meta-class :class:`~pyTooling.MetaClasses.ExtendedType`.""" 

77 

78 

79@export 

80class BaseClassWithoutSlotsError(ExtendedTypeError): 

81 """ 

82 This exception is raised when a class using ``__slots__`` inherits from at-least one base-class not using ``__slots__``. 

83 

84 .. seealso:: 

85 

86 :ref:`Python data model for slots <slots>` 

87 |rarr| What ``__slots__`` does and which rules Python imposes on it. 

88 :term:`Glossary entry __slots__ <__slots__>` 

89 |rarr| The glossary's short definition. 

90 """ 

91 

92 

93@export 

94class BaseClassWithNonEmptySlotsError(ExtendedTypeError): 

95 """ 

96 This exception is raised when a mixin-class uses slots, but Python prohibits slots. 

97 

98 .. important:: 

99 

100 To fulfill Python's requirements on slots, pyTooling uses slots only on the prinmary inheritance line. 

101 Mixin-classes collect slots, which get materialized when the mixin-class (secondary inheritance lines) gets merged 

102 into the primary inheritance line. 

103 """ 

104 

105 

106@export 

107class BaseClassIsNotAMixinError(ExtendedTypeError): 

108 """ 

109 This exception is raised when a class inherits from a secondary base-class that is not declared as a mixin. 

110 

111 Only the primary inheritance line may carry a normal class; every further base-class needs ``mixin=True`` (or the 

112 :deco:`~pyTooling.MetaClasses.mixin` decorator), because that is what allows their slots to be merged. 

113 """ 

114 

115 

116@export 

117class DuplicateFieldInSlotsError(ExtendedTypeError): 

118 """ 

119 This exception is raised when a slot name is used multiple times within the inheritance hierarchy. 

120 """ 

121 

122 

123@export 

124class UnannotatedFieldWarning(Warning): 

125 """ 

126 A class declares a field that was assigned in the class body without a type annotation. 

127 

128 An object field is annotated with its type, a class variable with :class:`~typing.ClassVar`. Without an annotation, 

129 :class:`ExtendedType` can't tell the two apart, and the field never becomes a slot. 

130 """ 

131 

132 

133@export 

134class IncompatibleMetaClassError(ExtendedTypeError): 

135 """ 

136 This exception is raised when a class decorated with :deco:`slotted`, :deco:`mixin` or :deco:`singleton` uses a 

137 meta-class that is neither :class:`type` nor derived from :class:`~pyTooling.MetaClasses.ExtendedType`. 

138 """ 

139 

140 

141@export 

142class AbstractClassError(ExtendedTypeError): 

143 """ 

144 This exception is raised, when a class contains methods marked with *abstractmethod* or *must-override*. 

145 

146 .. seealso:: 

147 

148 :deco:`~pyTooling.MetaClasses.abstractmethod` 

149 |rarr| Mark a method as *abstract*. 

150 :deco:`~pyTooling.MetaClasses.mustoverride` 

151 |rarr| Mark a method as *must overrride*. 

152 :exc:`~MustOverrideClassError` 

153 |rarr| Exception raised, if a method is marked as *must-override*. 

154 """ 

155 

156 

157@export 

158class MustOverrideClassError(AbstractClassError): 

159 """ 

160 This exception is raised, when a class contains methods marked with *must-override*. 

161 

162 .. seealso:: 

163 

164 :deco:`~pyTooling.MetaClasses.abstractmethod` 

165 |rarr| Mark a method as *abstract*. 

166 :deco:`~pyTooling.MetaClasses.mustoverride` 

167 |rarr| Mark a method as *must overrride*. 

168 :exc:`~AbstractClassError` 

169 |rarr| Exception raised, if a method is marked as *abstract*. 

170 """ 

171 

172 

173# """ 

174# Metaclass that allows multiple dispatch of methods based on method signatures. 

175# 

176# .. seealso: 

177# 

178# `Python Cookbook - Multiple dispatch with function annotations <https://GitHub.com/dabeaz/python-cookbook/blob/master/src/9/multiple_dispatch_with_function_annotations/example1.py?ts=2>`__ 

179# """ 

180 

181 

182M = TypeVar("M", bound=Callable[..., Any]) #: A type variable for methods. 

183C = TypeVar("C", bound=type) #: A type variable for classes. 

184 

185 

186def _recreateClass(cls: type, decoratorName: str, **options: bool) -> type: 

187 """ 

188 Recreate a class with :class:`ExtendedType` (or the class' own compatible meta-class) applying the given options. 

189 

190 :param cls: Class to recreate. 

191 :param decoratorName: Name of the calling decorator. It's used in the error message. 

192 :param options: Meta-class options like ``slots``, ``mixin`` or ``singleton``. 

193 :returns: The recreated class. 

194 :raises IncompatibleMetaClassError: If the class' meta-class is neither :class:`type`, nor derived from 

195 :class:`ExtendedType`. |br| 

196 A decorated class must use :class:`type` or a meta-class derived from 

197 :class:`~pyTooling.MetaClasses.ExtendedType`. 

198 """ 

199 if cls.__class__ is type: 

200 metacls = ExtendedType 

201 elif issubclass(cls.__class__, ExtendedType): 

202 metacls = cls.__class__ 

203 for method in cls.__methods__: 

204 delattr(method, "__classobj__") 

205 else: 

206 metaClass = cls.__class__ 

207 ex = IncompatibleMetaClassError(f"Class '{cls.__name__}' decorated with '@{decoratorName}' uses an incompatible meta-class.") 

208 ex.add_note(f"Meta-class is '{metaClass.__module__}.{metaClass.__name__}'.") 

209 ex.add_note(f"A decorated class must use 'type' or a meta-class derived from 'pyTooling.MetaClasses.ExtendedType'.") 

210 raise ex 

211 

212 bases = tuple(base for base in cls.__bases__ if base is not object) 

213 slots = cls.__dict__["__slots__"] if "__slots__" in cls.__dict__ else tuple() 

214 members = { 

215 "__qualname__": cls.__qualname__ 

216 } 

217 for key, value in cls.__dict__.items(): 

218 if key not in slots: 

219 members[key] = value 

220 

221 return metacls(cls.__name__, bases, members, **options) 

222 

223 

224@export 

225def slotted(cls): 

226 """ 

227 Class decorator recreating a class with slots derived from its annotated fields. 

228 

229 It is the decorator form of ``metaclass=ExtendedType, slots=True``, for a class that shouldn't name the 

230 meta-class explicitly. 

231 

232 :param cls: The class to recreate. 

233 :returns: The recreated class, using ``__slots__``. 

234 

235 .. seealso:: 

236 

237 :deco:`~pyTooling.MetaClasses.mixin` 

238 |rarr| Recreate a class as a mixin-class. 

239 :deco:`~pyTooling.MetaClasses.singleton` 

240 |rarr| Recreate a class as a singleton. 

241 """ 

242 return _recreateClass(cls, "slotted", slots=True) 

243 

244 

245@export 

246def mixin(cls): 

247 """ 

248 Class decorator recreating a class as a mixin-class. 

249 

250 A mixin-class collects its slots instead of materializing them; they are merged when the mixin joins a primary 

251 inheritance line. 

252 

253 :param cls: The class to recreate. 

254 :returns: The recreated class, marked as a mixin. 

255 

256 .. seealso:: 

257 

258 :deco:`~pyTooling.MetaClasses.slotted` 

259 |rarr| Recreate a class with slots. 

260 :deco:`~pyTooling.MetaClasses.singleton` 

261 |rarr| Recreate a class as a singleton. 

262 """ 

263 return _recreateClass(cls, "mixin", mixin=True) 

264 

265 

266@export 

267def singleton(cls): 

268 """ 

269 Class decorator recreating a class as a singleton. 

270 

271 Every instantiation of the decorated class returns the same object, including its state. 

272 

273 :param cls: The class to recreate. 

274 :returns: The recreated class, marked as a singleton. 

275 

276 .. seealso:: 

277 

278 :deco:`~pyTooling.MetaClasses.slotted` 

279 |rarr| Recreate a class with slots. 

280 :deco:`~pyTooling.MetaClasses.mixin` 

281 |rarr| Recreate a class as a mixin-class. 

282 """ 

283 return _recreateClass(cls, "singleton", singleton=True) 

284 

285 

286@export 

287def abstractclass(cls: C) -> C: 

288 """ 

289 Mark a class as *abstract*, so it cannot be instantiated, although it has no abstract method. 

290 

291 Some classes exist only to be derived from - a base-class collecting shared infrastructure, for instance - and 

292 have nothing to mark with :deco:`abstractmethod`. This decorator says so directly: it sets ``__abstractClass__`` 

293 on the class and recomputes ``__isAbstract__``, which replaces ``__new__`` by a method raising an 

294 :exc:`~pyTooling.MetaClasses.AbstractClassError`. 

295 

296 The marker belongs to the decorated class alone. :class:`ExtendedType` clears it on every class it creates, so a 

297 derived class is concrete again unless it is decorated itself or inherits an abstract method. 

298 

299 .. warning:: 

300 

301 This decorator needs meta-class :class:`~pyTooling.MetaClasses.ExtendedType`, which does the computation. 

302 

303 .. admonition:: ``example.py`` 

304 

305 .. code-block:: python 

306 

307 @abstractclass 

308 class Base(metaclass=ExtendedType): 

309 '''This class needs to be inherited.''' 

310 

311 :param cls: Class that is marked as *abstract*. 

312 :returns: The same class, marked and with its abstractness recomputed. 

313 :raises AttributeError: If the class was not created by :class:`ExtendedType`, because nothing would compute it. |br| 

314 Add ``metaclass=ExtendedType`` to the class definition, so abstractness is computed. 

315 

316 .. seealso:: 

317 

318 :exc:`~pyTooling.MetaClasses.AbstractClassError` 

319 |rarr| The exception raised when a still abstract class gets instantiated. 

320 :deco:`~pyTooling.MetaClasses.abstractmethod` 

321 |rarr| Mark a method as *abstract* and raise a :exc:`NotImplementedError` when called. 

322 :deco:`~pyTooling.MetaClasses.mustoverride` 

323 |rarr| Mark a method as *mustoverride* (minimal implementation, but can be called). 

324 :deco:`~pyTooling.Decorators.notimplemented` 

325 |rarr| Mark a *method* as not implemented and raise a :exc:`NotImplementedError`. 

326 """ 

327 if not isinstance(cls, ExtendedType): 

328 ex = AttributeError(f"Class '{cls.__name__}' is not created by meta-class 'ExtendedType'.") 

329 ex.add_note(f"Add 'metaclass=ExtendedType' to the class definition, so abstractness is computed.") 

330 raise ex 

331 

332 cls.__abstractClass__ = True 

333 

334 if not cls.__isAbstract__: 334 ↛ 337line 334 didn't jump to line 337 because the condition on line 334 was always true

335 cls.__isAbstract__ = ExtendedType._wrapNewMethodIfAbstract(cls) 

336 

337 return cls 

338 

339 

340@export 

341def abstractmethod(method: M) -> M: 

342 """ 

343 Mark a method as *abstract* and replace the implementation with a new method raising a :exc:`NotImplementedError`. 

344 

345 The original method is stored in ``<method>.__wrapped__`` and it's doc-string is copied to the replacing method. In 

346 additional field ``<method>.__abstract__`` is added. 

347 

348 .. warning:: 

349 

350 This decorator should be used in combination with meta-class :class:`~pyTooling.MetaClasses.ExtendedType`. 

351 Otherwise, an abstract class itself doesn't throw a :exc:`~pyTooling.MetaClasses.AbstractClassError` at 

352 instantiation. 

353 

354 .. admonition:: ``example.py`` 

355 

356 .. code-block:: python 

357 

358 class Data(mataclass=ExtendedType): 

359 @abstractmethod 

360 def method(self) -> bool: 

361 '''This method needs to be implemented.''' 

362 

363 :param method: Method that is marked as *abstract*. 

364 :returns: Replacement method, which raises a :exc:`NotImplementedError`. 

365 

366 .. seealso:: 

367 

368 :exc:`~pyTooling.MetaClasses.AbstractClassError` 

369 |rarr| The exception raised when a still abstract class gets instantiated. 

370 :deco:`~pyTooling.MetaClasses.abstractclass` 

371 |rarr| Mark a class as *abstract*. 

372 :deco:`~pyTooling.MetaClasses.mustoverride` 

373 |rarr| Mark a method as *mustoverride* (minimal implementation, but can be called). 

374 :deco:`~pyTooling.Decorators.notimplemented` 

375 |rarr| Mark a *method* as not implemented and raise a :exc:`NotImplementedError`. 

376 """ 

377 @wraps(method) 

378 def func(self) -> NoReturn: 

379 """ 

380 Replacement method, which raises a :exc:`NotImplementedError` when called. 

381 

382 :raises NotImplementedError: Always, because an abstract method has no implementation. 

383 """ 

384 raise NotImplementedError(f"Method '{method.__name__}' is abstract and needs to be overridden in a derived class.") 

385 

386 func.__abstract__ = True 

387 return func 

388 

389 

390@export 

391def mustoverride(method: M) -> M: 

392 """ 

393 Mark a method as *must-override*. 

394 

395 The returned function is the original function, but with an additional field ``<method>.____mustOverride__``, so a 

396 meta-class can identify a *must-override* method and raise an error. Such an error is not raised if the method is 

397 overridden by an inheriting class. 

398 

399 A *must-override* methods can offer a partial implementation, which is called via ``super()...``. 

400 

401 .. warning:: 

402 

403 This decorator needs to be used in combination with meta-class :class:`~pyTooling.MetaClasses.ExtendedType`. 

404 Otherwise, an abstract class itself doesn't throw a :exc:`~pyTooling.MetaClasses.MustOverrideClassError` at 

405 instantiation. 

406 

407 .. admonition:: ``example.py`` 

408 

409 .. code-block:: python 

410 

411 class Data(mataclass=ExtendedType): 

412 @mustoverride 

413 def method(self): 

414 '''This is a very basic implementation.''' 

415 

416 :param method: Method that is marked as *must-override*. 

417 :returns: Same method, but with additional ``<method>.__mustOverride__`` field. 

418 

419 .. seealso:: 

420 

421 :exc:`~pyTooling.MetaClasses.MustOverrideClassError` 

422 |rarr| The exception raised when a class gets instantiated still containing *mustoverride* methods. 

423 :deco:`~pyTooling.MetaClasses.abstractclass` 

424 |rarr| Mark a class as *abstract*. 

425 :deco:`~pyTooling.MetaClasses.abstractmethod` 

426 |rarr| Mark a method as *abstract* and raise a :exc:`NotImplementedError` when called. 

427 :deco:`~pyTooling.Decorators.notimplemented` 

428 |rarr| Mark a *method* as not implemented and raise a :exc:`NotImplementedError`. 

429 """ 

430 method.__mustOverride__ = True 

431 return method 

432 

433 

434# @export 

435# def overloadable(method: M) -> M: 

436# method.__overloadable__ = True 

437# return method 

438 

439 

440# @export 

441# class DispatchableMethod: 

442# """Represents a single multimethod.""" 

443# 

444# _methods: dict[Tuple, Callable] 

445# __name__: str 

446# __slots__ = ("_methods", "__name__") 

447# 

448# def __init__(self, name: str) -> None: 

449# self.__name__ = name 

450# self._methods = {} 

451# 

452# def __call__(self, *args: Any): 

453# """Call a method based on type signature of the arguments.""" 

454# types = tuple(type(arg) for arg in args[1:]) 

455# meth = self._methods.get(types, None) 

456# if meth: 

457# return meth(*args) 

458# else: 

459# raise TypeError(f"No matching method for types {types}.") 

460# 

461# def __get__(self, instance, cls) -> Self: 

462# """Descriptor method needed to make calls work in a class.""" 

463# if instance is not None: 

464# return MethodType(self, instance) 

465# else: 

466# return self 

467# 

468# def register(self, method: Callable) -> None: 

469# """Register a new method as a dispatchable.""" 

470# 

471# # Build a signature from the method's type annotations 

472# sig = signature(method) 

473# types: list[Type] = [] 

474# 

475# for name, parameter in sig.parameters.items(): 

476# if name == "self": 

477# continue 

478# 

479# if parameter.annotation is Parameter.empty: 

480# raise TypeError(f"Parameter '{name}' in method '{method.__name__}' must be annotated with a type.") 

481# 

482# if not isinstance(parameter.annotation, type): 

483# raise TypeError(f"Parameter '{name}' in method '{method.__name__}' annotation must be a type.") 

484# 

485# if parameter.default is not Parameter.empty: 

486# self._methods[tuple(types)] = method 

487# 

488# types.append(parameter.annotation) 

489# 

490# self._methods[tuple(types)] = method 

491 

492 

493# @export 

494# class DispatchDictionary(dict): 

495# """Special dictionary to build dispatchable methods in a metaclass.""" 

496# 

497# def __setitem__(self, key: str, value: Any): 

498# if callable(value) and key in self: 

499# # If key already exists, it must be a dispatchable method or callable 

500# currentValue = self[key] 

501# if isinstance(currentValue, DispatchableMethod): 

502# currentValue.register(value) 

503# else: 

504# dispatchable = DispatchableMethod(key) 

505# dispatchable.register(currentValue) 

506# dispatchable.register(value) 

507# 

508# super().__setitem__(key, dispatchable) 

509# else: 

510# super().__setitem__(key, value) 

511 

512 

513@export 

514class ExtendedType(type): 

515 """ 

516 An updates meta-class to construct new classes with an extended feature set. 

517 

518 .. todo:: META::ExtendedType Needs documentation. 

519 .. todo:: META::ExtendedType allow __dict__ and __weakref__ if slotted is enabled 

520 

521 .. rubric:: Features: 

522 

523 * Store object members more efficiently in ``__slots__`` instead of ``_dict__``. 

524 

525 * Implement ``__slots__`` only on primary inheritance line. 

526 * Collect class variables on secondary inheritance lines (mixin-classes) and defer implementation as ``__slots__``. 

527 * Handle object state exporting and importing for slots (:mod:`pickle` support) via ``__getstate__``/``__setstate__``. 

528 

529 * Allow only a single instance to be created (:term:`singleton`). |br| 

530 Further instantiations will return the previously create instance (identical object). 

531 * Define methods as :term:`abstract <abstract method>` or :term:`must-override <mustoverride method>` and prohibit 

532 instantiation of :term:`abstract classes <abstract class>`. 

533 

534 .. #* Allow method overloading and dispatch overloads based on argument signatures. 

535 

536 .. rubric:: Added class fields: 

537 

538 :__slotted__: True, if class uses `__slots__`. 

539 :__allSlots__: Set of class fields stored in slots for all classes in the inheritance hierarchy. 

540 :__slots__: Tuple of class fields stored in slots for current class in the inheritance hierarchy. |br| 

541 See :pep:`253` for details. 

542 :__isMixin__: True, if class is a mixin-class 

543 :__mixinSlots__: List of collected slots from secondary inheritance hierarchy (mixin hierarchy). 

544 :__methods__: List of methods. 

545 :__methodsWithAttributes__: List of methods with pyTooling attributes. 

546 :__abstractMethods__: List of abstract methods, which need to be implemented in the next class hierarchy levels. 

547 :__abstractClass__: True, if this class was decorated with :deco:`abstractclass`. 

548 :__isAbstract__: True, if class is abstract. 

549 :__isSingleton__: True, if class is a singleton 

550 :__singletonInstanceCond__: Condition variable to protect the singleton creation. 

551 :__singletonInstanceInit__: Singleton is initialized. 

552 :__singletonInstanceCache__: The singleton object, once created. 

553 :__pyattr__: List of class attributes. 

554 

555 .. rubric:: Added class properties: 

556 

557 :HasClassAttributes: Read-only property to check if the class has Attributes. 

558 :HasMethodAttributes: Read-only property to check if the class has methods with Attributes. 

559 

560 .. rubric:: Added methods: 

561 

562 If slots are used, the following methods are added to support :mod:`pickle`: 

563 

564 :__getstate__: Export an object's state for serialization. |br| 

565 See :pep:`307` for details. 

566 :__setstate__: Import an object's state for deserialization. |br| 

567 See :pep:`307` for details. 

568 

569 .. rubric:: Modified ``__new__`` method: 

570 

571 If class is a singleton, ``__new__`` will be replaced by a wrapper method. This wrapper is marked with ``__singleton_wrapper__``. 

572 

573 If class is abstract, ``__new__`` will be replaced by a method raising an exception. This replacement is marked with ``__raises_abstract_class_error__``. 

574 

575 .. rubric:: Modified ``__init__`` method: 

576 

577 If class is a singleton, ``__init__`` will be replaced by a wrapper method. This wrapper is marked by ``__singleton_wrapper__``. 

578 

579 .. rubric:: Modified abstract methods: 

580 

581 If a method is abstract, its marked with ``__abstract__``. |br| 

582 If a method is must override, its marked with ``__mustOverride__``. 

583 """ 

584 

585 # @classmethod 

586 # def __prepare__(cls, className, baseClasses, slots: bool = False, mixin: bool = False, singleton: bool = False): 

587 # return DispatchDictionary() 

588 

589 def __new__( 

590 self, 

591 className: str, 

592 baseClasses: tuple[type], 

593 members: dict[str, Any], 

594 slots: bool = False, 

595 mixin: bool = False, 

596 singleton: bool = False, 

597 **kwargs: Any 

598 ) -> Self: 

599 """ 

600 Construct a new class using this :term:`meta-class`. 

601 

602 :param className: The name of the class to construct. 

603 :param baseClasses: The tuple of :term:`base-classes <base-class>` the class is derived from. 

604 :param members: The dictionary of members for the constructed class. 

605 :param slots: Optional, if ``True``, store object attributes in :term:`__slots__ <slots>` instead of 

606 ``__dict__``. 

607 :param mixin: Optional, if ``True``, make the class a :term:`Mixin-Class`. If ``False``, create slots if 

608 ``slots`` 

609 is true. If ``None``, preserve behavior of primary base-class. 

610 :param singleton: Optional, if ``True``, make the class a :term:`Singleton`. 

611 :param kwargs: Any further class keyword argument, forwarded to :meth:`~object.__init_subclass__` as 

612 :func:`type` does. 

613 :returns: The new class. 

614 :raises AttributeError: If base-class has no '__slots__' attribute. 

615 :raises AttributeError: If slot already exists in base-class. 

616 """ 

617 from pyTooling.Attributes import ATTRIBUTES_MEMBER_NAME, AttributeScope 

618 

619 # Inherit 'slots' feature from primary base-class 

620 if len(baseClasses) > 0: 

621 primaryBaseClass = baseClasses[0] 

622 if isinstance(primaryBaseClass, self): 

623 slots = primaryBaseClass.__slotted__ 

624 

625 # Compute slots and mixin-slots from annotated fields as well as class- and object-fields with initial values. 

626 classFields, objectFields = self._computeSlots(className, baseClasses, members, slots, mixin) 

627 

628 # Compute abstract methods 

629 abstractMethods, members = self._checkForAbstractMethods(baseClasses, members) 

630 

631 # Create a new class - the remaining keyword arguments belong to '__init_subclass__', which 'type' calls 

632 newClass = type.__new__(self, className, baseClasses, members, **kwargs) 

633 

634 # Apply class fields 

635 for fieldName, typeAnnotation in classFields.items(): 

636 setattr(newClass, fieldName, typeAnnotation) 

637 

638 # Search in inheritance tree for abstract methods 

639 newClass.__abstractMethods__ = abstractMethods 

640 

641 newClass.__abstractClass__ = False 

642 newClass.__isAbstract__ = self._wrapNewMethodIfAbstract(newClass) 

643 newClass.__isSingleton__ = self._wrapNewMethodIfSingleton(newClass, singleton) 

644 

645 if slots: 

646 # If slots are used, implement __getstate__/__setstate__ API to support serialization using pickle. 

647 if "__getstate__" not in members: 

648 def __getstate__(self) -> dict[str, Any]: 

649 """ 

650 Return the object's state for pickling, collecting every slot of the class hierarchy. 

651 

652 :returns: Dictionary of slot names and their values. 

653 :raises ExtendedTypeError: If a slot was never assigned, so it has no value to serialize. 

654 """ 

655 try: 

656 return {slotName: getattr(self, slotName) for slotName in self.__allSlots__} 

657 except AttributeError as ex: 

658 raise ExtendedTypeError(f"Unassigned field '{ex.name}' in object '{self}' of type '{self.__class__.__name__}'.") from ex 

659 

660 newClass.__getstate__ = __getstate__ 

661 

662 if "__setstate__" not in members: 

663 def __setstate__(self, state: dict[str, Any]) -> None: 

664 """ 

665 Restore the object's state from unpickling, requiring exactly the slots of the class hierarchy. 

666 

667 :param state: Dictionary of slot names and their values. 

668 :raises ExtendedTypeError: If the given state misses a slot or carries an unexpected one. 

669 """ 

670 if self.__allSlots__ != (slots := set(state.keys())): 

671 if len(diff := self.__allSlots__.difference(slots)) > 0: 

672 raise ExtendedTypeError(f"""Missing fields in parameter 'state': '{"', '".join(diff)}'""") # WORKAROUND: Python <3.12 

673 else: 

674 diff = slots.difference(self.__allSlots__) 

675 raise ExtendedTypeError(f"""Unexpected fields in parameter 'state': '{"', '".join(diff)}'""") # WORKAROUND: Python <3.12 

676 

677 for slotName, value in state.items(): 

678 setattr(self, slotName, value) 

679 

680 newClass.__setstate__ = __setstate__ 

681 

682 # Check for inherited class attributes 

683 attributes: list[Attribute] = [] 

684 setattr(newClass, ATTRIBUTES_MEMBER_NAME, attributes) 

685 for base in baseClasses: 

686 if hasattr(base, ATTRIBUTES_MEMBER_NAME): 

687 pyAttr = getattr(base, ATTRIBUTES_MEMBER_NAME) 

688 for att in pyAttr: 

689 if AttributeScope.Class in att.Scope: 689 ↛ 688line 689 didn't jump to line 688 because the condition on line 689 was always true

690 attributes.append(att) 

691 att.__class__._classes.append(newClass) 

692 

693 # Check methods for attributes 

694 methods, methodsWithAttributes = self._findMethods(newClass, baseClasses, members) 

695 

696 # Add new fields for found methods 

697 newClass.__methods__ = tuple(methods) 

698 newClass.__methodsWithAttributes__ = tuple(methodsWithAttributes) 

699 

700 # Additional methods on a class 

701 def GetMethodsWithAttributes( 

702 self, 

703 predicate: Nullable[TAttributeFilter[TAttr]] = None 

704 ) -> dict[Callable[..., Any], tuple[Attribute, ...]]: 

705 """ 

706 Return the class' methods that carry at least one matching attribute. 

707 

708 :param predicate: Optional, an attribute class, an iterable of attribute classes, or ``None`` to accept every 

709 attribute. 

710 :returns: Dictionary of methods and the matching attributes attached to them. 

711 :raises ValueError: If an element of parameter 'predicate' is not a sub-class of :class:`~pyTooling.Attributes.Attribute`. 

712 :raises ValueError: If parameter 'predicate' is neither an attribute class nor an iterable of those. 

713 """ 

714 from pyTooling.Attributes import Attribute 

715 

716 if predicate is None: 

717 predicate = Attribute 

718 elif isinstance(predicate, Iterable): 718 ↛ 719line 718 didn't jump to line 719 because the condition on line 718 was never true

719 for attribute in predicate: 

720 if not issubclass(attribute, Attribute): 

721 raise ValueError(f"Parameter 'predicate' contains an element which is not a sub-class of 'Attribute'.") 

722 

723 predicate = tuple(predicate) 

724 elif not issubclass(predicate, Attribute): 724 ↛ 725line 724 didn't jump to line 725 because the condition on line 724 was never true

725 raise ValueError(f"Parameter 'predicate' is not a sub-class of 'Attribute'.") 

726 

727 methodAttributePairs = {} 

728 for method in newClass.__methodsWithAttributes__: 

729 matchingAttributes: list[Attribute] = [] 

730 for attribute in method.__pyattr__: 

731 if isinstance(attribute, predicate): 

732 matchingAttributes.append(attribute) 

733 

734 if len(matchingAttributes) > 0: 

735 methodAttributePairs[method] = tuple(matchingAttributes) 

736 

737 return methodAttributePairs 

738 

739 newClass.GetMethodsWithAttributes = classmethod(GetMethodsWithAttributes) 

740 GetMethodsWithAttributes.__qualname__ = f"{className}.{GetMethodsWithAttributes.__name__}" 

741 

742 # GetMethods(predicate) -> dict[method, list[attribute]] / generator 

743 # GetClassAtrributes -> list[attributes] / generator 

744 # MethodHasAttributes(predicate) -> bool 

745 # GetAttribute 

746 

747 return newClass 

748 

749 @classmethod 

750 def _findMethods( 

751 self, 

752 newClass: ExtendedType, 

753 baseClasses: tuple[type], 

754 members: dict[str, Any] 

755 ) -> tuple[list[MethodType], list[MethodType]]: 

756 """ 

757 Find methods and methods with :mod:`pyTooling.Attributes`. 

758 

759 .. todo:: 

760 

761 Describe algorithm. 

762 

763 :param newClass: Newly created class instance. 

764 :param baseClasses: The tuple of :term:`base-classes <base-class>` the class is derived from. 

765 :param members: Members of the new class. 

766 :returns: A 2-tuple of all methods and those methods carrying at least one attribute. 

767 :raises TypeError: If a member is neither a method nor a class, so it can't be searched for methods. 

768 """ 

769 from pyTooling.Attributes import Attribute 

770 

771 # Embedded bind function due to circular dependencies. 

772 def bind(instance: object, func: FunctionType, methodName: Nullable[str] = None): 

773 """ 

774 Nested function binding a function to an object as a method. 

775 

776 It exists here rather than in :mod:`pyTooling.Common`, because importing that module would be circular. 

777 

778 :param instance: The object the function is bound to. 

779 :param func: The function to bind. 

780 :param methodName: Optional, name of the method; by default the function's own name. 

781 :returns: The bound method. 

782 """ 

783 if methodName is None: 783 ↛ 786line 783 didn't jump to line 786 because the condition on line 783 was always true

784 methodName = func.__name__ 

785 

786 boundMethod = func.__get__(instance, instance.__class__) 

787 setattr(instance, methodName, boundMethod) 

788 

789 return boundMethod 

790 

791 methods = [] 

792 methodsWithAttributes = [] 

793 attributeIndex = {} 

794 

795 for base in baseClasses: 

796 if hasattr(base, "__methodsWithAttributes__"): 

797 methodsWithAttributes.extend(base.__methodsWithAttributes__) 

798 

799 for memberName, member in members.items(): 

800 if isinstance(member, FunctionType): 

801 method = newClass.__dict__[memberName] 

802 if hasattr(method, "__classobj__") and getattr(method, "__classobj__") is not newClass: 802 ↛ 803line 802 didn't jump to line 803 because the condition on line 802 was never true

803 raise TypeError(f"Method '{memberName}' is used by multiple classes: {method.__classobj__} and {newClass}.") 

804 else: 

805 setattr(method, "__classobj__", newClass) 

806 

807 def GetAttributes(inst: Any, predicate: Nullable[type[Attribute]] = None) -> tuple[Attribute, ...]: 

808 """ 

809 Nested function attached to the class, returning the attributes of one of its methods. 

810 

811 :param inst: The method to read the attributes from. 

812 :param predicate: Optional, an attribute class, or ``None`` to accept every attribute. 

813 :returns: Tuple of the matching attributes. 

814 """ 

815 results = [] 

816 try: 

817 for attribute in inst.__pyattr__: # type: Attribute 

818 if isinstance(attribute, predicate): 

819 results.append(attribute) 

820 return tuple(results) 

821 except AttributeError: 

822 return tuple() 

823 

824 method.GetAttributes = bind(method, GetAttributes) 

825 methods.append(method) 

826 

827 # print(f" convert function: '{memberName}' to method") 

828 # print(f" {member}") 

829 if "__pyattr__" in member.__dict__: 

830 attributes = member.__pyattr__ # type: list[Attribute] 

831 if isinstance(attributes, list) and len(attributes) > 0: 831 ↛ 799line 831 didn't jump to line 799 because the condition on line 831 was always true

832 methodsWithAttributes.append(member) 

833 for attribute in attributes: 

834 attribute._functions.remove(method) 

835 attribute._methods.append(method) 

836 

837 # print(f" attributes: {attribute.__class__.__name__}") 

838 if attribute not in attributeIndex: 838 ↛ 841line 838 didn't jump to line 841 because the condition on line 838 was always true

839 attributeIndex[attribute] = [member] 

840 else: 

841 attributeIndex[attribute].append(member) 

842 # else: 

843 # print(f" But has no attributes.") 

844 # else: 

845 # print(f" ?? {memberName}") 

846 return methods, methodsWithAttributes 

847 

848 @classmethod 

849 def _getAnnotations(metacls, members: dict[str, Any]) -> dict[str, Any]: 

850 """ 

851 Return the type annotations declared in a class body. 

852 

853 .. important:: 

854 

855 Python 3.14 (:pep:`649`) no longer fills ``__annotations__`` while the class body is executed, but installs an 

856 ``__annotate_func__`` instead. The :mod:`annotationlib` module needed to evaluate that function doesn't exist on 

857 older Python versions, therefore both mechanisms are supported. 

858 

859 :param members: Dictionary of class members. 

860 :returns: Dictionary of annotated field names and their type annotations. Empty, if the class body declared 

861 no annotations. 

862 """ 

863 if "__annotations__" in members: 

864 # WORKAROUND: LEGACY SUPPORT Python <= 3.13 

865 # Accessing annotations was changed in Python 3.14. 

866 # The necessary 'annotationlib' is not available for older Python versions. 

867 annotations = members["__annotations__"] 

868 elif version_info >= (3, 14) and (annotate := members.get("__annotate_func__", None)) is not None: 

869 from annotationlib import Format 

870 try: 

871 annotations = annotate(Format.VALUE) 

872 except NameError: 

873 # A forward reference the class body cannot resolve yet - PEP 649 offers the source text instead. 

874 annotations = annotate(Format.STRING) 

875 else: 

876 return {} 

877 

878 # 'from __future__ import annotations' (PEP 563) makes every annotation a string, and so does the fallback 

879 # above. Resolve what can be resolved, so a 'ClassVar' is still recognized as one. 

880 return { 

881 name: metacls._resolveAnnotation(typeAnnotation, members) 

882 for name, typeAnnotation in annotations.items() 

883 } 

884 

885 #: Matches a textual annotation denoting a :class:`~typing.ClassVar`, with or without a module qualifier. 

886 _CLASS_VARIABLE_PATTERN = re_compile(r"^\s*(?:\w+\.)*ClassVar\s*(?:\[|$)") 

887 

888 @classmethod 

889 def _resolveAnnotation(metacls, typeAnnotation: Any, members: dict[str, Any]) -> Any: 

890 """ 

891 Evaluate a postponed (string) annotation, so it can be inspected like an ordinary one. 

892 

893 :pep:`563` - ``from __future__ import annotations`` - turns **every** annotation in a module into a string, and 

894 :pep:`649` does the same for an annotation :mod:`annotationlib` cannot evaluate yet. A string tells this 

895 meta-class nothing: a ``ClassVar`` reads as ``"ClassVar[int]"`` and would silently become a slot. 

896 

897 The annotation is evaluated in the defining module's namespace plus the class body itself. A name that cannot be 

898 resolved - typically a forward reference to the class being created right now - is **returned unchanged**, which 

899 is harmless: the textual fallback in :meth:`_isClassVariable` still classifies it, and nothing else needs the 

900 type object. 

901 

902 :param typeAnnotation: The annotation to resolve; returned unchanged when it is not a string. 

903 :param members: Dictionary of class members, used as the local namespace. 

904 :returns: The evaluated annotation, or the original string when it cannot be evaluated. 

905 """ 

906 if not isinstance(typeAnnotation, str): 

907 return typeAnnotation 

908 

909 module = modules.get(members.get("__module__", ""), None) 

910 try: 

911 # The annotation is source code written in the class being created - the same trust level as importing it. 

912 return eval(typeAnnotation, getattr(module, "__dict__", {}), members) 

913 except Exception: # noqa: BLE001 - any failure means "keep the string", see above 

914 return typeAnnotation 

915 

916 @classmethod 

917 def _isClassVariable(metacls, typeAnnotation: Any) -> bool: 

918 """ 

919 Check if a type annotation declares a class variable. 

920 

921 Both forms are recognized: the evaluated :class:`~typing.ClassVar` and its textual form, which is what 

922 ``from __future__ import annotations`` leaves behind when the annotation cannot be evaluated. 

923 

924 :param typeAnnotation: The type annotation to check. 

925 :returns: ``True``, if the annotation is a :class:`~typing.ClassVar`. 

926 """ 

927 if isinstance(typeAnnotation, str): 

928 return metacls._CLASS_VARIABLE_PATTERN.match(typeAnnotation) is not None 

929 

930 return isinstance(typeAnnotation, _GenericAlias) and typeAnnotation.__origin__ is ClassVar 

931 

932 @classmethod 

933 def _isField(metacls, member: Any) -> bool: 

934 """ 

935 Check if a class member is a field, so a type annotation is expected for it. 

936 

937 Methods, nested classes, properties and any other descriptor carry their type information in their signature or 

938 in their own declaration, therefore they aren't fields. 

939 

940 :param member: The class member to check. 

941 :returns: ``True``, if the member is a field. 

942 """ 

943 if isinstance(member, (FunctionType, MethodType, BuiltinFunctionType, classmethod, staticmethod, property, type)): 

944 return False 

945 

946 # Any descriptor (e.g. a custom property implementation) declares its own type information. 

947 return not (hasattr(member, "__get__") or hasattr(member, "__set__")) 

948 

949 @classmethod 

950 def _checkForUnannotatedFields(metacls, className: str, members: dict[str, Any], annotations: dict[str, Any]) -> None: 

951 """ 

952 Report fields that were assigned in the class body without a type annotation. 

953 

954 Every field should carry type information: an object field is annotated with its type, a class variable with 

955 ``ClassVar[...]``. Without an annotation, :class:`ExtendedType` can't tell the two apart - an un-annotated 

956 assignment never becomes a slot and silently stays a class attribute. 

957 

958 .. important:: 

959 

960 This is reported as a :class:`~pyTooling.Warning.Warning`, so it needs a 

961 :class:`~pyTooling.Warning.WarningCollector` somewhere up the call-hierarchy to be observed. Importing a module 

962 with un-annotated fields doesn't fail. 

963 

964 :param className: The name of the class to construct. 

965 :param members: Dictionary of class members. 

966 :param annotations: Dictionary of annotated field names and their type annotations. 

967 """ 

968 unannotatedFields = [ 

969 fieldName 

970 for fieldName, member in members.items() 

971 if not (fieldName.startswith("__") and fieldName.endswith("__")) 

972 and fieldName not in annotations 

973 and metacls._isField(member) 

974 ] 

975 

976 if len(unannotatedFields) > 0: 

977 fieldNames = "', '".join(unannotatedFields) 

978 WarningCollector.Raise( 

979 UnannotatedFieldWarning(f"Class '{className}' declares {len(unannotatedFields)} field(s) without a type annotation."), 

980 notes=( 

981 f"Field(s) without a type annotation: '{fieldNames}'.", 

982 f"Annotate a class variable as 'ClassVar[...]' or an object field with its type.", 

983 ) 

984 ) 

985 

986 @classmethod 

987 def _computeSlots( 

988 self, 

989 className: str, 

990 baseClasses: tuple[type], 

991 members: dict[str, Any], 

992 slots: bool, 

993 mixin: bool 

994 ) -> tuple[dict[str, Any], dict[str, Any]]: 

995 """ 

996 Compute which field are listed in __slots__ and which need to be initialized in an instance or class. 

997 

998 .. todo:: 

999 

1000 Describe algorithm. 

1001 

1002 :param className: The name of the class to construct. 

1003 :param baseClasses: Tuple of base-classes. 

1004 :param members: Dictionary of class members. 

1005 :param slots: Optional, ``True``, if the class should setup ``__slots__``. 

1006 :param mixin: Optional, ``True``, if the class should behave as a mixin-class. 

1007 :returns: A 2-tuple with a dictionary of class members and object members. 

1008 :raises AttributeError: If a field's annotation refers to a name that can't be resolved. |br| 

1009 An assignment without a type annotation creates a class attribute, which 

1010 hides the slot's descriptor: reading the field works, but assigning it on an 

1011 instance raises. Annotate it as ``ClassVar[...]`` to declare a class 

1012 variable, or remove the assignment. A slot contributed by a mixin-class is 

1013 materialized in this class' ``__slots__``, and Python doesn't allow such a 

1014 name to be assigned in the class body. 

1015 :raises BaseClassWithoutSlotsError: If a base-class doesn't use slots. |br| 

1016 All base-classes of a class using ``__slots__`` must use ``__slots__`` 

1017 themselves. 

1018 """ 

1019 # Compute which field are listed in __slots__ and which need to be initialized in an instance or class. 

1020 slottedFields = [] 

1021 classFields = {} 

1022 objectFields = {} 

1023 annotations: dict[str, Any] = self._getAnnotations(members) 

1024 if slots or mixin: 

1025 # If slots are used, all base classes must use __slots__. 

1026 for baseClass in self._iterateBaseClasses(baseClasses): 

1027 # Exclude object as a special case 

1028 if baseClass is object or baseClass is Generic: 

1029 continue 

1030 

1031 if not hasattr(baseClass, "__slots__"): 

1032 ex = BaseClassWithoutSlotsError(f"Base-classes '{baseClass.__name__}' doesn't use '__slots__'.") 

1033 ex.add_note(f"All base-classes of a class using '__slots__' must use '__slots__' itself.") 

1034 raise ex 

1035 

1036 # Non-empty __slots__ on secondary base-classes are rejected by _aggregateMixinSlots below. 

1037 

1038 # Copy all field names from primary base-class' __slots__, which are later needed for error checking. 

1039 inheritedSlottedFields = {} 

1040 if len(baseClasses) > 0: 

1041 for base in reversed(baseClasses[0].mro()): 

1042 # Exclude object as a special case 

1043 if base is object or base is Generic: 

1044 continue 

1045 

1046 for annotation in base.__slots__: 

1047 inheritedSlottedFields[annotation] = base 

1048 

1049 # When adding annotated fields to slottedFields, check if name was not used in inheritance hierarchy. 

1050 for fieldName, typeAnnotation in annotations.items(): 

1051 if fieldName in inheritedSlottedFields: 1051 ↛ 1052line 1051 didn't jump to line 1052 because the condition on line 1051 was never true

1052 cls = inheritedSlottedFields[fieldName] 

1053 raise AttributeError(f"Slot '{fieldName}' declared in class '{className}' already exists in base-class '{cls.__module__}.{cls.__name__}'.") 

1054 

1055 # A ClassVar is never a slot, with or without an initial value. 

1056 # * If it has an initial value, copy field and initial value to classFields dictionary and remove field from members. 

1057 # * Otherwise it's a forward declaration and derived classes assign the actual value. 

1058 isClassVariable = self._isClassVariable(typeAnnotation) 

1059 hasInitialValue = fieldName in members 

1060 if isClassVariable: 

1061 if hasInitialValue: 

1062 classFields[fieldName] = members[fieldName] 

1063 del members[fieldName] 

1064 

1065 # If an annotated field has an initial value 

1066 # * copy field and initial value to objectFields dictionary 

1067 # * remove field from members 

1068 elif hasInitialValue: 

1069 slottedFields.append(fieldName) 

1070 objectFields[fieldName] = members[fieldName] 

1071 del members[fieldName] 

1072 else: 

1073 slottedFields.append(fieldName) 

1074 

1075 mixinSlots = self._aggregateMixinSlots(className, baseClasses) 

1076 

1077 # A member assigned in the class body without a type annotation stays a class attribute. If it carries the name 

1078 # of a slot, that class attribute shadows the slot's descriptor and the field becomes read-only on instances. 

1079 # Report it here instead of letting the first assignment fail with a bare AttributeError. 

1080 shadowedSlots = {**inheritedSlottedFields, **{fieldName: None for fieldName in mixinSlots}} 

1081 for fieldName in shadowedSlots.keys() & members.keys(): 

1082 ex = DuplicateFieldInSlotsError(f"Slot '{fieldName}' is shadowed by a class member in class '{className}'.") 

1083 if (baseClass := shadowedSlots[fieldName]) is not None: 

1084 ex.add_note(f"Slot '{fieldName}' is declared in base-class '{baseClass.__module__}.{baseClass.__name__}'.") 

1085 ex.add_note(f"An assignment without a type annotation creates a class attribute, which hides the slot's descriptor.") 

1086 ex.add_note(f"Reading the field works, but assigning it on an instance raises an AttributeError.") 

1087 else: 

1088 ex.add_note(f"Slot '{fieldName}' is contributed by a mixin-class and materialized in this class' '__slots__'.") 

1089 ex.add_note(f"Python doesn't allow a name to be listed in '__slots__' and assigned in the class body.") 

1090 ex.add_note(f"Annotate it as 'ClassVar[...]' to declare a class variable, or remove the assignment.") 

1091 raise ex 

1092 else: 

1093 # When adding annotated fields to slottedFields, check if name was not used in inheritance hierarchy. 

1094 for fieldName, typeAnnotation in annotations.items(): 

1095 # If annotated field is a ClassVar, and it has an initial value 

1096 # * copy field and initial value to classFields dictionary 

1097 # * remove field from members 

1098 if self._isClassVariable(typeAnnotation) and fieldName in members: 

1099 classFields[fieldName] = members[fieldName] 

1100 del members[fieldName] 

1101 

1102 self._checkForUnannotatedFields(className, members, annotations) 

1103 

1104 if mixin: 

1105 mixinSlots.extend(slottedFields) 

1106 members["__slotted__"] = True 

1107 members["__slots__"] = tuple() 

1108 members["__allSlots__"] = set() 

1109 members["__isMixin__"] = True 

1110 members["__mixinSlots__"] = tuple(mixinSlots) 

1111 elif slots: 

1112 slottedFields.extend(mixinSlots) 

1113 members["__slotted__"] = True 

1114 members["__slots__"] = tuple(slottedFields) 

1115 members["__allSlots__"] = set(chain(slottedFields, inheritedSlottedFields.keys())) 

1116 members["__isMixin__"] = False 

1117 members["__mixinSlots__"] = tuple() 

1118 else: 

1119 members["__slotted__"] = False 

1120 # NO __slots__ 

1121 # members["__allSlots__"] = set() 

1122 members["__isMixin__"] = False 

1123 members["__mixinSlots__"] = tuple() 

1124 return classFields, objectFields 

1125 

1126 @classmethod 

1127 def _aggregateMixinSlots(self, className: str, baseClasses: tuple[type]) -> list[str]: 

1128 """ 

1129 Aggregate slot names requested by mixin-base-classes. 

1130 

1131 .. todo:: 

1132 

1133 Describe algorithm. 

1134 

1135 :param className: The name of the class to construct. 

1136 :param baseClasses: The tuple of :term:`base-classes <base-class>` the class is derived from. 

1137 :returns: A list of slot names. 

1138 :raises BaseClassWithNonEmptySlotsError: If a mixin-class uses non-empty slots. |br| 

1139 In Python, only one inheritance branch can use non-empty ``__slots__``. 

1140 """ 

1141 mixinSlots = [] 

1142 if len(baseClasses) > 0: 

1143 # If class has base-classes ensure only the primary inheritance path uses slots and all secondary inheritance 

1144 # paths have an empty slots tuple. Otherwise, raise a BaseClassWithNonEmptySlotsError. 

1145 inheritancePaths = [path for path in self._iterateBaseClassPaths(baseClasses)] 

1146 primaryInharitancePath: set[type] = set(inheritancePaths[0]) 

1147 for typePath in inheritancePaths[1:]: 

1148 for t in typePath: 

1149 if hasattr(t, "__slots__") and len(t.__slots__) != 0 and t not in primaryInharitancePath: 

1150 ex = BaseClassWithNonEmptySlotsError(f"Base-class '{t.__name__}' has non-empty __slots__ and can't be used as a direct or indirect base-class for '{className}'.") 

1151 ex.add_note(f"In Python, only one inheritance branch can use non-empty __slots__.") 

1152 # ex.add_note(f"With ExtendedType, only the primary base-class can use non-empty __slots__.") 

1153 # ex.add_note(f"Secondary base-classes should be marked as mixin-classes.") 

1154 raise ex 

1155 

1156 # If current class is set to be a mixin, then aggregate all mixinSlots in a list. 

1157 # Ensure all base-classes are either constructed 

1158 # * by meta-class ExtendedType, or 

1159 # * use no slots, or 

1160 # * are typing.Generic 

1161 # If it was constructed by ExtendedType, then ensure this class itself is a mixin-class. 

1162 for baseClass in baseClasses: # type: ExtendedType 

1163 if isinstance(baseClass, _GenericAlias) and baseClass.__origin__ is Generic: 1163 ↛ 1164line 1163 didn't jump to line 1164 because the condition on line 1163 was never true

1164 pass 

1165 elif baseClass.__class__ is self and baseClass.__isMixin__: 

1166 mixinSlots.extend(baseClass.__mixinSlots__) 

1167 elif hasattr(baseClass, "__mixinSlots__"): 

1168 mixinSlots.extend(baseClass.__mixinSlots__) 

1169 

1170 return mixinSlots 

1171 

1172 @classmethod 

1173 def _iterateBaseClasses(metacls, baseClasses: tuple[type]) -> Generator[type, None, None]: 

1174 """ 

1175 Return a generator to iterate (visit) all base-classes ... 

1176 

1177 .. todo:: 

1178 

1179 Describe iteration order. 

1180 

1181 :param baseClasses: The tuple of :term:`base-classes <base-class>` the class is derived from. 

1182 :returns: Generator to iterate all base-classes. 

1183 """ 

1184 if len(baseClasses) == 0: 

1185 return 

1186 

1187 visited: set[type] = set() 

1188 iteratorStack: list[Iterator[type]] = list() 

1189 

1190 for baseClass in baseClasses: 

1191 yield baseClass 

1192 visited.add(baseClass) 

1193 iteratorStack.append(iter(baseClass.__bases__)) 

1194 

1195 while True: 

1196 try: 

1197 base = next(iteratorStack[-1]) # type: type 

1198 if base not in visited: 1198 ↛ 1203line 1198 didn't jump to line 1203 because the condition on line 1198 was always true

1199 yield base 

1200 if len(base.__bases__) > 0: 

1201 iteratorStack.append(iter(base.__bases__)) 

1202 else: 

1203 continue 

1204 

1205 except StopIteration: 

1206 iteratorStack.pop() 

1207 

1208 if len(iteratorStack) == 0: 

1209 break 

1210 

1211 @classmethod 

1212 def _iterateBaseClassPaths(metacls, baseClasses: tuple[type]) -> Generator[tuple[type, ...], None, None]: 

1213 """ 

1214 Return a generator to iterate all possible inheritance paths for a given list of base-classes. 

1215 

1216 An inheritance path is expressed as a tuple of base-classes from current base-class (left-most item) to 

1217 :class:`object` (right-most item). 

1218 

1219 :param baseClasses: The tuple of :term:`base-classes <base-class>` the class is derived from. 

1220 :returns: Generator to iterate all inheritance paths. |br| 

1221 An inheritance path is a tuple of types (base-classes). 

1222 """ 

1223 if len(baseClasses) == 0: 1223 ↛ 1224line 1223 didn't jump to line 1224 because the condition on line 1223 was never true

1224 return 

1225 

1226 typeStack: list[type] = list() 

1227 iteratorStack: list[Iterator[type]] = list() 

1228 

1229 for baseClass in baseClasses: 

1230 typeStack.append(baseClass) 

1231 iteratorStack.append(iter(baseClass.__bases__)) 

1232 

1233 while True: 

1234 try: 

1235 base = next(iteratorStack[-1]) # type: type 

1236 typeStack.append(base) 

1237 if len(base.__bases__) == 0: 

1238 yield tuple(typeStack) 

1239 typeStack.pop() 

1240 else: 

1241 iteratorStack.append(iter(base.__bases__)) 

1242 

1243 except StopIteration: 

1244 typeStack.pop() 

1245 iteratorStack.pop() 

1246 

1247 if len(typeStack) == 0: 

1248 break 

1249 

1250 @classmethod 

1251 def _checkForAbstractMethods( 

1252 metacls, 

1253 baseClasses: tuple[type], 

1254 members: dict[str, Any] 

1255 ) -> tuple[dict[str, Callable[..., Any]], dict[str, Any]]: 

1256 """ 

1257 Check if the current class contains abstract methods and return a tuple of them. 

1258 

1259 These abstract methods might be inherited from any base-class. If there are inherited abstract methods, check if 

1260 they are now implemented (overridden) by the current class that's right now constructed. 

1261 

1262 :param baseClasses: The tuple of :term:`base-classes <base-class>` the class is derived from. 

1263 :param members: The dictionary of members for the constructed class. 

1264 :returns: A tuple of abstract method's names. 

1265 """ 

1266 abstractMethods = {} 

1267 if baseClasses: 

1268 # Aggregate all abstract methods from all base-classes. 

1269 for baseClass in baseClasses: 

1270 if hasattr(baseClass, "__abstractMethods__"): 

1271 abstractMethods.update(baseClass.__abstractMethods__) 

1272 

1273 for base in baseClasses: 

1274 for memberName, member in base.__dict__.items(): 

1275 # A method the new class defines itself is the implementation; it must not be replaced by the one an 

1276 # inheritance branch happens to carry (pyTooling #297). 

1277 if memberName in members: 

1278 continue 

1279 

1280 if (memberName in abstractMethods and isinstance(member, FunctionType) and 

1281 not (hasattr(member, "__abstract__") or hasattr(member, "__mustOverride__"))): 

1282 def outer(method): 

1283 """ 

1284 Nested function creating a wrapper, so the abstract method of the base-class isn't modified itself. 

1285 

1286 :param method: The inherited abstract method. 

1287 :returns: A wrapper forwarding to that method. 

1288 """ 

1289 @wraps(method) 

1290 def inner(cls, *args: Any, **kwargs: Any): 

1291 """ 

1292 Wrapper forwarding to the inherited abstract method. 

1293 

1294 :param cls: The class the method is called on. 

1295 :param args: Positional parameters passed to the method. 

1296 :param kwargs: Named parameters passed to the method. 

1297 :returns: Whatever the wrapped method returns. 

1298 """ 

1299 return method(cls, *args, **kwargs) 

1300 

1301 # ':func:`~functools.wraps` copies the wrapped function's '__dict__', which carries the 

1302 # bookkeeping ExtendedType attached to it. The wrapper belongs to the class being 

1303 # constructed, so the owner is dropped and '_findMethods' assigns the new one. 

1304 inner.__dict__.pop("__classobj__", None) 

1305 

1306 return inner 

1307 

1308 members[memberName] = outer(member) 

1309 

1310 # Check if methods are marked: 

1311 # * If so, add them to list of abstract methods 

1312 # * If not, method is now implemented and removed from list 

1313 for memberName, member in members.items(): 

1314 if callable(member): 

1315 if ((hasattr(member, "__abstract__") and member.__abstract__) or 

1316 (hasattr(member, "__mustOverride__") and member.__mustOverride__)): 

1317 abstractMethods[memberName] = member 

1318 elif memberName in abstractMethods: 

1319 del abstractMethods[memberName] 

1320 

1321 return abstractMethods, members 

1322 

1323 @classmethod 

1324 def _wrapNewMethodIfSingleton(metacls, newClass, singleton: bool) -> bool: 

1325 """ 

1326 If a class is a singleton, wrap the ``_new__`` method, so it returns a cached object, if a first object was created. 

1327 

1328 Only the first object creation initializes the object. 

1329 

1330 This implementation is threadsafe. 

1331 

1332 :param newClass: The newly constructed class for further modifications. 

1333 :param singleton: Optional, if ``True``, the class allows only a single instance to exist. 

1334 :returns: ``True``, if the class is a singleton. 

1335 """ 

1336 if hasattr(newClass, "__isSingleton__"): 

1337 singleton = newClass.__isSingleton__ 

1338 

1339 if singleton: 

1340 oldnew = newClass.__new__ 

1341 if hasattr(oldnew, "__singleton_wrapper__"): 

1342 oldnew = oldnew.__wrapped__ 

1343 

1344 oldinit = newClass.__init__ 

1345 if hasattr(oldinit, "__singleton_wrapper__"): 

1346 oldinit = oldinit.__wrapped__ 

1347 

1348 @wraps(oldnew) 

1349 def singleton_new(cls, *args: Any, **kwargs: Any): 

1350 """ 

1351 Replacement ``__new__`` method, which returns the singleton's one instance. 

1352 

1353 The first call creates the object and caches it; every further call returns the cached object. The 

1354 condition variable makes that safe when several threads instantiate the class at once. 

1355 

1356 :param cls: The class being instantiated. 

1357 :param args: Positional parameters passed to the original ``__new__``. 

1358 :param kwargs: Named parameters passed to the original ``__new__``. 

1359 :returns: The singleton's instance. 

1360 """ 

1361 with cls.__singletonInstanceCond__: 

1362 if cls.__singletonInstanceCache__ is None: 

1363 obj = oldnew(cls, *args, **kwargs) 

1364 cls.__singletonInstanceCache__ = obj 

1365 else: 

1366 obj = cls.__singletonInstanceCache__ 

1367 

1368 return obj 

1369 

1370 @wraps(oldinit) 

1371 def singleton_init(self, *args: Any, **kwargs: Any): 

1372 """ 

1373 Replacement ``__init__`` method, which initializes the singleton's instance exactly once. 

1374 

1375 A further instantiation waits until the first one finished initializing, so it never sees a half-built object. 

1376 

1377 :param args: Positional parameters passed to the original ``__init__``. 

1378 :param kwargs: Named parameters passed to the original ``__init__``. 

1379 :raises ValueError: If a further instantiation passes parameters, which would be silently ignored. 

1380 """ 

1381 cls = self.__class__ 

1382 cv = cls.__singletonInstanceCond__ 

1383 with cv: 

1384 if cls.__singletonInstanceInit__: 

1385 oldinit(self, *args, **kwargs) 

1386 cls.__singletonInstanceInit__ = False 

1387 cv.notify_all() 

1388 elif args or kwargs: 

1389 raise ValueError(f"A further instance of a singleton can't be reinitialized with parameters.") 

1390 else: 

1391 while cls.__singletonInstanceInit__: 1391 ↛ 1392line 1391 didn't jump to line 1392 because the condition on line 1391 was never true

1392 cv.wait() 

1393 

1394 singleton_new.__singleton_wrapper__ = True 

1395 singleton_init.__singleton_wrapper__ = True 

1396 

1397 newClass.__new__ = singleton_new 

1398 newClass.__init__ = singleton_init 

1399 newClass.__singletonInstanceCond__ = Condition() 

1400 newClass.__singletonInstanceInit__ = True 

1401 newClass.__singletonInstanceCache__ = None 

1402 return True 

1403 

1404 return False 

1405 

1406 @classmethod 

1407 def _wrapNewMethodIfAbstract(metacls, newClass) -> bool: 

1408 """ 

1409 If the class is marked ``__abstractClass__`` or has abstract methods, replace the ``_new__`` method, so it 

1410 raises an exception. 

1411 

1412 :param newClass: The newly constructed class for further modifications. 

1413 :returns: ``True``, if the class is abstract. 

1414 :raises AbstractClassError: If the class is abstract and can't be instantiated. 

1415 :raises ExtendedTypeError: If a singleton wrapper was found around a method raising 

1416 :exc:`AbstractClassError`, which is not handled yet. 

1417 """ 

1418 # Replace '__new__' by a variant to throw an error on not overridden methods 

1419 if newClass.__abstractClass__ or len(newClass.__abstractMethods__) > 0: 

1420 oldnew = newClass.__new__ 

1421 if hasattr(oldnew, "__raises_abstract_class_error__"): 

1422 oldnew = oldnew.__wrapped__ 

1423 

1424 @wraps(oldnew) 

1425 def abstract_new(cls, *_, **__): 

1426 """ 

1427 Replacement ``__new__`` method, which rejects the instantiation of an abstract class. 

1428 

1429 The message names the methods to override, or says that the class needs to be derived when it was declared 

1430 abstract without having abstract methods. 

1431 

1432 :raises AbstractClassError: Always, because an abstract class can't be instantiated. 

1433 """ 

1434 if len(newClass.__abstractMethods__) > 0: 

1435 raise AbstractClassError(f"""Class '{cls.__name__}' is abstract. The following methods: '{"', '".join(newClass.__abstractMethods__)}' need to be overridden in a derived class.""") 

1436 else: 

1437 raise AbstractClassError(f"Class '{cls.__name__}' is abstract and needs to be derived.") 

1438 

1439 abstract_new.__raises_abstract_class_error__ = True 

1440 

1441 newClass.__new__ = abstract_new 

1442 return True 

1443 

1444 # Handle classes which are not abstract, especially derived classes, if not abstract anymore 

1445 else: 

1446 # skip intermediate 'new' function if class isn't abstract anymore 

1447 try: 

1448 if newClass.__new__.__raises_abstract_class_error__: 1448 ↛ 1470line 1448 didn't jump to line 1470 because the condition on line 1448 was always true

1449 origNew = newClass.__new__.__wrapped__ 

1450 

1451 # WORKAROUND: __new__ checks tp_new and implements different behavior 

1452 # Bugreport: https://github.com/python/cpython/issues/105888 

1453 if origNew is object.__new__: 

1454 @wraps(object.__new__) 

1455 def wrapped_new(inst, *_, **__): 

1456 """ 

1457 Replacement ``__new__`` method for a class that isn't abstract anymore. 

1458 

1459 It calls :meth:`object.__new__` with the class only, because that implementation rejects further 

1460 parameters. 

1461 

1462 :param inst: The class being instantiated. 

1463 :returns: The new instance. 

1464 """ 

1465 return object.__new__(inst) 

1466 

1467 newClass.__new__ = wrapped_new 

1468 else: 

1469 newClass.__new__ = origNew 

1470 elif newClass.__new__.__isSingleton__: 

1471 raise ExtendedTypeError( 

1472 "Found a singleton wrapper around an AbstractError raising method. This case is not handled yet." 

1473 ) 

1474 except AttributeError as ex: 

1475 if ex.name != "__raises_abstract_class_error__": 1475 ↛ 1476line 1475 didn't jump to line 1476 because the condition on line 1475 was never true

1476 raise ex 

1477 

1478 return False 

1479 

1480 # Additional properties and methods on a class 

1481 @readonly 

1482 def HasClassAttributes(self) -> bool: 

1483 """ 

1484 Read-only property to check if the class has Attributes (:attr:`__pyattr__`). 

1485 

1486 :returns: ``True``, if the class has Attributes. 

1487 """ 

1488 try: 

1489 return len(self.__pyattr__) > 0 

1490 except AttributeError: 

1491 return False 

1492 

1493 @readonly 

1494 def HasMethodAttributes(self) -> bool: 

1495 """ 

1496 Read-only property to check if the class has methods with Attributes (:attr:`__methodsWithAttributes__`). 

1497 

1498 :returns: ``True``, if the class has any method with Attributes. 

1499 """ 

1500 try: 

1501 return len(self.__methodsWithAttributes__) > 0 

1502 except AttributeError: 

1503 return False 

1504 

1505 

1506@export 

1507class SlottedObject(metaclass=ExtendedType, slots=True): 

1508 """Classes derived from this class will store all members in ``__slots__``."""