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

435 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-02-20 22:29 +0000

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

2# _____ _ _ __ __ _ ____ _ # 

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

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

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

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

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

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

9# Authors: # 

10# Patrick Lehmann # 

11# Sven Köhler # 

12# # 

13# License: # 

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

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

16# # 

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

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

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

20# # 

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

22# # 

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

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

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

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

27# limitations under the License. # 

28# # 

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

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

31# 

32""" 

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

34 

35.. hint:: 

36 

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

38""" 

39from functools import wraps 

40from itertools import chain 

41from sys import version_info 

42from threading import Condition 

43from types import FunctionType, MethodType 

44from typing import Any, Tuple, List, Dict, Callable, Generator, Set, Iterator, Iterable, Union, NoReturn, Self 

45from typing import Type, TypeVar, Generic, _GenericAlias, ClassVar, Optional as Nullable 

46 

47from pyTooling.Exceptions import ToolingException 

48from pyTooling.Decorators import export, readonly 

49 

50 

51__all__ = ["M"] 

52 

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

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

55 

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

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

58iterable of those.""" 

59 

60 

61@export 

62class ExtendedTypeError(ToolingException): 

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

64 

65 

66@export 

67class BaseClassWithoutSlotsError(ExtendedTypeError): 

68 """ 

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

70 

71 .. seealso:: 

72 

73 * :ref:`Python data model for slots <slots>` 

74 * :term:`Glossary entry __slots__ <__slots__>` 

75 """ 

76 

77 

78@export 

79class BaseClassWithNonEmptySlotsError(ExtendedTypeError): 

80 """ 

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

82 

83 .. important:: 

84 

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

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

87 into the primary inheritance line. 

88 """ 

89 

90 

91@export 

92class BaseClassIsNotAMixinError(ExtendedTypeError): 

93 pass 

94 

95 

96@export 

97class DuplicateFieldInSlotsError(ExtendedTypeError): 

98 """ 

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

100 """ 

101 

102 

103@export 

104class AbstractClassError(ExtendedTypeError): 

105 """ 

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

107 

108 .. seealso:: 

109 

110 :func:`@abstractmethod <pyTooling.MetaClasses.abstractmethod>` 

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

112 :func:`@mustoverride <pyTooling.MetaClasses.mustoverride>` 

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

114 :exc:`~MustOverrideClassError` 

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

116 """ 

117 

118 

119@export 

120class MustOverrideClassError(AbstractClassError): 

121 """ 

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

123 

124 .. seealso:: 

125 

126 :func:`@abstractmethod <pyTooling.MetaClasses.abstractmethod>` 

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

128 :func:`@mustoverride <pyTooling.MetaClasses.mustoverride>` 

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

130 :exc:`~AbstractClassError` 

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

132 """ 

133 

134 

135# """ 

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

137# 

138# .. seealso: 

139# 

140# `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>`__ 

141# """ 

142 

143 

144M = TypeVar("M", bound=Callable) #: A type variable for methods. 

145 

146 

147@export 

148def slotted(cls): 

149 if cls.__class__ is type: 

150 metacls = ExtendedType 

151 elif issubclass(cls.__class__, ExtendedType): 

152 metacls = cls.__class__ 

153 for method in cls.__methods__: 

154 delattr(method, "__classobj__") 

155 else: 

156 raise ExtendedTypeError("Class uses an incompatible meta-class.") # FIXME: create exception for it? 

157 

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

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

160 members = { 

161 "__qualname__": cls.__qualname__ 

162 } 

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

164 if key not in slots: 

165 members[key] = value 

166 

167 return metacls(cls.__name__, bases, members, slots=True) 

168 

169 

170@export 

171def mixin(cls): 

172 if cls.__class__ is type: 

173 metacls = ExtendedType 

174 elif issubclass(cls.__class__, ExtendedType): 174 ↛ 179line 174 didn't jump to line 179 because the condition on line 174 was always true

