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

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

57 

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 

63 

64 

65__all__ = ["M"] 

66 

67 

68@export 

69class ThisClass: 

70 """ 

71 Sentinel for a class variable whose value is the class declaring it. 

72 

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. 

75 

76 .. code-block:: python 

77 

78 class Workflow(metaclass=ExtendedType, slots=True): 

79 _PARENT_TYPE: ClassVar[Nullable[type]] = ThisClass # a workflow is contained in a workflow 

80 

81 assert Workflow._PARENT_TYPE is Workflow 

82 

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. 

85 

86 .. note:: 

87 

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 """ 

94 

95 

96@export 

97class ExtendedTypeError(ToolingException): 

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

99 

100 

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. 

105 

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 """ 

109 

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. 

112 

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. 

116 

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 

124 

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

130 

131 :returns: The base-class, or ``None`` if it wasn't recorded. 

132 """ 

133 return self._baseClass 

134 

135 @readonly 

136 def ClassName(self) -> Nullable[str]: 

137 """ 

138 Read-only property to access the name of the class under construction (:attr:`_className`). 

139 

140 :returns: The class' name, or ``None`` if it wasn't recorded. 

141 """ 

142 return self._className 

143 

144 

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__``. 

149 

150 .. seealso:: 

151 

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 """ 

157 

158 

159@export 

160class BaseClassWithNonEmptySlotsError(InvalidBaseClassError): 

161 """ 

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

163 

164 .. important:: 

165 

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 """ 

170 

171 

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. 

176 

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 """ 

180 

181 

182@export 

183class DuplicateFieldInSlotsError(ExtendedTypeError): 

184 """ 

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

186 """ 

187 

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. 

190 

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. 

194 

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 

202 

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

207 

208 :returns: The slot's name, or ``None`` if it wasn't recorded. 

209 """ 

210 return self._fieldName 

211 

212 @readonly 

213 def ClassName(self) -> Nullable[str]: 

214 """ 

215 Read-only property to access the name of the class under construction (:attr:`_className`). 

216 

217 :returns: The class' name, or ``None`` if it wasn't recorded. 

218 """ 

219 return self._className 

220 

221 

222@export 

223class UnannotatedFieldWarning(Warning): 

224 """ 

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

226 

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. 

229 

230 .. attention:: 

231 

232 It's a non-critical warning: without a :class:`~pyTooling.Warning.WarningCollector` up the call-hierarchy, it's 

233 dropped silently. 

234 """ 

235 

236 _className: Nullable[str] #: Name of the class under construction. 

237 _fieldNames: tuple[str, ...] #: Names of the fields without a type annotation. 

238 

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. 

248 

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) 

256 

257 @readonly 

258 def ClassName(self) -> Nullable[str]: 

259 """ 

260 Read-only property to access the name of the class under construction (:attr:`_className`). 

261 

262 The class isn't created yet when the warning is reported, so it carries the name. 

263 

264 :returns: The class' name, or ``None`` if it wasn't recorded. 

265 """ 

266 return self._className 

267 

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

272 

273 :returns: Tuple of field names; empty if they weren't recorded. 

274 """ 

275 return self._fieldNames 

276 

277 

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. 

282 

283 An expectation is declared in one of two places, and this exception is raised at the matching moment: 

284 

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. 

291 

292 Either way the alternative is an :exc:`AttributeError` on first access, somewhere else entirely, and only if that 

293 code path runs. 

294 

295 .. seealso:: 

296 

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 """ 

304 

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. 

307 

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. 

317 

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) 

325 

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

330 

331 :returns: The class, or ``None`` if it wasn't recorded. 

332 """ 

333 return self._incompleteClass 

334 

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

339 

340 :returns: Tuple of member names, or ``None`` if they weren't recorded. 

341 """ 

342 return self._missingMembers 

343 

344 

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 """ 

351 

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. 

354 

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. 

364 

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 

372 

373 @readonly 

374 def DecoratedClass(self) -> Nullable[type]: 

375 """ 

376 Read-only property to access the decorated class (:attr:`_decoratedClass`). 

377 

378 :returns: The decorated class, or ``None`` if it wasn't recorded. 

379 """ 

