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

587 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-21 19:41 +0000

1# ==================================================================================================================== # 

2# _____ _ _ __ __ _ ____ _ # 

3# _ __ _ |_ _|__ ___ | (_)_ __ __ _ | \/ | ___| |_ __ _ / ___| | __ _ ___ ___ ___ ___ # 

4# | '_ \| | | || |/ _ \ / _ \| | | '_ \ / _` | | |\/| |/ _ \ __/ _` | | | |/ _` / __/ __|/ _ \/ __| # 

5# | |_) | |_| || | (_) | (_) | | | | | | (_| |_| | | | __/ || (_| | |___| | (_| \__ \__ \ __/\__ \ # 

6# | .__/ \__, ||_|\___/ \___/|_|_|_| |_|\__, (_)_| |_|\___|\__\__,_|\____|_|\__,_|___/___/\___||___/ # 

7# |_| |___/ |___/ # 

8# ==================================================================================================================== # 

9# Authors: # 

10# Patrick Lehmann # 

11# Sven Köhler # 

12# # 

13# License: # 

14# ==================================================================================================================== # 

15# Copyright 2017-2026 Patrick Lehmann - Bötzingen, Germany # 

16# # 

17# Licensed under the Apache License, Version 2.0 (the "License"); # 

18# you may not use this file except in compliance with the License. # 

19# You may obtain a copy of the License at # 

20# # 

21# http://www.apache.org/licenses/LICENSE-2.0 # 

22# # 

23# Unless required by applicable law or agreed to in writing, software # 

24# distributed under the License is distributed on an "AS IS" BASIS, # 

25# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # 

26# See the License for the specific language governing permissions and # 

27# limitations under the License. # 

28# # 

29# SPDX-License-Identifier: Apache-2.0 # 

30# ==================================================================================================================== # 

31# 

32""" 

33The MetaClasses package implements Python meta-classes (classes to construct other classes in Python). 

34 

35.. hint:: 

36 

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

38 

39.. seealso:: 

40 

41 :mod:`pyTooling.Decorators` 

42 |rarr| The decorator form of the same class options. 

43 :mod:`pyTooling.Attributes` 

44 |rarr| Attributes, which this meta-class collects per class. 

45 :mod:`pyTooling.Exceptions` 

46 |rarr| The base-exception of everything this meta-class raises. 

47""" 

48from __future__ import annotations 

49 

50from functools import wraps 

51from itertools import chain 

52from re import compile as re_compile 

53from sys import modules, version_info 

54from threading import Condition 

55from types import BuiltinFunctionType, FunctionType, MethodType 

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

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

58 

59from pyTooling.Common import getFullyQualifiedName 

60from pyTooling.Exceptions import ToolingException 

61from pyTooling.Decorators import export, readonly 

62from pyTooling.Warning import Warning, WarningCollector 

63 

64 

65__all__ = ["M"] 

66 

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

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

69 

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

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

72iterable of those.""" 

73 

74 

75@export 

76class ThisClass: 

77 """ 

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

79 

80 A class doesn't exist while its body runs, so a class variable can't name it. Setting the variable to this 

81 sentinel says *"the class I am declared in"*, and :class:`ExtendedType` rebinds it once the class exists. 

82 

83 .. code-block:: python 

84 

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

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

87 

88 assert Workflow._PARENT_TYPE is Workflow 

89 

90 Only a variable the class **declared** is rebound; an inherited one keeps the value its own class resolved. It is 

91 an empty class rather than a bare object, so a variable annotated as a :class:`type` still type-checks. 

92 

93 .. note:: 

94 

95 :pep:`661` adds a ``sentinel`` builtin in Python 3.15, and ``ThisClass = sentinel("ThisClass")`` would give a 

96 better :func:`repr` and identity through :mod:`pickle` and :mod:`copy`. It is **not** used here: a sentinel is 

97 an *instance*, so a variable annotated :class:`type` would no longer type-check, and this sentinel never 

98 survives class creation anyway - it is replaced before anyone can pickle or print it. :class:`ExtendedType` 

99 compares by identity, so swapping the definition is a one-line change if that day comes. 

100 """ 

101 

102 

103@export 

104class ExtendedTypeError(ToolingException): 

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

106 

107 

108@export 

109class BaseClassWithoutSlotsError(ExtendedTypeError): 

110 """ 

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

112 

113 .. seealso:: 

114 

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

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

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

118 |rarr| The glossary's short definition. 

119 """ 

120 

121 

122@export 

123class BaseClassWithNonEmptySlotsError(ExtendedTypeError): 

124 """ 

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

126 

127 .. important:: 

128 

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

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

131 into the primary inheritance line. 

132 """ 

133 

134 

135@export 

136class BaseClassIsNotAMixinError(ExtendedTypeError): 

137 """ 

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

139 

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

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

142 """ 

143 

144 

145@export 

146class DuplicateFieldInSlotsError(ExtendedTypeError): 

147 """ 

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

149 """ 

150 

151 

152@export 

153class UnannotatedFieldWarning(Warning): 

154 """ 

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

156 

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

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

159 """ 

160 

161 

162@export 

163class UnfulfilledExpectationError(ExtendedTypeError): 

164 """ 

165 This exception is raised when a class doesn't provide the members a mixin-class in its hierarchy expects. 

166 

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

168 

169 * A **class** lists what it needs from whichever class it is mixed into, with the ``expects`` class keyword 

170 argument. Which members are missing is determined when the class is constructed, and instantiating a class that 

171 still misses one raises this exception. A class may stay incomplete as long as nothing instantiates it, so an 

172 intermediate class can pass the expectation on to its own subclasses. 

173 * A single **method** lists what it needs from its class, with the :deco:`expects` decorator. The class stays 

174 usable; only calling that method raises this exception. 

175 

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

177 code path runs. 

178 

179 .. seealso:: 

180 

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

182 |rarr| The same mechanism, for a class with methods that still need to be overridden. 