175 metacls = cls.__class__ 

176 for method in cls.__methods__: 

177 delattr(method, "__classobj__") 

178 else: 

179 raise ExtendedTypeError("Class uses an incompatible meta-class.") # FIXME: create exception for it? 

180 

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

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

183 members = { 

184 "__qualname__": cls.__qualname__ 

185 } 

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

187 if key not in slots: 

188 members[key] = value 

189 

190 return metacls(cls.__name__, bases, members, mixin=True) 

191 

192 

193@export 

194def singleton(cls): 

195 if cls.__class__ is type: 

196 metacls = ExtendedType 

197 elif issubclass(cls.__class__, ExtendedType): 

198 metacls = cls.__class__ 

199 for method in cls.__methods__: 

200 delattr(method, "__classobj__") 

201 else: 

202 raise ExtendedTypeError("Class uses an incompatible meta-class.") # FIXME: create exception for it? 

203 

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

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

206 members = { 

207 "__qualname__": cls.__qualname__ 

208 } 

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

210 if key not in slots: 

211 members[key] = value 

212 

213 return metacls(cls.__name__, bases, members, singleton=True) 

214 

215 

216@export 

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

218 """ 

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

220 

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

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

223 

224 .. warning:: 

225 

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

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

228 instantiation. 

229 

230 .. admonition:: ``example.py`` 

231 

232 .. code-block:: python 

233 

234 class Data(mataclass=ExtendedType): 

235 @abstractmethod 

236 def method(self) -> bool: 

237 '''This method needs to be implemented''' 

238 

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

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

241 

242 .. seealso:: 

243 

244 * :exc:`~pyTooling.Exceptions.AbstractClassError` 

245 * :func:`~pyTooling.Metaclasses.mustoverride` 

246 * :func:`~pyTooling.Metaclasses.notimplemented` 

247 """ 

248 @wraps(method) 

249 def func(self) -> NoReturn: 

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

251 

252 func.__abstract__ = True 

253 return func 

254 

255 

256@export 

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

258 """ 

259 Mark a method as *must-override*. 

260 

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

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

263 overridden by an inheriting class. 

264 

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

266 

267 .. warning:: 

268 

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

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

271 instantiation. 

272 

273 .. admonition:: ``example.py`` 

274 

275 .. code-block:: python 

276 

277 class Data(mataclass=ExtendedType): 

278 @mustoverride 

279 def method(self): 

280 '''This is a very basic implementation''' 

281 

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

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

284 

285 .. seealso:: 

286 

287 * :exc:`~pyTooling.Exceptions.MustOverrideClassError` 

288 * :func:`~pyTooling.Metaclasses.abstractmethod` 

289 * :func:`~pyTooling.Metaclasses.notimplemented` 

290 """ 

291 method.__mustOverride__ = True 

292 return method 

293 

294 

295# @export 

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

297# method.__overloadable__ = True 

298# return method 

299 

300 

301# @export 

302# class DispatchableMethod: 

303# """Represents a single multimethod.""" 

304# 

305# _methods: Dict[Tuple, Callable] 

306# __name__: str 

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

308# 

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

310# self.__name__ = name 

311# self._methods = {} 

312# 

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

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

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

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

317# if meth: 

318# return meth(*args) 

319# else: 

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

321# 

322# def __get__(self, instance, cls): # Starting with Python 3.11+, use typing.Self as return type 

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

324# if instance is not None: 

325# return MethodType(self, instance) 

326# else: 

327# return self 

328# 

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

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

331# 

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

333# sig = signature(method) 

334# types: List[Type] = [] 

335# 

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

337# if name == "self": 

338# continue 

339# 

340# if parameter.annotation is Parameter.empty: 

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

342# 

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

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

345# 

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

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

348# 

349# types.append(parameter.annotation) 

350# 

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

352 

353 

354# @export 

355# class DispatchDictionary(dict): 

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

357# 

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

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

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

361# currentValue = self[key] 