380 return self._decoratedClass 

381 

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

386 

387 :returns: The meta-class, or ``None`` if it wasn't recorded. 

388 """ 

389 return self._metaClass 

390 

391 

392@export 

393class AbstractClassError(ExtendedTypeError): 

394 """ 

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

396 

397 .. seealso:: 

398 

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 """ 

406 

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. 

409 

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. 

419 

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) 

428 

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

433 

434 :returns: The abstract class, or ``None`` if it wasn't recorded. 

435 """ 

436 return self._abstractClass 

437 

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

442 

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 

447 

448 

449@export 

450class MustOverrideClassError(AbstractClassError): 

451 """ 

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

453 

454 .. seealso:: 

455 

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 """ 

463 

464 

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# """ 

472 

473 

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

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

476 

477 

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. 

481 

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 

507 

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 

516 

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

518 

519 

520@export 

521def slotted(cls): 

522 """ 

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

524 

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

526 meta-class explicitly. 

527 

528 :param cls: The class to recreate. 

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

530 

531 .. seealso:: 

532 

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) 

539 

540 

541@export 

542def mixin(cls): 

543 """ 

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

545 

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

547 inheritance line. 

548 

549 :param cls: The class to recreate. 

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

551 

552 .. seealso:: 

553 

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) 

560 

561 

562@export 

563def singleton(cls): 

564 """ 

565 Class decorator recreating a class as a singleton. 

566 

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

568 

569 :param cls: The class to recreate. 

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

571 

572 .. seealso:: 

573 

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) 

580 

581 

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. 

586 

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

591 

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. 

594 

595 .. warning:: 

596 

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

598 

599 .. admonition:: ``example.py`` 

600 

601 .. code-block:: python 

602 

603 @abstractclass 

604 class Base(metaclass=ExtendedType): 

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

606 

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. 

611 

612 .. seealso:: 

613 

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 

627 

628 cls.__abstractClass__ = True 

629 

630 if not cls.__isAbstract__: 

631 cls.__isAbstract__ = ExtendedType._wrapNewMethodIfAbstract(cls) 

632 

633 return cls 

634 

635 

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

640 

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. 

643 

644 .. warning:: 

645 

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. 

649 

650 .. admonition:: ``example.py`` 

651 

652 .. code-block:: python 

653 

654 class Data(mataclass=ExtendedType): 

655 @abstractmethod 

656 def method(self) -> bool: 

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

658 

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

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

661 

662 .. seealso:: 

663 

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. 

677 

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

681 

682 func.__abstract__ = True 

683 return func 

684 

685 

686@export 

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

688 """ 

689 Mark a method as *must-override*. 

690 

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. 

694 

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

696 

697 .. warning:: 

698 

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. 

702 

703 .. admonition:: ``example.py`` 

704 

705 .. code-block:: python 

706 

707 class Data(mataclass=ExtendedType): 

708 @mustoverride 

709 def method(self): 

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

711 

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

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

714 

715 .. seealso:: 

716 

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 

728 

729 

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. 

734 

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. 

740 

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. 

748 

749 .. admonition:: ``example.py`` 

750 

751 .. code-block:: python 

752 

753 class Terminal(metaclass=ExtendedType, slots=True): 

754 @expects("MainParser", "SubParsers") 

755 def PrintHelp(self) -> None: 

756 self.MainParser.print_help() 

757 

758 Terminal().PrintHelp() # UnfulfilledExpectationError 

759 

760 class Application(Terminal, ArgParseHelperMixin): 

761 pass 

762 

763 Application().PrintHelp() # fine 

764 

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. 

768 

769 .. seealso:: 

770 

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 

783 

784 def decorator(method: M) -> M: 

785 """ 

786 Attach the expected member names to the decorated method. 

787 

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 

798 

799 method.__expectedMembers__ = memberNames 

800 return method 

801 

802 return decorator 

803 

804 

805# @export 

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

807# method.__overloadable__ = True 

808# return method 

809 

810 

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 

862 

863 

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) 

882 

883 

884@export 

885class ExtendedType(type): 

886 """ 

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