183 :deco:`~pyTooling.MetaClasses.expects` 

184 |rarr| Mark a *method* as needing members its class provides only in some combinations. 

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

186 |rarr| The meta-class implementing the check. 

187 """ 

188 

189 

190@export 

191class IncompatibleMetaClassError(ExtendedTypeError): 

192 """ 

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

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

195 """ 

196 

197 

198@export 

199class AbstractClassError(ExtendedTypeError): 

200 """ 

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

202 

203 .. seealso:: 

204 

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

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

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

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

209 :exc:`~MustOverrideClassError` 

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

211 """ 

212 

213 

214@export 

215class MustOverrideClassError(AbstractClassError): 

216 """ 

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

218 

219 .. seealso:: 

220 

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

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

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

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

225 :exc:`~AbstractClassError` 

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

227 """ 

228 

229 

230# """ 

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

232# 

233# .. seealso: 

234# 

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

236# """ 

237 

238 

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

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

241 

242 

243def _recreateClass(cls: type, decoratorName: str, **options: Any) -> type: 

244 """ 

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

246 

247 :param cls: Class to recreate. 

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

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

250 :returns: The recreated class. 

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

252 :class:`ExtendedType`. |br| 

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

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

255 """ 

256 if cls.__class__ is type: 

257 metacls = ExtendedType 

258 elif issubclass(cls.__class__, ExtendedType): 

259 metacls = cls.__class__ 

260 for method in cls.__methods__: 

261 delattr(method, "__classobj__") 

262 else: 

263 metaClass = cls.__class__ 

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

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

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

267 raise ex 

268 

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

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

271 members = { 

272 "__qualname__": cls.__qualname__ 

273 } 

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

275 if key not in slots: 

276 members[key] = value 

277 

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

279 

280 

281@export 

282def slotted(cls): 

283 """ 

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

285 

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

287 meta-class explicitly. 

288 

289 :param cls: The class to recreate. 

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

291 

292 .. seealso:: 

293 

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

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

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

297 |rarr| Recreate a class as a singleton. 

298 """ 

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

300 

301 

302@export 

303def mixin(cls): 

304 """ 

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

306 

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

308 inheritance line. 

309 

310 :param cls: The class to recreate. 

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

312 

313 .. seealso:: 

314 

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

316 |rarr| Recreate a class with slots. 

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

318 |rarr| Recreate a class as a singleton. 

319 """ 

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

321 

322 

323@export 

324def singleton(cls): 

325 """ 

326 Class decorator recreating a class as a singleton. 

327 

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

329 

330 :param cls: The class to recreate. 

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

332 

333 .. seealso:: 

334 

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

336 |rarr| Recreate a class with slots. 

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

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

339 """ 

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

341 

342 

343@export 

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

345 """ 

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

347 

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

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

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

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

352 

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

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

355 

356 .. warning:: 

357 

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

359 

360 .. admonition:: ``example.py`` 

361 

362 .. code-block:: python 

363 

364 @abstractclass 

365 class Base(metaclass=ExtendedType): 

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

367 

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

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

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

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

372 

373 .. seealso:: 

374 

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

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

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

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

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

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

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

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

383 """ 

384 if not isinstance(cls, ExtendedType): 

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

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

387 raise ex 

388 

389 cls.__abstractClass__ = True 

390 

391 if not cls.__isAbstract__: 

392 cls.__isAbstract__ = ExtendedType._wrapNewMethodIfAbstract(cls) 

393 

394 return cls 

395 

396 

397@export 

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

399 """ 

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

401 

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

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

404 

405 .. warning:: 

406 

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

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

409 instantiation. 

410 

411 .. admonition:: ``example.py`` 

412 

413 .. code-block:: python 

414 

415 class Data(mataclass=ExtendedType): 

416 @abstractmethod 

417 def method(self) -> bool: 

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

419 

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

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

422 

423 .. seealso:: 

424 

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

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

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

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

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

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

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

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

433 """ 

434 @wraps(method) 

435 def func(self) -> NoReturn: 

436 """ 

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

438 

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

440 """ 

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

442 

443 func.__abstract__ = True 

444 return func 

445 

446 

447@export 

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

449 """ 

450 Mark a method as *must-override*. 

451 

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

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

454 overridden by an inheriting class. 

455 

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

457 

458 .. warning:: 

459 

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

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

462 instantiation. 

463 

464 .. admonition:: ``example.py`` 

465 

466 .. code-block:: python 

467 

468 class Data(mataclass=ExtendedType): 

469 @mustoverride 

470 def method(self): 

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

472 

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

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

475 

476 .. seealso:: 

477 

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

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

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

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

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

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

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

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

486 """ 

487 method.__mustOverride__ = True 

488 return method 

489 

490 

491@export 

492def expects(*memberNames: str) -> Callable[[M], M]: 

493 """ 

494 Mark a method as needing members its class provides only in some combinations, usually through a mixin-class. 

495 

496 The marked method sits on the class in the **primary inheritance line** and waits for a :term:`mixin-class` 

497 further along the bases to supply what it reads, while the class itself stays perfectly usable without that 

498 mixin. The requirement belongs to the method, not to the class, so ``expects`` as a class keyword argument would 

499 be too strict: it would reject a class that never calls the method. It is the opposite direction of 

500 ``ExtendedType``'s ``expects`` keyword, where a mixin-class states what it needs from its host. 

501 

502 :class:`ExtendedType` checks the marked method against every class it is reachable from. If a class provides the 

503 members, the method is left untouched, so a fulfilled expectation costs nothing per call. If it doesn't, the 

504 method is replaced by one raising an :exc:`UnfulfilledExpectationError` when called - naming the missing members, 

505 instead of an :exc:`AttributeError` from somewhere in the method's body. A replacement inherited from a 

506 base-class is removed again as soon as a class provides the missing members, so the mixin-class may join any 

507 number of levels further down. 

508 

509 .. admonition:: ``example.py`` 

510 

511 .. code-block:: python 

512 

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

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

515 def PrintHelp(self) -> None: 

516 self.MainParser.print_help() 

517 

518 Terminal().PrintHelp() # UnfulfilledExpectationError 

519 

520 class Application(Terminal, ArgParseHelperMixin): 

521 pass 

522 

523 Application().PrintHelp() # fine 

524 

525 :param memberNames: Names of the members the method needs from its class. 

526 :returns: Decorator marking the method with an ``<method>.__expectedMembers__`` field. 

527 :raises TypeError: If an element of parameter 'memberNames' is not a string. 

528 

529 .. seealso:: 

530 

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

532 |rarr| The ``expects`` class keyword argument, for a class that needs the members as a whole. 

533 :exc:`~pyTooling.MetaClasses.UnfulfilledExpectationError` 

534 |rarr| The exception raised when a missing member is reached for. 

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

536 |rarr| Mark a method as *abstract*, when the class itself declares what has to be overridden. 

537 """ 