362# if isinstance(currentValue, DispatchableMethod): 

363# currentValue.register(value) 

364# else: 

365# dispatchable = DispatchableMethod(key) 

366# dispatchable.register(currentValue) 

367# dispatchable.register(value) 

368# 

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

370# else: 

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

372 

373 

374@export 

375class ExtendedType(type): 

376 """ 

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

378 

379 .. todo:: META::ExtendedType Needs documentation. 

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

381 

382 .. rubric:: Features: 

383 

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

385 

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

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

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

389 

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

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

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

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

394 

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

396 

397 .. rubric:: Added class fields: 

398 

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

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

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

402 See :pep:`253` for details. 

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

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

405 :__methods__: List of methods. 

406 :__methodsWithAttributes__: List of methods with pyTooling attributes. 

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

408 :__isAbstract__: True, if class is abstract. 

409 :__isSingleton__: True, if class is a singleton 

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

411 :__singletonInstanceInit__: Singleton is initialized. 

412 :__singletonInstanceCache__: The singleton object, once created. 

413 :__pyattr__: List of class attributes. 

414 

415 .. rubric:: Added class properties: 

416 

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

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

419 

420 .. rubric:: Added methods: 

421 

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

423 

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

425 See :pep:`307` for details. 

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

427 See :pep:`307` for details. 

428 

429 .. rubric:: Modified ``__new__`` method: 

430 

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

432 

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

434 

435 .. rubric:: Modified ``__init__`` method: 

436 

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

438 

439 .. rubric:: Modified abstract methods: 

440 

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

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

443 """ 

444 

445 # @classmethod 

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

447 # return DispatchDictionary() 

448 

449 def __new__( 

450 self, 

451 className: str, 

452 baseClasses: Tuple[type], 

453 members: Dict[str, Any], 

454 slots: bool = False, 

455 mixin: bool = False, 

456 singleton: bool = False 

457 ) -> Self: 

458 """ 

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

460 

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

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

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

464 :param slots: If true, store object attributes in :term:`__slots__ <slots>` instead of ``__dict__``. 

465 :param mixin: If true, make the class a :term:`Mixin-Class`. 

466 If false, create slots if ``slots`` is true. 

467 If none, preserve behavior of primary base-class. 

468 :param singleton: If true, make the class a :term:`Singleton`. 

469 :returns: The new class. 

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

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

472 """ 

473 from pyTooling.Attributes import ATTRIBUTES_MEMBER_NAME, AttributeScope 

474 

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

476 if len(baseClasses) > 0: 

477 primaryBaseClass = baseClasses[0] 

478 if isinstance(primaryBaseClass, self): 

479 slots = primaryBaseClass.__slotted__ 

480 

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

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

483 

484 # Compute abstract methods 

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

486 

487 # Create a new class 

488 newClass = type.__new__(self, className, baseClasses, members) 

489 

490 # Apply class fields 

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

492 setattr(newClass, fieldName, typeAnnotation) 

493 

494 # Search in inheritance tree for abstract methods 

495 newClass.__abstractMethods__ = abstractMethods 

496 newClass.__isAbstract__ = self._wrapNewMethodIfAbstract(newClass) 

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

498 

499 if slots: 

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

501 if "__getstate__" not in members: 

502 def __getstate__(self) -> Dict[str, Any]: 

503 try: 

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

505 except AttributeError as ex: 

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

507 

508 newClass.__getstate__ = __getstate__ 

509 

510 if "__setstate__" not in members: 

511 def __setstate__(self, state: Dict[str, Any]) -> None: 

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

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

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

515 else: 

516 diff = slots.difference(self.__allSlots__) 

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

518 

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

520 setattr(self, slotName, value) 

521 

522 newClass.__setstate__ = __setstate__ 

523 

524 # Check for inherited class attributes 

525 attributes = [] 

526 setattr(newClass, ATTRIBUTES_MEMBER_NAME, attributes) 

527 for base in baseClasses: 