888 

889 .. rubric:: Features: 

890 

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

892 

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__``. 

897 

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. 

905 

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

907 

908 .. rubric:: Added class fields: 

909 

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. 

932 

933 .. rubric:: Added class properties: 

934 

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. 

937 

938 .. rubric:: Added methods: 

939 

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

941 

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. 

946 

947 .. rubric:: Modified ``__new__`` method: 

948 

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

950 

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

952 

953 .. rubric:: Modified ``__init__`` method: 

954 

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__``. 

957 

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

959 

960 .. rubric:: Modified abstract methods: 

961 

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

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

964 

965 .. seealso:: 

966 

967 :ref:`META/ExtendedType` 

968 |rarr| Every class keyword argument, its rules and the exceptions it raises. 

969 """ 

970 

971 # @classmethod 

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

973 # return DispatchDictionary() 

974 

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

989 

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. 

992 

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__ 

1019 

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) 

1022 

1023 # Compute abstract methods 

1024 abstractMethods, members = metacls._checkForAbstractMethods(baseClasses, members) 

1025 

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) 

1030 

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) 

1038 

1039 # Search in inheritance tree for abstract methods 

1040 newClass.__abstractMethods__ = abstractMethods 

1041 

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) 

1047 

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) 

1053 

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. 

1060 

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 

1070 

1071 newClass.__getstate__ = __getstate__ 

1072 

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. 

1077 

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 

1087 

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

1089 setattr(self, slotName, value) 

1090 

1091 newClass.__setstate__ = __setstate__ 

1092 

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) 

1103 

1104 # Check methods for attributes 

1105 methods, methodsWithAttributes = metacls._findMethods(newClass, baseClasses, members) 

1106 

1107 # Add new fields for found methods 

1108 newClass.__methods__ = tuple(methods) 

1109 newClass.__methodsWithAttributes__ = tuple(methodsWithAttributes) 

1110 

1111 # Reject calling a method that expects members this class doesn't provide 

1112 metacls._wrapMethodsWithUnfulfilledExpectations(newClass) 

1113 

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. 

1121 

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

1134 

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

1138 

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) 

1145 

1146 if len(matchingAttributes) > 0: 

1147 methodAttributePairs[method] = tuple(matchingAttributes) 

1148 

1149 return methodAttributePairs 

1150 

1151 newClass.GetMethodsWithAttributes = classmethod(GetMethodsWithAttributes) 

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

1153 

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

1155 # GetClassAtrributes -> list[attributes] / generator 

1156 # MethodHasAttributes(predicate) -> bool 

1157 # GetAttribute 

1158 

1159 return newClass 

1160 

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

1170 

1171 .. todo:: 

1172 

1173 Describe algorithm. 

1174 

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. 

1185 

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

1187 

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__ 

1195 

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

1197 setattr(instance, methodName, boundMethod) 

1198 

1199 return boundMethod 

1200 

1201 methods = [] 

1202 methodsWithAttributes = [] 

1203 attributeIndex = {} 

1204 

1205 for base in baseClasses: 

1206 if hasattr(base, "__methodsWithAttributes__"): 

1207 methodsWithAttributes.extend(base.__methodsWithAttributes__) 

1208 

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) 

1216 

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. 

1220 

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

1233 

1234 method.GetAttributes = bind(method, GetAttributes) 

1235 methods.append(method) 

1236 

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 

1254 

1255 @classmethod 

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

1257 """ 

1258 Return the type annotations declared in a class body. 

1259 

1260 .. important:: 

1261 

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. 

1265 

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 {} 

1284 

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 } 

1291 

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. 

1295 

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. 

1300 

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. 

1304 

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. 

1309 

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. 

1312 

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 

1319 

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 

1326 

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 

1331 

1332 @classmethod 

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