538 for memberName in memberNames: 

539 if not isinstance(memberName, str): 539 ↛ 540line 539 didn't jump to line 540 because the condition on line 539 was never true

540 ex = TypeError(f"Parameter 'memberNames' contains an element that is not a string.") 

541 ex.add_note(f"Got type '{getFullyQualifiedName(memberName)}'.") 

542 raise ex 

543 

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

545 """ 

546 Attach the expected member names to the decorated method. 

547 

548 :param method: Method that expects members from its class. 

549 :returns: Same method, but with additional ``<method>.__expectedMembers__`` field. 

550 :raises TypeError: If applied to a class instead of a method. |br| 

551 A class states what it expects with the ``expects`` class keyword argument of 

552 :class:`ExtendedType`. 

553 """ 

554 if isinstance(method, type): 554 ↛ 555line 554 didn't jump to line 555 because the condition on line 554 was never true

555 ex = TypeError(f"Decorator 'expects' is applied to class '{method.__name__}' instead of a method.") 

556 ex.add_note(f"A class names what it expects with the 'expects' class keyword argument of 'ExtendedType'.") 

557 raise ex 

558 

559 method.__expectedMembers__ = memberNames 

560 return method 

561 

562 return decorator 

563 

564 

565# @export 

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

567# method.__overloadable__ = True 

568# return method 

569 

570 

571# @export 

572# class DispatchableMethod: 

573# """Represents a single multimethod.""" 

574# 

575# _methods: dict[Tuple, Callable] 

576# __name__: str 

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

578# 

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

580# self.__name__ = name 

581# self._methods = {} 

582# 

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

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

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

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

587# if meth: 

588# return meth(*args) 

589# else: 

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

591# 

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

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

594# if instance is not None: 

595# return MethodType(self, instance) 

596# else: 

597# return self 

598# 

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

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

601# 

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

603# sig = signature(method) 

604# types: list[Type] = [] 

605# 

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

607# if name == "self": 

608# continue 

609# 

610# if parameter.annotation is Parameter.empty: 

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

612# 

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

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

615# 

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

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

618# 

619# types.append(parameter.annotation) 

620# 

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

622 

623 

624# @export 

625# class DispatchDictionary(dict): 

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

627# 

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

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

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

631# currentValue = self[key] 

632# if isinstance(currentValue, DispatchableMethod): 

633# currentValue.register(value) 

634# else: 

635# dispatchable = DispatchableMethod(key) 

636# dispatchable.register(currentValue) 

637# dispatchable.register(value) 

638# 

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

640# else: 

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

642 

643 

644@export 

645class ExtendedType(type): 

646 """ 

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

648 

649 .. todo:: META::ExtendedType Needs documentation. 

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

651 

652 .. rubric:: Features: 

653 

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

655 

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

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

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

659 

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

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

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

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

664 * Let a mixin-class state which members it expects from its host class (``expects``), or a single method state 

665 what it needs from its class (:deco:`expects`), and reject instantiation resp. that call while a member is 

666 missing. 

667 

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

669 

670 .. rubric:: Added class fields: 

671 

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

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

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

675 See :pep:`253` for details. 

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

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

678 :__methods__: List of methods. 

679 :__methodsWithAttributes__: List of methods with pyTooling attributes. 

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

681 :__expectedMembers__: Mapping of a member name expected from the host class to the name of the class 

682 expecting it. 

683 :__missingMembers__: Tuple of expected members this class doesn't provide (yet). 

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

685 :__isAbstract__: True, if class is abstract. 

686 :__isSingleton__: True, if class is a singleton 

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

688 :__singletonInstanceInit__: Singleton is initialized. 

689 :__singletonInstanceCache__: The singleton object, once created. 

690 :__pyattr__: List of class attributes. 

691 

692 .. rubric:: Added class properties: 

693 

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

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

696 

697 .. rubric:: Added methods: 

698 

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

700 

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

702 See :pep:`307` for details. 

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

704 See :pep:`307` for details. 

705 

706 .. rubric:: Modified ``__new__`` method: 

707 

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

709 

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

711 

712 .. rubric:: Modified ``__init__`` method: 

713 

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

715 

716 .. rubric:: Modified abstract methods: 

717 

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

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

720 """ 

721 

722 # @classmethod 

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

724 # return DispatchDictionary() 

725 

726 def __new__( 

727 self, 

728 className: str, 

729 baseClasses: tuple[type], 

730 members: dict[str, Any], 

731 slots: bool = False, 

732 mixin: bool = False, 

733 singleton: bool = False, 

734 expects: Iterable[str] = (), 

735 **kwargs: Any 

736 ) -> Self: 

737 """ 

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

739 

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

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

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

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

744 ``__dict__``. 

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

746 ``slots`` 

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

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

749 :param expects: Optional, names of members this class needs from whichever class it is mixed into. |br| 

750 See :attr:`__expectedMembers__`. 

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

752 :func:`type` does. 

753 :returns: The new class. 

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

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

756 """ 