528 if hasattr(base, ATTRIBUTES_MEMBER_NAME): 

529 pyAttr = getattr(base, ATTRIBUTES_MEMBER_NAME) 

530 for att in pyAttr: 

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

532 attributes.append(att) 

533 att.__class__._classes.append(newClass) 

534 

535 # Check methods for attributes 

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

537 

538 # Add new fields for found methods 

539 newClass.__methods__ = tuple(methods) 

540 newClass.__methodsWithAttributes__ = tuple(methodsWithAttributes) 

541 

542 # Additional methods on a class 

543 def GetMethodsWithAttributes(self, predicate: Nullable[TAttributeFilter[TAttr]] = None) -> Dict[Callable, Tuple["Attribute", ...]]: 

544 """ 

545 

546 :param predicate: 

547 :return: 

548 :raises ValueError: 

549 :raises ValueError: 

550 """ 

551 from pyTooling.Attributes import Attribute 

552 

553 if predicate is None: 

554 predicate = Attribute 

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

556 for attribute in predicate: 

557 if not issubclass(attribute, Attribute): 

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

559 

560 predicate = tuple(predicate) 

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

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

563 

564 methodAttributePairs = {} 

565 for method in newClass.__methodsWithAttributes__: 

566 matchingAttributes = [] 

567 for attribute in method.__pyattr__: 

568 if isinstance(attribute, predicate): 

569 matchingAttributes.append(attribute) 

570 

571 if len(matchingAttributes) > 0: 

572 methodAttributePairs[method] = tuple(matchingAttributes) 

573 

574 return methodAttributePairs 

575 

576 newClass.GetMethodsWithAttributes = classmethod(GetMethodsWithAttributes) 

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

578 

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

580 # GetClassAtrributes -> list[attributes] / generator 

581 # MethodHasAttributes(predicate) -> bool 

582 # GetAttribute 

583 

584 return newClass 

585 

586 @classmethod 

587 def _findMethods( 

588 self, 

589 newClass: "ExtendedType", 

590 baseClasses: Tuple[type], 

591 members: Dict[str, Any] 

592 ) -> Tuple[List[MethodType], List[MethodType]]: 

593 """ 

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

595 

596 .. todo:: 

597 

598 Describe algorithm. 

599 

600 :param newClass: Newly created class instance. 

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

602 :param members: Members of the new class. 

603 :return: 

604 """ 

605 from pyTooling.Attributes import Attribute 

606 

607 # Embedded bind function due to circular dependencies. 

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

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

610 methodName = func.__name__ 

611 

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

613 setattr(instance, methodName, boundMethod) 

614 

615 return boundMethod 

616 

617 methods = [] 

618 methodsWithAttributes = [] 

619 attributeIndex = {} 

620 

621 for base in baseClasses: 

622 if hasattr(base, "__methodsWithAttributes__"): 

623 methodsWithAttributes.extend(base.__methodsWithAttributes__) 

624 

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

626 if isinstance(member, FunctionType): 

627 method = newClass.__dict__[memberName] 

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

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

630 else: 

631 setattr(method, "__classobj__", newClass) 

632 

633 def GetAttributes(inst: Any, predicate: Nullable[Type[Attribute]] = None) -> Tuple[Attribute, ...]: 

634 results = [] 

635 try: 

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

637 if isinstance(attribute, predicate): 

638 results.append(attribute) 

639 return tuple(results) 

640 except AttributeError: 

641 return tuple() 

642 

643 method.GetAttributes = bind(method, GetAttributes) 

644 methods.append(method) 

645 

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

647 # print(f" {member}") 

648 if "__pyattr__" in member.__dict__: 

649 attributes = member.__pyattr__ # type: List[Attribute] 

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

651 methodsWithAttributes.append(member) 

652 for attribute in attributes: 

653 attribute._functions.remove(method) 

654 attribute._methods.append(method) 

655 

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

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

658 attributeIndex[attribute] = [member] 