1334 """ 

1335 Check if a type annotation declares a class variable. 

1336 

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. 

1339 

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 

1345 

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

1347 

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. 

1352 

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. 

1355 

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 

1361 

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

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

1364 

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. 

1369 

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. 

1373 

1374 .. important:: 

1375 

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. 

1379 

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 ] 

1391 

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 ) 

1405 

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. 

1418 

1419 .. todo:: 

1420 

1421 Describe algorithm. 

1422 

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 

1467 

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 

1473 

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 

1485 

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 

1494 

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

1496 

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 

1504 

1505 for annotation in base.__slots__: 

1506 inheritedSlottedFields[annotation] = base 

1507 

1508 if weakref: 

1509 fields = annotations.copy() 

1510 fields["__weakref__"] = None 

1511 else: 

1512 fields = annotations 

1513 

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__}'.") 

1519 

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] 

1530 

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) 

1540 

1541 mixinSlots = metacls._aggregateMixinSlots(className, baseClasses) 

1542 

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 

1560 

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 

1572 

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

1590 

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

1596 

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] 

1606 

1607 metacls._checkForUnannotatedFields(className, members, annotations) 

1608 

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 

1630 

1631 @classmethod 

1632 def _aggregateMixinSlots(metacls, className: str, baseClasses: tuple[type]) -> dict[str, type]: 

1633 """ 

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

1635 

1636 .. todo:: 

1637 

1638 Describe algorithm. 

1639 

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 

1667 

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 

1683 

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 

1712 

1713 return mixinSlots 

1714 

1715 @staticmethod 

1716 def _findSlotDeclaration(baseClass: type, fieldName: str) -> type: 

1717 """ 

1718 Find the class in a base-class' hierarchy, which declared a slot. 

1719 

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. 

1723 

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

1732 

1733 if fieldName in slots or fieldName in cls.__dict__.get("__mixinSlots__", ()): 

1734 return cls 

1735 

1736 return baseClass 

1737 

1738 @classmethod 

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

1740 """ 

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

1742 

1743 .. todo:: 

1744 

1745 Describe iteration order. 

1746 

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 

1752 

1753 visited: set[type] = set() 

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

1755 

1756 for baseClass in baseClasses: 

1757 yield baseClass 

1758 visited.add(baseClass) 

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

1760 

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 

1770 

1771 except StopIteration: 

1772 iteratorStack.pop() 

1773 

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 

1776 

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. 

1781 

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

1784 

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 

1791 

1792 typeStack: list[type] = list() 

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

1794 

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 

1800 

1801 typeStack.append(baseClass) 

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

1803 

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

1813 

1814 except StopIteration: 

1815 typeStack.pop() 

1816 iteratorStack.pop() 

1817 

1818 if len(typeStack) == 0: 

1819 break 

1820 

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. 

1829 

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. 

1832 

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

1842 

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 

1851 

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. 

1858 

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. 

1866 

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) 

1872 

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) 

1877 

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

1887 

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] 

1899 

1900 return abstractMethods, members 

1901 

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. 

1906 

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. 

1909 

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 

1917 

1918 return member 

1919 

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. 

1924 

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__``. 

1929 

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. 

1934 

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. 

1937 

1938 The value is assigned, not copied: a mutable initial value is shared by all instances, as a class variable is. 

1939 

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. 

1943 

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 

1952 

1953 if mixin or len(allSlotDefaults) == 0: 

1954 return 

1955 

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

1964 

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__ 

1969 

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__``. 

1974 

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) 

1981 

1982 init(self, *args, **kwargs) 

1983 

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 

1987 

1988 newClass.__init__ = init_withDefaults 

1989 

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. 

1994 

1995 Only the first object creation initializes the object. 

1996 

1997 This implementation is threadsafe. 

1998 

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__ 

2005 

2006 if singleton: 

2007 oldnew = newClass.__new__ 

2008 if hasattr(oldnew, "__singleton_wrapper__"): 

2009 oldnew = oldnew.__wrapped__ 

2010 

2011 oldinit = newClass.__init__ 

2012 if hasattr(oldinit, "__singleton_wrapper__"): 

2013 oldinit = oldinit.__wrapped__ 

2014 

2015 @wraps(oldnew) 

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