757 from pyTooling.Attributes import ATTRIBUTES_MEMBER_NAME, AttributeScope 

758 

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

760 if len(baseClasses) > 0: 

761 primaryBaseClass = baseClasses[0] 

762 if isinstance(primaryBaseClass, self): 

763 slots = primaryBaseClass.__slotted__ 

764 

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

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

767 

768 # Compute abstract methods 

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

770 

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

772 # Class variables with an initial value are part of 'members', so they are bound before that hook runs and 

773 # are not re-assigned afterwards - doing so would overwrite whatever '__init_subclass__' computed from them. 

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

775 

776 # A class variable set to 'ThisClass' means "the class I am declared in". The class doesn't exist while its body 

777 # runs, so the value is resolved here. Only a variable this class declared is rebound - an inherited one keeps 

778 # the value its own class resolved - and the current value is read from the class, so an '__init_subclass__' 

779 # that replaced it wins. 

780 for memberName in members: 

781 if getattr(newClass, memberName, None) is ThisClass: 

782 setattr(newClass, memberName, newClass) 

783 

784 # Search in inheritance tree for abstract methods 

785 newClass.__abstractMethods__ = abstractMethods 

786 

787 newClass.__abstractClass__ = False 

788 newClass.__isAbstract__ = self._wrapNewMethodIfAbstract(newClass) 

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

790 

791 # Collect the members expected from the host class and reject instantiation while any of them is missing 

792 newClass.__expectedMembers__ = self._collectExpectedMembers(className, baseClasses, members, expects) 

793 newClass.__missingMembers__ = self._computeMissingMembers(newClass, mixin) 

794 if not newClass.__isAbstract__: 

795 self._wrapNewMethodIfExpectationUnfulfilled(newClass) 

796 

797 if slots: 

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

799 if "__getstate__" not in members: 

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

801 """ 

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

803 

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

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

806 """ 

807 try: 

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

809 except AttributeError as ex: 

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

811 

812 newClass.__getstate__ = __getstate__ 

813 

814 if "__setstate__" not in members: 

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

816 """ 

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

818 

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

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

821 """ 

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

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

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

825 else: 

826 diff = slots.difference(self.__allSlots__) 

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

828 

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

830 setattr(self, slotName, value) 

831 

832 newClass.__setstate__ = __setstate__ 

833 

834 # Check for inherited class attributes 

835 attributes: list[Attribute] = [] 

836 setattr(newClass, ATTRIBUTES_MEMBER_NAME, attributes) 

837 for base in baseClasses: 

838 if hasattr(base, ATTRIBUTES_MEMBER_NAME): 

839 pyAttr = getattr(base, ATTRIBUTES_MEMBER_NAME) 

840 for att in pyAttr: 

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

842 attributes.append(att) 

843 att.__class__._classes.append(newClass) 

844 

845 # Check methods for attributes 

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

847 

848 # Add new fields for found methods 

849 newClass.__methods__ = tuple(methods) 

850 newClass.__methodsWithAttributes__ = tuple(methodsWithAttributes) 

851 

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

853 self._wrapMethodsWithUnfulfilledExpectations(newClass) 

854 

855 # Additional methods on a class 