659 else: 

660 attributeIndex[attribute].append(member) 

661 # else: 

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

663 # else: 

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

665 return methods, methodsWithAttributes 

666 

667 @classmethod 

668 def _computeSlots( 

669 self, 

670 className: str, 

671 baseClasses: Tuple[type], 

672 members: Dict[str, Any], 

673 slots: bool, 

674 mixin: bool 

675 ) -> Tuple[Dict[str, Any], Dict[str, Any]]: 

676 """ 

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

678 

679 .. todo:: 

680 

681 Describe algorithm. 

682 

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

684 :param baseClasses: Tuple of base-classes. 

685 :param members: Dictionary of class members. 

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

687 :param mixin: True, if the class should behave as a mixin-class. 

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

689 """ 

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

691 slottedFields = [] 

692 classFields = {} 

693 objectFields = {} 

694 if slots or mixin: 

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

696 for baseClass in self._iterateBaseClasses(baseClasses): 

697 # Exclude object as a special case 

698 if baseClass is object or baseClass is Generic: 

699 continue 

700 

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

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

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

704 raise ex 

705 

706 # FIXME: should have a check for non-empty slots on secondary base-classes too 

707 

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

709 inheritedSlottedFields = {} 

710 if len(baseClasses) > 0: 

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

712 # Exclude object as a special case 

713 if base is object or base is Generic: 

714 continue 

715 

716 for annotation in base.__slots__: 

717 inheritedSlottedFields[annotation] = base 

718 

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

720 if "__annotations__" in members: 

721 # WORKAROUND: LEGACY SUPPORT Python <= 3.13 

722 # Accessing annotations was changed in Python 3.14. 

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

724 annotations: Dict[str, Any] = members.get("__annotations__", {}) 

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

726 from annotationlib import Format 

727 annotations: Dict[str, Any] = annotate(Format.VALUE) 

728 else: 

729 annotations = {} 

730 

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

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

733 cls = inheritedSlottedFields[fieldName] 

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

735 

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

737 # * copy field and initial value to classFields dictionary 

738 # * remove field from members 

739 if isinstance(typeAnnotation, _GenericAlias) and typeAnnotation.__origin__ is ClassVar and fieldName in members: 

740 classFields[fieldName] = members[fieldName] 

741 del members[fieldName] 

742 

743 # If an annotated field has an initial value 

744 # * copy field and initial value to objectFields dictionary 

745 # * remove field from members 

746 elif fieldName in members: 

747 slottedFields.append(fieldName) 

748 objectFields[fieldName] = members[fieldName] 

749 del members[fieldName] 

750 else: 

751 slottedFields.append(fieldName) 

752 

753 mixinSlots = self._aggregateMixinSlots(className, baseClasses) 

754 else: 

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

756 annotations: Dict[str, Any] = members.get("__annotations__", {}) 

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

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

759 # * copy field and initial value to classFields dictionary 

760 # * remove field from members 

761 if isinstance(typeAnnotation, _GenericAlias) and typeAnnotation.__origin__ is ClassVar and fieldName in members: 

762 classFields[fieldName] = members[fieldName] 

763 del members[fieldName] 

764 

765 # FIXME: search for fields without annotation 

766 if mixin: 

767 mixinSlots.extend(slottedFields) 

768 members["__slotted__"] = True 

769 members["__slots__"] = tuple() 

770 members["__allSlots__"] = set() 

771 members["__isMixin__"] = True 

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

773 elif slots: 

774 slottedFields.extend(mixinSlots) 

775 members["__slotted__"] = True 

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

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

778 members["__isMixin__"] = False 

779 members["__mixinSlots__"] = tuple() 

780 else: 

781 members["__slotted__"] = False 

782 # NO __slots__ 

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

784 members["__isMixin__"] = False 

785 members["__mixinSlots__"] = tuple() 

786 return classFields, objectFields 

787 

788 @classmethod 