2017 """ 

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

2019 

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. 

2022 

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__ 

2034 

2035 return obj 

2036 

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. 

2041 

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

2043 

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

2060 

2061 singleton_new.__singleton_wrapper__ = True 

2062 singleton_init.__singleton_wrapper__ = True 

2063 

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 

2070 

2071 return False 

2072 

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. 

2083 

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. 

2087 

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 

2111 

2112 # WORKAROUND: Python <3.13 

2113 # expects = tuple(sorted(get_protocol_members(expects))) 

2114 expects = tuple(sorted(_get_protocol_attrs(expects))) 

2115 

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 

2121 

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 

2128 

2129 expected: dict[str, str] = {} 

2130 for baseClass in baseClasses: 

2131 for memberName, origin in getattr(baseClass, "__expectedMembers__", {}).items(): 

2132 expected.setdefault(memberName, origin) 

2133 

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) 

2137 

2138 for memberName in expects: 

2139 expected.setdefault(memberName, className) 

2140 

2141 return expected 

2142 

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. 

2147 

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. 

2150 

2151 A mixin-class is never missing anything, because it can't provide what it expects from its host class. 

2152 

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

2159 

2160 return tuple(memberName for memberName in newClass.__expectedMembers__ if not hasattr(newClass, memberName)) 

2161 

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. 

2166 

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. 

2170 

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

2179 

2180 return False 

2181 

2182 oldnew = newClass.__new__ 

2183 if hasattr(oldnew, "__raises_unfulfilled_expectation_error__"): 

2184 oldnew = oldnew.__wrapped__ 

2185 

2186 @wraps(oldnew) 

2187 def unfulfilled_new(cls, *_, **__): 

2188 """ 

2189 Replacement ``__new__`` method, which rejects the instantiation of a class with unfulfilled expectations. 

2190 

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 

2202 

2203 unfulfilled_new.__raises_unfulfilled_expectation_error__ = True 

2204 

2205 newClass.__new__ = unfulfilled_new 

2206 return True 

2207 

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. 

2212 

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. 

2217 

2218 Only the method the class resolves counts: an override without the marker expects nothing, even if the method it 

2219 overrides does. 

2220 

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 

2233 

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 

2240 

2241 missing = tuple(name for name in original.__expectedMembers__ if not hasattr(newClass, name)) 

2242 isReplaced = hasattr(inherited, "__raises_unfulfilled_expectation_error__") 

2243 

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 

2249 

2250 wrapped.append(memberName) 

2251 if isReplaced and inherited.__missingMembers__ == missing: 

2252 # the inherited replacement already reports exactly these members 

2253 continue 

2254 

2255 def unfulfilledMethodFactory(methodName: str, missingMembers: tuple[str, ...]) -> Callable[..., NoReturn]: 

2256 """ 

2257 Create the replacement for one method, binding the names it is missing. 

2258 

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. 

2267 

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 

2275 

2276 return unfulfilledMethod 

2277 

2278 replacement = unfulfilledMethodFactory(memberName, missing) 

2279 

2280 replacement.__raises_unfulfilled_expectation_error__ = True 

2281 replacement.__missingMembers__ = missing 

2282 

2283 setattr(newClass, memberName, replacement) 

2284 

2285 return tuple(wrapped) 

2286 

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. 

2291 

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. 

2294 

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. 

2305 

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

2307 parameters. 

2308 

2309 :param inst: The class being instantiated. 

2310 :returns: The new instance. 

2311 """ 

2312 return object.__new__(inst) 

2313 

2314 newClass.__new__ = wrapped_new 

2315 else: 

2316 newClass.__new__ = originalNew 

2317 

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. 

2323 

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__ 

2335 

2336 @wraps(oldnew) 

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

2338 """ 

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

2340 

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. 

2343 

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 ) 

2361 

2362 abstract_new.__raises_abstract_class_error__ = True 

2363 

2364 newClass.__new__ = abstract_new 

2365 return True 

2366 

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 

2371 

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 

2385 

2386 return False 

2387 

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

2393 

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

2395 """ 

2396 try: 

2397 return len(self.__pyattr__) > 0 

2398 except AttributeError: 

2399 return False 

2400 

2401 @readonly 

2402 def HasMethodAttributes(self) -> bool: 

2403 """ 

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

2405 

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 

2412 

2413 

2414@export 

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

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