856 def GetMethodsWithAttributes( 

857 self, 

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

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

860 """ 

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

862 

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

864 attribute. 

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

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

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

868 """ 

869 from pyTooling.Attributes import Attribute 

870 

871 if predicate is None: 

872 predicate = Attribute 

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

874 for attribute in predicate: 

875 if not issubclass(attribute, Attribute): 

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

877 

878 predicate = tuple(predicate) 

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

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

881 

882 methodAttributePairs = {} 

883 for method in newClass.__methodsWithAttributes__: 

884 matchingAttributes: list[Attribute] = [] 

885 for attribute in method.__pyattr__: 

886 if isinstance(attribute, predicate): 

887 matchingAttributes.append(attribute) 

888 

889 if len(matchingAttributes) > 0: 

890 methodAttributePairs[method] = tuple(matchingAttributes) 

891 

892 return methodAttributePairs 

893 

894 newClass.GetMethodsWithAttributes = classmethod(GetMethodsWithAttributes) 

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

896 

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

898 # GetClassAtrributes -> list[attributes] / generator 

899 # MethodHasAttributes(predicate) -> bool 

900 # GetAttribute 

901 

902 return newClass 

903 

904 @classmethod 

905 def _findMethods( 

906 self, 

907 newClass: ExtendedType, 

908 baseClasses: tuple[type], 

909 members: dict[str, Any] 

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

911 """ 

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

913 

914 .. todo:: 

915 

916 Describe algorithm. 

917 

918 :param newClass: Newly created class instance. 

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

920 :param members: Members of the new class. 

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

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

923 """ 

924 from pyTooling.Attributes import Attribute 

925 

926 # Embedded bind function due to circular dependencies. 

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

928 """ 

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

930 

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

932 

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

934 :param func: The function to bind. 

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

936 :returns: The bound method. 

937 """ 

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

939 methodName = func.__name__ 

940 

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

942 setattr(instance, methodName, boundMethod) 

943 

944 return boundMethod 

945 

946 methods = [] 

947 methodsWithAttributes = [] 

948 attributeIndex = {} 

949 

950 for base in baseClasses: 

951 if hasattr(base, "__methodsWithAttributes__"): 

952 methodsWithAttributes.extend(base.__methodsWithAttributes__) 

953 

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

955 if isinstance(member, FunctionType): 

956 method = newClass.__dict__[memberName] 

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

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

959 else: 

960 setattr(method, "__classobj__", newClass) 

961 

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

963 """ 

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

965 

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

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

968 :returns: Tuple of the matching attributes. 

969 """ 

970 results = [] 

971 try: 

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

973 if isinstance(attribute, predicate): 

974 results.append(attribute) 

975 return tuple(results) 

976 except AttributeError: 

977 return tuple() 

978 

979 method.GetAttributes = bind(method, GetAttributes) 

980 methods.append(method) 

981 

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

983 # print(f" {member}") 

984 if "__pyattr__" in member.__dict__: 

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

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

987 methodsWithAttributes.append(member) 

988 for attribute in attributes: 

989 attribute._functions.remove(method) 

990 attribute._methods.append(method) 

991 

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

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

994 attributeIndex[attribute] = [member] 

995 else: 

996 attributeIndex[attribute].append(member) 

997 # else: 

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

999 # else: 

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

1001 return methods, methodsWithAttributes 

1002 

1003 @classmethod 

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

1005 """ 

1006 Return the type annotations declared in a class body. 

1007 

1008 .. important:: 

1009 

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

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

1012 older Python versions, therefore both mechanisms are supported. 

1013 

1014 :param members: Dictionary of class members. 

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

1016 no annotations. 

1017 """ 

1018 if "__annotations__" in members: 

1019 # WORKAROUND: LEGACY SUPPORT Python <= 3.13 

1020 # Accessing annotations was changed in Python 3.14. 

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

1022 annotations = members["__annotations__"] 

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

1024 from annotationlib import Format 

1025 try: 

1026 annotations = annotate(Format.VALUE) 

1027 except NameError: 

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

1029 annotations = annotate(Format.STRING) 

1030 else: 

1031 return {} 

1032 

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

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

1035 return { 

1036 name: metacls._resolveAnnotation(typeAnnotation, members) 

1037 for name, typeAnnotation in annotations.items() 

1038 } 

1039 

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

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

1042 

1043 @classmethod 

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

1045 """ 

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

1047 

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

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

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

1051 

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

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

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

1055 type object. 

1056 

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

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

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

1060 """ 

1061 if not isinstance(typeAnnotation, str): 

1062 return typeAnnotation 

1063 

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

1065 try: 

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

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

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

1069 return typeAnnotation 

1070 

1071 @classmethod 

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

1073 """ 

1074 Check if a type annotation declares a class variable. 

1075 

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

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

1078 

1079 :param typeAnnotation: The type annotation to check. 

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

1081 """ 

1082 if isinstance(typeAnnotation, str): 

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

1084 

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

1086 

1087 @classmethod 

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

1089 """ 

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

1091 

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

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

1094 

1095 :param member: The class member to check. 

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

1097 """ 

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

1099 return False 

1100 

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

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

1103 

1104 @classmethod 

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

1106 """ 

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

1108 

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

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

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

1112 

1113 .. important:: 

1114 

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

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

1117 with un-annotated fields doesn't fail. 

1118 

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

1120 :param members: Dictionary of class members. 

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

1122 """ 

1123 unannotatedFields = [ 

1124 fieldName 

1125 for fieldName, member in members.items() 

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

1127 and fieldName not in annotations 

1128 and metacls._isField(member) 

1129 ] 

1130 

1131 if len(unannotatedFields) > 0: 

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

1133 WarningCollector.Raise( 

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

1135 notes=( 

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

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

1138 ) 

1139 ) 

1140 

1141 @classmethod 

1142 def _computeSlots( 

1143 self, 

1144 className: str, 

1145 baseClasses: tuple[type], 

1146 members: dict[str, Any], 

1147 slots: bool, 

1148 mixin: bool 

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

1150 """ 

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

1152 

1153 .. todo:: 

1154 

1155 Describe algorithm. 

1156 

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

1158 :param baseClasses: Tuple of base-classes. 

1159 :param members: Dictionary of class members. 

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

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

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

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

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

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

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

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

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

1169 name to be assigned in the class body. 

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

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

1172 themselves. 

1173 :raises DuplicateFieldInSlotsError: If a class member shadows a slot a base-class or mixin-class declares. 

1174 |br| 

1175 Without an annotation, annotate it as ``ClassVar[...]`` or remove the 

1176 assignment. Annotating it does **not** resolve the clash - the slot 

1177 exists either way - so a class variable of that name has to be renamed, 

1178 or the slot dropped. 

1179 """ 

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

1181 slottedFields = [] 

1182 classFields = {} 

1183 objectFields = {} 

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

1185 if slots or mixin: 

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

1187 for baseClass in self._iterateBaseClasses(baseClasses): 

1188 # Exclude object as a special case 

1189 if baseClass is object or baseClass is Generic: 

1190 continue 

1191 

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

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

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

1195 raise ex 

1196 

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

1198 

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

1200 inheritedSlottedFields = {} 

1201 if len(baseClasses) > 0: 

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

1203 # Exclude object as a special case 

1204 if base is object or base is Generic: 

1205 continue 

1206 

1207 for annotation in base.__slots__: 

1208 inheritedSlottedFields[annotation] = base 

1209 

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

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

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

1213 cls = inheritedSlottedFields[fieldName] 

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

1215 

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

1217 # * If it has an initial value, note the field in classFields and **leave it in members**, so that 

1218 # 'type.__new__' binds it before calling '__init_subclass__'. Removing it and assigning it 

1219 # afterwards made a derived class' value invisible to that hook, which then read the base class'. 

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

1221 isClassVariable = self._isClassVariable(typeAnnotation) 

1222 hasInitialValue = fieldName in members 

1223 if isClassVariable: 

1224 if hasInitialValue: 

1225 classFields[fieldName] = members[fieldName] 

1226 

1227 # If an annotated field has an initial value 

1228 # * copy field and initial value to objectFields dictionary 

1229 # * remove field from members 

1230 elif hasInitialValue: 

1231 slottedFields.append(fieldName) 

1232 objectFields[fieldName] = members[fieldName] 

1233 del members[fieldName] 

1234 else: 

1235 slottedFields.append(fieldName) 

1236 

1237 mixinSlots = self._aggregateMixinSlots(className, baseClasses) 

1238 

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

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

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

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

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

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

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

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

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

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

1249 else: 

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

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

1252 

1253 if fieldName in classFields: 

1254 ex.add_note("Annotating it as 'ClassVar[...]' doesn't resolve this - the slot exists either way.") 

1255 ex.add_note("Rename the class variable, or drop the slot the base-class or mixin-class declares.") 

1256 else: 

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

1258 

1259 raise ex 

1260 else: 

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

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

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

1264 # * copy field and initial value to classFields dictionary 

1265 # * remove field from members 

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

1267 classFields[fieldName] = members[fieldName] 

1268 

1269 self._checkForUnannotatedFields(className, members, annotations) 

1270 

1271 if mixin: 

1272 mixinSlots.extend(slottedFields) 

1273 members["__slotted__"] = True 

1274 members["__slots__"] = tuple() 

1275 members["__allSlots__"] = set() 

1276 members["__isMixin__"] = True 

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

1278 elif slots: 

1279 slottedFields.extend(mixinSlots) 

1280 members["__slotted__"] = True 

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

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

1283 members["__isMixin__"] = False 

1284 members["__mixinSlots__"] = tuple() 

1285 else: 

1286 members["__slotted__"] = False 

1287 # NO __slots__ 

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

1289 members["__isMixin__"] = False 

1290 members["__mixinSlots__"] = tuple() 

1291 return classFields, objectFields 

1292 

1293 @classmethod 

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

1295 """ 

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

1297 

1298 .. todo:: 

1299 

1300 Describe algorithm. 

1301 

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

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

1304 :returns: A list of slot names. 

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

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

1307 """ 

1308 mixinSlots = [] 

1309 if len(baseClasses) > 0: 

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

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

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

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

1314 for typePath in inheritancePaths[1:]: 

1315 for t in typePath: 

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

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

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

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

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

1321 raise ex 

1322 

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

1324 # Ensure all base-classes are either constructed 

1325 # * by meta-class ExtendedType, or 

1326 # * use no slots, or 

1327 # * are typing.Generic 

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

1329 for baseClass in baseClasses: # type: ExtendedType 

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

1331 pass 

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

1333 mixinSlots.extend(baseClass.__mixinSlots__) 

1334 elif hasattr(baseClass, "__mixinSlots__"): 

1335 mixinSlots.extend(baseClass.__mixinSlots__) 

1336 

1337 return mixinSlots 

1338 

1339 @classmethod 

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

1341 """ 

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

1343 

1344 .. todo:: 

1345 

1346 Describe iteration order. 

1347 

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

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

1350 """ 

1351 if len(baseClasses) == 0: 

1352 return 

1353 

1354 visited: set[type] = set() 

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

1356 

1357 for baseClass in baseClasses: 

1358 yield baseClass 

1359 visited.add(baseClass) 

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

1361 

1362 while True: 

1363 try: 

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

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

1366 yield base 

1367 if len(base.__bases__) > 0: 

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

1369 else: 

1370 continue 

1371 

1372 except StopIteration: 

1373 iteratorStack.pop() 

1374 

1375 if len(iteratorStack) == 0: 

1376 break 

1377 

1378 @classmethod 

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

1380 """ 

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

1382 

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

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

1385 

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

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

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

1389 """ 

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

1391 return 

1392 

1393 typeStack: list[type] = list() 

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

1395 

1396 for baseClass in baseClasses: 

1397 typeStack.append(baseClass) 

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

1399 

1400 while True: 

1401 try: 

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

1403 typeStack.append(base) 

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

1405 yield tuple(typeStack) 

1406 typeStack.pop() 

1407 else: 

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

1409 

1410 except StopIteration: 

1411 typeStack.pop() 

1412 iteratorStack.pop() 

1413 

1414 if len(typeStack) == 0: 

1415 break 

1416 

1417 @classmethod 

1418 def _checkForAbstractMethods( 

1419 metacls, 

1420 baseClasses: tuple[type], 

1421 members: dict[str, Any] 

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

1423 """ 

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

1425 

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

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

1428 

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

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

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

1432 """ 

1433 abstractMethods = {} 

1434 if baseClasses: 

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

1436 for baseClass in baseClasses: 

1437 if hasattr(baseClass, "__abstractMethods__"): 

1438 abstractMethods.update(baseClass.__abstractMethods__) 

1439 

1440 for base in baseClasses: 

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

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

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

1444 if memberName in members: 

1445 continue 

1446 

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

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

1449 def outer(method): 

1450 """ 

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

1452 

1453 :param method: The inherited abstract method. 

1454 :returns: A wrapper forwarding to that method. 

1455 """ 

1456 @wraps(method) 

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

1458 """ 

1459 Wrapper forwarding to the inherited abstract method. 

1460 

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

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

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

1464 :returns: Whatever the wrapped method returns. 

1465 """ 

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

1467 

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

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

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

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

1472 

1473 return inner 

1474 

1475 members[memberName] = outer(member) 

1476 

1477 # Check if methods are marked: 

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

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

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

1481 if callable(member): 

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

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

1484 abstractMethods[memberName] = member 

1485 elif memberName in abstractMethods: 

1486 del abstractMethods[memberName] 

1487 

1488 return abstractMethods, members 

1489 

1490 @classmethod 

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

1492 """ 

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

1494 

1495 Only the first object creation initializes the object. 

1496 

1497 This implementation is threadsafe. 

1498 

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

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

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

1502 """ 

1503 if hasattr(newClass, "__isSingleton__"): 

1504 singleton = newClass.__isSingleton__ 

1505 

1506 if singleton: 

1507 oldnew = newClass.__new__ 

1508 if hasattr(oldnew, "__singleton_wrapper__"): 

1509 oldnew = oldnew.__wrapped__ 

1510 

1511 oldinit = newClass.__init__ 

1512 if hasattr(oldinit, "__singleton_wrapper__"): 

1513 oldinit = oldinit.__wrapped__ 

1514 

1515 @wraps(oldnew) 

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

1517 """ 

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

1519 

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

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

1522 

1523 :param cls: The class being instantiated. 

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

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

1526 :returns: The singleton's instance. 

1527 """ 

1528 with cls.__singletonInstanceCond__: 

1529 if cls.__singletonInstanceCache__ is None: 

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

1531 cls.__singletonInstanceCache__ = obj 

1532 else: 

1533 obj = cls.__singletonInstanceCache__ 

1534 

1535 return obj 

1536 

1537 @wraps(oldinit) 

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

1539 """ 

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

1541 

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

1543 

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

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

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

1547 """ 

1548 cls = self.__class__ 

1549 cv = cls.__singletonInstanceCond__ 

1550 with cv: 

1551 if cls.__singletonInstanceInit__: 

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

1553 cls.__singletonInstanceInit__ = False 

1554 cv.notify_all() 

1555 elif args or kwargs: 

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

1557 else: 

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

1559 cv.wait() 

1560 

1561 singleton_new.__singleton_wrapper__ = True 

1562 singleton_init.__singleton_wrapper__ = True 

1563 

1564 newClass.__new__ = singleton_new 

1565 newClass.__init__ = singleton_init 

1566 newClass.__singletonInstanceCond__ = Condition() 

1567 newClass.__singletonInstanceInit__ = True 

1568 newClass.__singletonInstanceCache__ = None 

1569 return True 

1570 

1571 return False 

1572 

1573 @classmethod 

1574 def _collectExpectedMembers( 

1575 metacls, 

1576 className: str, 

1577 baseClasses: tuple[type, ...], 

1578 members: dict[str, Any], 

1579 expects: Iterable[str] 

1580 ) -> dict[str, str]: 

1581 """ 

1582 Collect the members expected by this class and by every class in its inheritance hierarchy. 

1583 

1584 A mixin-class states what it needs from the class it is mixed into, and that expectation has to survive until a 

1585 concrete class can satisfy it. The result maps each expected member's name to the name of the class expecting 

1586 it, so an error message can name the origin. 

1587 

1588 :param className: The name of the class being constructed. 

1589 :param baseClasses: The tuple of base-classes the class is derived from. 

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

1591 :param expects: Names of members the class being constructed expects. 

1592 :returns: Dictionary mapping an expected member's name to the name of the class expecting it. 

1593 :raises TypeError: If parameter 'expects' is not an iterable of strings. 

1594 :raises TypeError: If an element of parameter 'expects' is not a string. 

1595 """ 

1596 if isinstance(expects, str) or not isinstance(expects, Iterable): 1596 ↛ 1597line 1596 didn't jump to line 1597 because the condition on line 1596 was never true

1597 ex = TypeError(f"Parameter 'expects' is not an iterable of strings.") 

1598 ex.add_note(f"Got type '{getFullyQualifiedName(expects)}'.") 

1599 raise ex 

1600 

1601 expects = tuple(expects) 

1602 for memberName in expects: 

1603 if not isinstance(memberName, str): 1603 ↛ 1604line 1603 didn't jump to line 1604 because the condition on line 1603 was never true

1604 ex = TypeError(f"Parameter 'expects' contains an element that is not a string.") 

1605 ex.add_note(f"Got type '{getFullyQualifiedName(memberName)}'.") 

1606 raise ex 

1607 

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

1609 for baseClass in baseClasses: 

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

1611 expected.setdefault(memberName, origin) 

1612 

1613 # a class recreated by '@mixin' and friends carries its own expectations in the copied members 

1614 for memberName, origin in members.get("__expectedMembers__", {}).items(): 

1615 expected.setdefault(memberName, origin) 

1616 

1617 for memberName in expects: 

1618 expected.setdefault(memberName, className) 

1619 

1620 return expected 

1621 

1622 @classmethod 

1623 def _computeMissingMembers(metacls, newClass: type, mixin: bool) -> tuple[str, ...]: 

1624 """ 

1625 Determine which of the expected members the class doesn't provide. 

1626 

1627 A member is provided when it is reachable on the class: a method, a property, a class variable, or a field, for 

1628 which :class:`ExtendedType` created a slot descriptor when the mixin-class joined the primary inheritance line. 

1629 

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

1631 

1632 :param newClass: The newly constructed class. 

1633 :param mixin: ``True``, if the class is a mixin-class. 

1634 :returns: Tuple of expected member names the class doesn't provide. 

1635 """ 

1636 if mixin: 

1637 return tuple() 

1638 

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

1640 

1641 @classmethod 

1642 def _wrapNewMethodIfExpectationUnfulfilled(metacls, newClass) -> bool: 

1643 """ 

1644 If the class doesn't provide every expected member, replace the ``__new__`` method, so it raises an exception. 

1645 

1646 The diagnosis happens here, at class construction time, but the exception is raised on instantiation - the same 

1647 way an abstract class is handled. A class is allowed to stay incomplete as long as nothing instantiates it, so 

1648 an intermediate class can pass an expectation on to its own subclasses. 

1649 

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

1651 :returns: ``True``, if the class has unfulfilled expectations. 

1652 """ 

1653 if len(newClass.__missingMembers__) == 0: 

1654 # skip an intermediate 'new' function if the class fulfills its expectations again 

1655 oldnew = newClass.__new__ 

1656 if hasattr(oldnew, "__raises_unfulfilled_expectation_error__"): 

1657 newClass.__new__ = oldnew.__wrapped__ 

1658 

1659 return False 

1660 

1661 oldnew = newClass.__new__ 

1662 if hasattr(oldnew, "__raises_unfulfilled_expectation_error__"): 

1663 oldnew = oldnew.__wrapped__ 

1664 

1665 @wraps(oldnew) 

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

1667 """ 

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

1669 

1670 :param cls: The class an instance was requested of. 

1671 :raises UnfulfilledExpectationError: Always, because the class doesn't provide every expected member. 

1672 """ 

1673 ex = UnfulfilledExpectationError(f"Class '{cls.__name__}' doesn't provide every expected member.") 

1674 for memberName in newClass.__missingMembers__: 

1675 ex.add_note(f"Missing '{memberName}', expected by '{newClass.__expectedMembers__[memberName]}'.") 

1676 raise ex 

1677 

1678 unfulfilled_new.__raises_unfulfilled_expectation_error__ = True 

1679 

1680 newClass.__new__ = unfulfilled_new 

1681 return True 

1682 

1683 @classmethod 

1684 def _wrapMethodsWithUnfulfilledExpectations(metacls, newClass) -> tuple[str, ...]: 

1685 """ 

1686 Replace every method marked with :deco:`expects` whose expected members the class doesn't provide. 

1687 

1688 The replacement raises an :exc:`UnfulfilledExpectationError` when it is called, so the class stays usable and 

1689 only the method that can't work is rejected. A method whose expectation is fulfilled is left alone - the 

1690 original function stays in the class, so a fulfilled expectation costs nothing per call. A replacement 

1691 inherited from a base-class is removed again as soon as a class provides the missing members. 

1692 

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

1694 :returns: Tuple of method names that were replaced. 

1695 """ 

1696 # Collect every marked method reachable on this class, looking underneath a replacement from a base-class 

1697 marked: dict[str, Callable[..., Any]] = {} 

1698 for baseClass in reversed(newClass.__mro__): 

1699 for memberName, member in vars(baseClass).items(): 

1700 original = getattr(member, "__wrapped__", member) 

1701 if hasattr(original, "__expectedMembers__"): 

1702 marked[memberName] = original 

1703 

1704 wrapped: list[str] = [] 

1705 for memberName, original in marked.items(): 

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

1707 inherited = getattr(newClass, memberName) 

1708 isReplaced = hasattr(inherited, "__raises_unfulfilled_expectation_error__") 

1709 

1710 if len(missing) == 0: 

1711 # the class provides the members now, so the original method is reachable again 

1712 if isReplaced: 

1713 setattr(newClass, memberName, original) 

1714 continue 

1715 

1716 wrapped.append(memberName) 

1717 if isReplaced and inherited.__missingMembers__ == missing: 

1718 # the inherited replacement already reports exactly these members 

1719 continue 

1720 

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

1722 """ 

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

1724 

1725 :param methodName: Name of the method that is replaced. 

1726 :param missingMembers: Names of the members the method needs and the class doesn't provide. 

1727 :returns: Replacement method raising an :exc:`UnfulfilledExpectationError`. 

1728 """ 

1729 @wraps(original) 

1730 def unfulfilledMethod(self, *_, **__) -> NoReturn: 

1731 """ 

1732 Replacement method, which rejects a call that would fail on a missing member. 

1733 

1734 :raises UnfulfilledExpectationError: Always, because the class doesn't provide every expected member. 

1735 """ 

1736 message = f"Method '{type(self).__name__}.{methodName}()' expects members this class doesn't provide." 

1737 ex = UnfulfilledExpectationError(message) 

1738 for memberName in missingMembers: 

1739 ex.add_note(f"Missing '{memberName}'.") 

1740 raise ex 

1741 

1742 return unfulfilledMethod 

1743 

1744 replacement = unfulfilledMethodFactory(memberName, missing) 

1745 

1746 replacement.__raises_unfulfilled_expectation_error__ = True 

1747 replacement.__missingMembers__ = missing 

1748 

1749 setattr(newClass, memberName, replacement) 

1750 

1751 return tuple(wrapped) 

1752 

1753 @classmethod 

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

1755 """ 

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

1757 raises an exception. 

1758 

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

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

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

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

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

1764 """ 

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

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

1767 oldnew = newClass.__new__ 

1768 if hasattr(oldnew, "__raises_abstract_class_error__"): 

1769 oldnew = oldnew.__wrapped__ 

1770 

1771 @wraps(oldnew) 

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

1773 """ 

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

1775 

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

1777 abstract without having abstract methods. 

1778 

1779 :param cls: The abstract class an instance was requested of. 

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

1781 """ 

1782 if len(newClass.__abstractMethods__) > 0: 

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

1784 else: 

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

1786 

1787 abstract_new.__raises_abstract_class_error__ = True 

1788 

1789 newClass.__new__ = abstract_new 

1790 return True 

1791 

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

1793 else: 

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

1795 try: 

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

1797 origNew = newClass.__new__.__wrapped__ 

1798 

1799 # WORKAROUND: __new__ checks tp_new and implements different behavior 

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

1801 if origNew is object.__new__: 

1802 @wraps(object.__new__) 

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

1804 """ 

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

1806 

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

1808 parameters. 

1809 

1810 :param inst: The class being instantiated. 

1811 :returns: The new instance. 

1812 """ 

1813 return object.__new__(inst) 

1814 

1815 newClass.__new__ = wrapped_new 

1816 else: 

1817 newClass.__new__ = origNew 

1818 elif newClass.__new__.__isSingleton__: 

1819 raise ExtendedTypeError( 

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

1821 ) 

1822 except AttributeError as ex: 

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

1824 raise ex 

1825 

1826 return False 

1827 

1828 # Additional properties and methods on a class 

1829 @readonly 

1830 def HasClassAttributes(self) -> bool: 

1831 """ 

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

1833 

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

1835 """ 

1836 try: 

1837 return len(self.__pyattr__) > 0 

1838 except AttributeError: 

1839 return False 

1840 

1841 @readonly 

1842 def HasMethodAttributes(self) -> bool: 

1843 """ 

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

1845 

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

1847 """ 

1848 try: 

1849 return len(self.__methodsWithAttributes__) > 0 

1850 except AttributeError: 

1851 return False 

1852 

1853 

1854@export 

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

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