789 def _aggregateMixinSlots(self, className: str, baseClasses: Tuple[type]) -> List[str]: 

790 """ 

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

792 

793 .. todo:: 

794 

795 Describe algorithm. 

796 

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

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

799 :returns: A list of slot names. 

800 """ 

801 mixinSlots = [] 

802 if len(baseClasses) > 0: 

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

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

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

806 primaryInharitancePath: Set[type] = set(inheritancePaths[0]) 

807 for typePath in inheritancePaths[1:]: 

808 for t in typePath: 

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

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

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

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

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

814 raise ex 

815 

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

817 # Ensure all base-classes are either constructed 

818 # * by meta-class ExtendedType, or 

819 # * use no slots, or 

820 # * are typing.Generic 

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

822 for baseClass in baseClasses: # type: ExtendedType 

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

824 pass 

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

826 mixinSlots.extend(baseClass.__mixinSlots__) 

827 elif hasattr(baseClass, "__mixinSlots__"): 

828 mixinSlots.extend(baseClass.__mixinSlots__) 

829 

830 return mixinSlots 

831 

832 @classmethod 

833 def _iterateBaseClasses(metacls, baseClasses: Tuple[type]) -> Generator[type, None, None]: 

834 """ 

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

836 

837 .. todo:: 

838 

839 Describe iteration order. 

840 

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

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

843 """ 

844 if len(baseClasses) == 0: 

845 return 

846 

847 visited: Set[type] = set() 

848 iteratorStack: List[Iterator[type]] = list() 

849 

850 for baseClass in baseClasses: 

851 yield baseClass 

852 visited.add(baseClass) 

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

854 

855 while True: 

856 try: 

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

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

859 yield base 

860 if len(base.__bases__) > 0: 

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

862 else: 

863 continue 

864 

865 except StopIteration: 

866 iteratorStack.pop() 

867 

868 if len(iteratorStack) == 0: 

869 break 

870 

871 @classmethod 

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

873 """ 

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

875 

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

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

878 

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

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

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

882 """ 

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

884 return 

885 

886 typeStack: List[type] = list() 

887 iteratorStack: List[Iterator[type]] = list() 

888 

889 for baseClass in baseClasses: 

890 typeStack.append(baseClass) 

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

892 

893 while True: 

894 try: 

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

896 typeStack.append(base) 

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

898 yield tuple(typeStack) 

899 typeStack.pop() 

900 else: 

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

902 

903 except StopIteration: 

904 typeStack.pop() 

905 iteratorStack.pop() 

906 

907 if len(typeStack) == 0: 

908 break 

909 

910 @classmethod 

911 def _checkForAbstractMethods(metacls, baseClasses: Tuple[type], members: Dict[str, Any]) -> Tuple[Dict[str, Callable], Dict[str, Any]]: 

912 """ 

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

914 

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

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

917 

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

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

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

921 """ 

922 abstractMethods = {} 

923 if baseClasses: 

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

925 for baseClass in baseClasses: 

926 if hasattr(baseClass, "__abstractMethods__"): 

927 abstractMethods.update(baseClass.__abstractMethods__) 

928 

929 for base in baseClasses: 

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

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

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

933 def outer(method): 

934 @wraps(method) 

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

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

937 

938 return inner 

939 

940 members[memberName] = outer(member) 

941 

942 # Check if methods are marked: 

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

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

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

946 if callable(member): 

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

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

949 abstractMethods[memberName] = member 

950 elif memberName in abstractMethods: 

951 del abstractMethods[memberName] 

952 

953 return abstractMethods, members 

954 

955 @classmethod 

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

957 """ 

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

959 

960 Only the first object creation initializes the object. 

961 

962 This implementation is threadsafe. 

963 

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

965 :param singleton: If ``True``, the class allows only a single instance to exist. 

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

967 """ 

968 if hasattr(newClass, "__isSingleton__"): 

969 singleton = newClass.__isSingleton__ 

970 

971 if singleton: 

