Coverage for pyTooling/Decorators/__init__.py: 91%
90 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-25 12:33 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-25 12:33 +0000
1# ==================================================================================================================== #
2# _____ _ _ ____ _ #
3# _ __ _ |_ _|__ ___ | (_)_ __ __ _ | _ \ ___ ___ ___ _ __ __ _| |_ ___ _ __ ___ #
4# | '_ \| | | || |/ _ \ / _ \| | | '_ \ / _` | | | | |/ _ \/ __/ _ \| '__/ _` | __/ _ \| '__/ __| #
5# | |_) | |_| || | (_) | (_) | | | | | | (_| |_| |_| | __/ (_| (_) | | | (_| | || (_) | | \__ \ #
6# | .__/ \__, ||_|\___/ \___/|_|_|_| |_|\__, (_)____/ \___|\___\___/|_| \__,_|\__\___/|_| |___/ #
7# |_| |___/ |___/ #
8# ==================================================================================================================== #
9# Authors: #
10# Patrick Lehmann #
11# #
12# License: #
13# ==================================================================================================================== #
14# Copyright 2017-2026 Patrick Lehmann - Bötzingen, Germany #
15# #
16# Licensed under the Apache License, Version 2.0 (the "License"); #
17# you may not use this file except in compliance with the License. #
18# You may obtain a copy of the License at #
19# #
20# http://www.apache.org/licenses/LICENSE-2.0 #
21# #
22# Unless required by applicable law or agreed to in writing, software #
23# distributed under the License is distributed on an "AS IS" BASIS, #
24# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. #
25# See the License for the specific language governing permissions and #
26# limitations under the License. #
27# #
28# SPDX-License-Identifier: Apache-2.0 #
29# ==================================================================================================================== #
30#
31"""Decorators controlling visibility of entities in a Python module.
33.. hint::
35 See :ref:`high-level help <DECO>` for explanations and usage examples.
37.. seealso::
39 :mod:`pyTooling.MetaClasses`
40 |rarr| The meta-class offering the same features as class options.
41 :mod:`pyTooling.Attributes`
42 |rarr| Attributes, which mark an entity instead of modifying it.
43"""
44from __future__ import annotations
46from enum import Enum, unique
47from functools import wraps
48from sys import modules
49from typing import TYPE_CHECKING, Any, Union, TypeVar, Callable, Generic, NoReturn, ParamSpec, overload, \
50 Optional as Nullable
52__all__ = ["export", "Param", "RetType", "Func", "T"]
55# See https://stackoverflow.com/questions/47060133/python-3-type-hinting-for-decorator
56Param = ParamSpec("Param") #: A parameter specification for function or method
57RetType = TypeVar("RetType") #: Type variable for a return type
58Func = Callable[Param, RetType] #: Type specification for a function
61T = TypeVar("T", bound=Union[type, Callable[..., Any]]) #: A type variable for classes or functions.
62C = TypeVar("C", bound=Callable[..., Any]) #: A type variable for functions or methods.
65def export(entity: T) -> T:
66 """
67 Register the given function or class as publicly accessible in a module.
69 Creates or updates the ``__all__`` attribute in the module in which the decorated entity is defined to include the
70 name of the decorated entity.
72 +---------------------------------------------+------------------------------------------------+
73 | ``to_export.py`` | ``another_file.py`` |
74 +=============================================+================================================+
75 | .. code-block:: python | .. code-block:: python |
76 | | |
77 | from pyTooling.Decorators import export | from .to_export import * |
78 | | |
79 | @export | |
80 | def exported(): | # 'exported' will be listed in __all__ |
81 | pass | assert "exported" in globals() |
82 | | |
83 | def not_exported(): | # 'not_exported' won't be listed in __all__ |
84 | pass | assert "not_exported" not in globals() |
85 | | |
86 +---------------------------------------------+------------------------------------------------+
88 :param entity: The function or class to include in `__all__`.
89 :returns: The unmodified function or class.
90 :raises AttributeError: If parameter ``entity`` has no ``__module__`` member.
91 :raises TypeError: If parameter ``entity`` is not a top-level entity in a module.
92 :raises TypeError: If parameter ``entity`` has no ``__name__``.
93 :raises ValueError: If the decorated entity has no ``__module__`` attribute, so it can't be added to ``__all__``.
94 """
95 # * Based on an idea by Duncan Booth:
96 # http://groups.google.com/group/comp.lang.python/msg/11cbb03e09611b8a
97 # * Improved via a suggestion by Dave Angel:
98 # http://groups.google.com/group/comp.lang.python/msg/3d400fb22d8a42e1
100 if not hasattr(entity, "__module__"): 100 ↛ 101line 100 didn't jump to line 101 because the condition on line 100 was never true
101 raise AttributeError(f"{entity} has no __module__ attribute. Please ensure it is a top-level function or class reference defined in a module.")
103 if hasattr(entity, "__qualname__"): 103 ↛ 107line 103 didn't jump to line 107 because the condition on line 103 was always true
104 if any(i in entity.__qualname__ for i in (".", "<locals>", "<lambda>")):
105 raise TypeError(f"Only named top-level functions and classes may be exported, not {entity}")
107 if not hasattr(entity, "__name__") or entity.__name__ == "<lambda>": 107 ↛ 108line 107 didn't jump to line 108 because the condition on line 107 was never true
108 raise TypeError(f"Entity must be a named top-level function or class, not {entity.__class__}")
110 try:
111 module = modules[entity.__module__]
112 except KeyError:
113 raise ValueError(f"Module {entity.__module__} is not present in sys.modules. Please ensure it is in the import path before calling export().")
115 if hasattr(module, "__all__"):
116 if entity.__name__ not in module.__all__: # type: ignore 116 ↛ 121line 116 didn't jump to line 121 because the condition on line 116 was always true
117 module.__all__.append(entity.__name__) # type: ignore
118 else:
119 module.__all__ = [entity.__name__] # type: ignore
121 return entity
124@export
125def notimplemented(message: str) -> Callable[..., Any]:
126 """
127 Mark a method as *not implemented* and replace the implementation with a new method raising a :exc:`NotImplementedError`.
129 The original method is stored in ``<method>.__wrapped__`` and it's doc-string is copied to the replacing method. In
130 additional the field ``<method>.__notImplemented__`` is added.
132 .. admonition:: ``example.py``
134 .. code-block:: python
136 class Data:
137 @notimplemented
138 def method(self) -> bool:
139 '''This method needs to be implemented'''
140 return True
142 :param message: Text of the :exc:`NotImplementedError` raised by the replacement method.
143 :returns: Decorator function that replaces the decorated method.
145 .. seealso::
147 :deco:`~pyTooling.MetaClasses.abstractmethod`
148 |rarr| Mark a method as *abstract* and raise a :exc:`NotImplementedError` when called.
149 :deco:`~pyTooling.MetaClasses.mustoverride`
150 |rarr| Mark a method as *mustoverride* (minimal implementation, but can be called).
151 """
153 def decorator(method: C) -> C:
154 """
155 Decorator function, which replaces the decorated method by one raising a :exc:`NotImplementedError`.
157 :param method: Method to be replaced.
158 :returns: Replacement method, carrying the field ``__notImplemented__``.
159 """
160 @wraps(method)
161 def func(*_: Any, **__: Any) -> NoReturn:
162 """
163 Replacement method, which raises a :exc:`NotImplementedError` when called.
165 :raises NotImplementedError: Always, with the message given to :deco:`notimplemented`.
166 """
167 raise NotImplementedError(message)
169 func.__notImplemented__ = True
170 return func
172 return decorator
175_ReturnType = TypeVar("_ReturnType")
176"""A type variable for the value a read-only property hands out."""
179@export
180class readonly(property, Generic[_ReturnType]):
181 """
182 Marks a property as *read-only*.
184 The doc-string is taken from the getter-method, like :class:`property` does.
186 A plain :class:`property` hands out ``<property>.setter`` and ``<property>.deleter``, so a property declared as
187 read-only could be made writable again further down the class body. Both methods therefore raise an
188 :exc:`AttributeError` instead.
190 .. seealso::
192 :class:`property`
193 A decorator to convert getter, setter and deleter methods into a property applying the descriptor protocol.
194 """
196 fget: Callable[[Any], _ReturnType] #: The getter-method; a read-only property is always constructed from one.
198 def __init__(self, fget: Callable[[Any], _ReturnType], doc: Nullable[str] = None) -> None:
199 """
200 Create a read-only property from a getter-method.
202 :class:`property` accepts a setter and a deleter here as well; this class does not, because it exists to
203 reject them. Narrowing the signature to the getter is also what binds the type variable, so that reading the
204 property hands out the getter's return type instead of :data:`~typing.Any`.
206 :param fget: The getter-method the property is constructed from.
207 :param doc: Optional, doc-string of the property. If ``None``, the getter-method's doc-string is used.
208 """
209 super().__init__(fget, None, None, doc)
211 def getter(self, fget: Callable[[Any], _ReturnType], /) -> readonly[_ReturnType]:
212 """
213 Derive a read-only property with another getter-method from this one.
215 :class:`property` implements this by reconstructing itself as ``type(self)(fget, fset, fdel, doc)``, which is
216 the only reason a setter and a deleter would have to be accepted by :meth:`__init__`. Constructing the
217 property here instead keeps that signature down to what a read-only property actually has.
219 :param fget: The getter-method of the derived property.
220 :returns: A new read-only property using the given getter-method, and its doc-string.
221 """
222 return type(self)(fget)
224 if TYPE_CHECKING: # pragma: no cover - only type checkers read these declarations
225 @overload
226 def __get__(self, instance: None, owner: type, /) -> readonly[_ReturnType]:
227 ...
229 @overload
230 def __get__(self, instance: Any, owner: Nullable[type] = None, /) -> _ReturnType:
231 ...
233 def __get__(self, instance: Any, owner: Nullable[type] = None, /) -> Union[readonly[_ReturnType], _ReturnType]:
234 """
235 Return the value of the property, or the property itself when it is read from the class.
237 Declaring this - :class:`property` implements it already - is what tells a type checker that the value has
238 the getter's return type. Without it, every read of a ``@readonly`` property is :data:`~typing.Any`, and that
239 spreads: a comparison of two such values, or a method returning one, becomes ``Any`` as well.
241 It is declared for type checkers only. At runtime, :class:`property`'s own ``__get__`` reads the value.
243 :param instance: The object the property is read from, or ``None`` when it is read from the class.
244 :param owner: Optional, the class the property is defined in.
245 :returns: The value the getter returns, or this property when read from the class.
246 """
247 return super().__get__(instance, owner) # type: ignore[no-any-return]
249 def setter(self, fset: Callable[..., Any]) -> NoReturn:
250 """
251 Reject attaching a setter to a read-only property.
253 :param fset: The setter-method that was to be attached.
254 :raises AttributeError: Always, because a read-only property can't have a setter. |br|
255 Use :deco:`property` instead of :deco:`readonly`, if the property should be writable.
256 """
257 ex = AttributeError(f"Property '{self.fget.__name__}' is read-only, so it can't have a setter.")
258 ex.add_note("Use '@property' instead of '@readonly', if the property should be writable.")
259 raise ex
261 def deleter(self, fdel: Callable[..., Any]) -> NoReturn:
262 """
263 Reject attaching a deleter to a read-only property.
265 :param fdel: The deleter-method that was to be attached.
266 :raises AttributeError: Always, because a read-only property can't have a deleter. |br|
267 Use :deco:`property` instead of :deco:`readonly`, if the property should be deletable.
268 """
269 ex = AttributeError(f"Property '{self.fget.__name__}' is read-only, so it can't have a deleter.")
270 ex.add_note("Use '@property' instead of '@readonly', if the property should be deletable.")
271 raise ex
274@export
275@unique
276class DocStringMergeStrategy(Enum):
277 """
278 Strategy :func:`InheritDocString` follows when it combines the base-class' and the derived entity's doc-strings.
280 A doc-string's **summary** is its first paragraph - the text up to the first blank line. Whatever follows is its
281 **body**. A strategy naming *WithoutSummary* drops the summary of the doc-string it is applied to, because the other
282 doc-string already provides one.
284 .. seealso::
286 :deco:`InheritDocString`
287 |rarr| Copy or merge a base-class' doc-string into the derived entity.
288 """
290 SummaryOnly = 0 #: The base-class' summary, then the derived entity's doc-string.
291 BaseLast = 1 #: The derived entity's doc-string, then the base-class' doc-string.
292 BaseLastWithoutSummary = 2 #: The derived entity's doc-string, then the base-class' body.
293 BaseFirst = 3 #: The base-class' doc-string, then the derived entity's doc-string.
294 BaseInBetweenWithoutSummary = 4 #: The derived entity's summary, the base-class' body, then the derived body.
297@export
298def InheritDocString(
299 baseClass: type,
300 strategy: DocStringMergeStrategy = DocStringMergeStrategy.BaseLast,
301 prefix: str = "",
302 interfix: str = "\n\n",
303 postfix: str = ""
304) -> Callable[[Func | type], Func | type]:
305 """
306 Merge the doc-string from given base-class into the class or method this decorator is applied to.
308 The decorated entity keeps what is specific to it and inherits the rest, so a description doesn't have to be
309 repeated. Which parts are taken from which doc-string, and in which order they are arranged, is selected with
310 ``strategy``; by default the base-class' doc-string is appended to the derived entity's doc-string
311 (:attr:`~DocStringMergeStrategy.BaseLast`).
313 A derived entity without a doc-string of its own inherits the base-class' doc-string unchanged - that is the plain
314 copy this decorator started out as, and it needs no special strategy.
316 Both doc-strings are dedented with :func:`inspect.cleandoc` before they are combined. This matters for Python
317 versions before 3.13, where the compiler does not strip a doc-string's indentation: combining a tab-indented
318 base-class doc-string with a space-indented derived doc-string would otherwise leave the first part indented relative
319 to the second, which renders as a block quote.
321 The result is assembled as ``prefix + part + interfix + part ... + postfix``. Parts that are empty - a missing
322 doc-string, or a body the strategy asked for that doesn't exist - are omitted together with their ``interfix``. If
323 nothing remains, the decorated entity's doc-string is left unchanged.
325 .. admonition:: ``example.py``
327 .. code-block:: python
329 from pyTooling.Decorators import InheritDocString, DocStringMergeStrategy
331 class Class1:
332 def method(self):
333 '''Method's doc-string.'''
335 class Class2(Class1):
336 @InheritDocString(Class1)
337 def method(self):
338 super().method()
340 .. admonition:: ``merging.py``
342 .. code-block:: python
344 @InheritDocString(
345 Class1,
346 DocStringMergeStrategy.BaseLastWithoutSummary,
347 interfix="\\n\\n**Inherited:**\\n\\n"
348 )
349 class Class2(Class1):
350 '''What is specific to Class2.'''
352 :param baseClass: Base-class to copy the doc-string from to the class or method being decorated.
353 :param strategy: Optional, which parts of both doc-strings are used and in which order they are arranged; defaults
354 to :attr:`~DocStringMergeStrategy.BaseLast`.
355 :param prefix: Optional, text inserted in front of the merged doc-string; defaults to an empty string.
356 :param interfix: Optional, text inserted between the parts; defaults to a blank line (``"\\n\\n"``).
357 :param postfix: Optional, text appended to the merged doc-string; defaults to an empty string.
358 :returns: Decorator function that merges the doc-string.
360 .. seealso::
362 :class:`DocStringMergeStrategy`
363 |rarr| Selects which parts of both doc-strings are merged, and in which order.
364 """
365 def decorator(param: Func | type) -> Func | type:
366 """
367 Decorator function, which merges the doc-string from base-class' method into method ``m``.
369 :param param: Method to which the doc-string from a method in ``baseClass`` (with same className) should be merged.
370 :returns: Same method, but with overwritten doc-string field (``__doc__``).
371 """
372 from inspect import cleandoc
373 # Imported here, because 'pyTooling.Documentation' imports 'export' from this module.
374 from pyTooling.Documentation import splitDocString
376 if isinstance(param, type):
377 baseDoc = baseClass.__doc__
378 elif callable(param): 378 ↛ 381line 378 didn't jump to line 381 because the condition on line 378 was always true
379 baseDoc = getattr(baseClass, param.__name__).__doc__
380 else:
381 return param
383 derivedDoc = param.__doc__
384 baseSummary, baseBody = splitDocString(baseDoc, maxSummaryLength=0) # Disable summary length check
385 derivedSummary, derivedBody = splitDocString(derivedDoc, maxSummaryLength=0) # Disable summary length check
386 base = cleandoc(baseDoc) if baseDoc is not None else ""
387 derived = cleandoc(derivedDoc) if derivedDoc is not None else ""
389 parts: tuple[str, ...]
390 if strategy is DocStringMergeStrategy.SummaryOnly:
391 parts = (baseSummary, derived)
392 elif strategy is DocStringMergeStrategy.BaseLast:
393 parts = (derived, base)
394 elif strategy is DocStringMergeStrategy.BaseLastWithoutSummary:
395 parts = (derived, baseBody)
396 elif strategy is DocStringMergeStrategy.BaseFirst:
397 parts = (base, derived)
398 else:
399 parts = (derivedSummary, baseBody, derivedBody)
401 merged = interfix.join(part for part in parts if part != "")
402 if merged != "":
403 param.__doc__ = f"{prefix}{merged}{postfix}"
405 return param
407 return decorator