972 oldnew = newClass.__new__ 

973 if hasattr(oldnew, "__singleton_wrapper__"): 

974 oldnew = oldnew.__wrapped__ 

975 

976 oldinit = newClass.__init__ 

977 if hasattr(oldinit, "__singleton_wrapper__"): 977 ↛ 978line 977 didn't jump to line 978 because the condition on line 977 was never true

978 oldinit = oldinit.__wrapped__ 

979 

980 @wraps(oldnew) 

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

982 with cls.__singletonInstanceCond__: 

983 if cls.__singletonInstanceCache__ is None: 

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

985 cls.__singletonInstanceCache__ = obj 

986 else: 

987 obj = cls.__singletonInstanceCache__ 

988 

989 return obj 

990 

991 @wraps(oldinit) 

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

993 cls = self.__class__ 

994 cv = cls.__singletonInstanceCond__ 

995 with cv: 

996 if cls.__singletonInstanceInit__: 

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

998 cls.__singletonInstanceInit__ = False 

999 cv.notify_all() 

1000 elif args or kwargs: 

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

1002 else: 

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

1004 cv.wait() 

1005 

1006 singleton_new.__singleton_wrapper__ = True 

1007 singleton_init.__singleton_wrapper__ = True 

1008 

1009 newClass.__new__ = singleton_new 

1010 newClass.__init__ = singleton_init 

1011 newClass.__singletonInstanceCond__ = Condition() 

1012 newClass.__singletonInstanceInit__ = True 

1013 newClass.__singletonInstanceCache__ = None 

1014 return True 

1015 

1016 return False 

1017 

1018 @classmethod 

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

1020 """ 

1021 If the class has abstract methods, replace the ``_new__`` method, so it raises an exception. 

1022 

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

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

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

1026 """ 

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

1028 if len(newClass.__abstractMethods__) > 0: 

1029 oldnew = newClass.__new__ 

1030 if hasattr(oldnew, "__raises_abstract_class_error__"): 

1031 oldnew = oldnew.__wrapped__ 

1032 

1033 @wraps(oldnew) 

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

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

1036 

1037 abstract_new.__raises_abstract_class_error__ = True 

1038 

1039 newClass.__new__ = abstract_new 

1040 return True 

1041 

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

1043 else: 

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

1045 try: 

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

1047 origNew = newClass.__new__.__wrapped__ 

1048 

1049 # WORKAROUND: __new__ checks tp_new and implements different behavior 

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

1051 if origNew is object.__new__: 1051 ↛ 1058line 1051 didn't jump to line 1058 because the condition on line 1051 was always true

1052 @wraps(object.__new__) 

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

1054 return object.__new__(inst) 

1055 

1056 newClass.__new__ = wrapped_new 

1057 else: 

1058 newClass.__new__ = origNew 

1059 elif newClass.__new__.__isSingleton__: 

1060 raise Exception(f"Found a singleton wrapper around an AbstractError raising method. This case is not handled yet.") 

1061 except AttributeError as ex: 

1062 # WORKAROUND: 

1063 # AttributeError.name was added in Python 3.10. For version <3.10 use a string contains operation. 

1064 try: 

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

1066 raise ex 

1067 except AttributeError: 

1068 if "__raises_abstract_class_error__" not in str(ex): 

1069 raise ex 

1070 

1071 return False 

1072 

1073 # Additional properties and methods on a class 

1074 @readonly 

1075 def HasClassAttributes(self) -> bool: 

1076 """ 

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

1078 

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

1080 """ 

1081 try: 

1082 return len(self.__pyattr__) > 0 

1083 except AttributeError: 

1084 return False 

1085 

1086 @readonly 

1087 def HasMethodAttributes(self) -> bool: 

1088 """ 

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

1090 

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

1092 """ 

1093 try: 

1094 return len(self.__methodsWithAttributes__) > 0 

1095 except AttributeError: 

1096 return False 

1097 

1098 

1099@export 

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

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