Coverage for pyTooling/Decorators/__init__.py: 92%
98 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-07 05:59 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-07 05:59 +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 inspect import cleandoc
49from sys import modules
50from types import FunctionType
51from typing import Any, Union, TypeVar, Callable, Generic, NoReturn, ParamSpec, overload
52from typing import Optional as Nullable
54__all__ = ["export", "Param", "RetType", "Func", "T"]
57# See https://stackoverflow.com/questions/47060133/python-3-type-hinting-for-decorator
58Param = ParamSpec("Param") #: A parameter specification for function or method
59RetType = TypeVar("RetType") #: Type variable for a return type
60Func = Callable[Param, RetType] #: Type specification for a function
63T = TypeVar("T", bound=Union[type, FunctionType]) #: A type variable for a classes or functions.
64C = TypeVar("C", bound=Callable[..., Any]) #: A type variable for functions or methods.
67def export(entity: T) -> T:
68 """
69 Register the given function or class as publicly accessible in a module.
71 Creates or updates the ``__all__`` attribute in the module in which the decorated entity is defined to include the
72 name of the decorated entity.
74 +---------------------------------------------+------------------------------------------------+
75 | ``to_export.py`` | ``another_file.py`` |
76 +=============================================+================================================+
77 | .. code-block:: python | .. code-block:: python |
78 | | |
79 | from pyTooling.Decorators import export | from .to_export import * |
80 | | |
81 | @export | |
82 | def exported(): | # 'exported' will be listed in __all__ |
83 | pass | assert "exported" in globals() |
84 | | |
85 | def not_exported(): | # 'not_exported' won't be listed in __all__ |
86 | pass | assert "not_exported" not in globals() |
87 | | |
88 +---------------------------------------------+------------------------------------------------+
90 :param entity: The function or class to include in `__all__`.
91 :returns: The unmodified function or class.
92 :raises AttributeError: If parameter ``entity`` has no ``__module__`` member.
93 :raises TypeError: If parameter ``entity`` is not a top-level entity in a module.
94 :raises TypeError: If parameter ``entity`` has no ``__name__``.
95 :raises ValueError: If the decorated entity has no ``__module__`` attribute, so it can't be added to ``__all__``.
96 """
97 # * Based on an idea by Duncan Booth:
98 # http://groups.google.com/group/comp.lang.python/msg/11cbb03e09611b8a
99 # * Improved via a suggestion by Dave Angel:
100 # http://groups.google.com/group/comp.lang.python/msg/3d400fb22d8a42e1
102 if not hasattr(entity, "__module__"): 102 ↛ 103line 102 didn't jump to line 103 because the condition on line 102 was never true
103 raise AttributeError(f"{entity} has no __module__ attribute. Please ensure it is a top-level function or class reference defined in a module.")
105 if hasattr(entity, "__qualname__"): 105 ↛ 109line 105 didn't jump to line 109 because the condition on line 105 was always true
106 if any(i in entity.__qualname__ for i in (".", "<locals>", "<lambda>")):
107 raise TypeError(f"Only named top-level functions and classes may be exported, not {entity}")
109 if not hasattr(entity, "__name__") or entity.__name__ == "<lambda>": 109 ↛ 110line 109 didn't jump to line 110 because the condition on line 109 was never true
110 raise TypeError(f"Entity must be a named top-level function or class, not {entity.__class__}")
112 try:
113 module = modules[entity.__module__]
114 except KeyError:
115 raise ValueError(f"Module {entity.__module__} is not present in sys.modules. Please ensure it is in the import path before calling export().")
117 if hasattr(module, "__all__"):
118 if entity.__name__ not in module.__all__: # type: ignore 118 ↛ 123line 118 didn't jump to line 123 because the condition on line 118 was always true
119 module.__all__.append(entity.__name__) # type: ignore
120 else:
121 module.__all__ = [entity.__name__] # type: ignore
123 return entity
126@export
127def notimplemented(message: str) -> Callable[..., Any]:
128 """
129 Mark a method as *not implemented* and replace the implementation with a new method raising a :exc:`NotImplementedError`.
131 The original method is stored in ``<method>.__wrapped__`` and it's doc-string is copied to the replacing method. In
132 additional the field ``<method>.__notImplemented__`` is added.
134 .. admonition:: ``example.py``
136 .. code-block:: python
138 class Data:
139 @notimplemented
140 def method(self) -> bool:
141 '''This method needs to be implemented'''
142 return True
144 :param message: Text of the :exc:`NotImplementedError` raised by the replacement method.
145 :returns: Decorator function that replaces the decorated method.
147 .. seealso::
149 :deco:`~pyTooling.MetaClasses.abstractmethod`
150 |rarr| Mark a method as *abstract* and raise a :exc:`NotImplementedError` when called.
151 :deco:`~pyTooling.MetaClasses.mustoverride`
152 |rarr| Mark a method as *mustoverride* (minimal implementation, but can be called).
153 """
155 def decorator(method: C) -> C:
156 """
157 Decorator function, which replaces the decorated method by one raising a :exc:`NotImplementedError`.
159 :param method: Method to be replaced.
160 :returns: Replacement method, carrying the field ``__notImplemented__``.
161 """
162 @wraps(method)
163 def func(*_: Any, **__: Any) -> NoReturn:
164 """
165 Replacement method, which raises a :exc:`NotImplementedError` when called.
167 :raises NotImplementedError: Always, with the message given to :deco:`notimplemented`.
168 """
169 raise NotImplementedError(message)
171 func.__notImplemented__ = True
172 return func
174 return decorator
177_ReturnType = TypeVar("_ReturnType")
178"""A type variable for the value a read-only property hands out."""
181@export
182class readonly(property, Generic[_ReturnType]):
183 """
184 Marks a property as *read-only*.
186 The doc-string is taken from the getter-method, like :class:`property` does.
188 A plain :class:`property` hands out ``<property>.setter`` and ``<property>.deleter``, so a property declared as
189 read-only could be made writable again further down the class body. Both methods therefore raise an
190 :exc:`AttributeError` instead.
192 .. seealso::
194 :class:`property`
195 A decorator to convert getter, setter and deleter methods into a property applying the descriptor protocol.
196 """
198 fget: Callable[[Any], _ReturnType] #: The getter-method; a read-only property is always constructed from one.
200 def __init__(self, fget: Callable[[Any], _ReturnType], doc: Nullable[str] = None) -> None:
201 """
202 Create a read-only property from a getter-method.
204 :class:`property` accepts a setter and a deleter here as well; this class does not, because it exists to
205 reject them. Narrowing the signature to the getter is also what binds the type variable, so that reading the
206 property hands out the getter's return type instead of :data:`~typing.Any`.
208 :param fget: The getter-method the property is constructed from.
209 :param doc: Optional, doc-string of the property. If ``None``, the getter-method's doc-string is used.
210 """
211 super().__init__(fget, None, None, doc)
213 def getter(self, fget: Callable[[Any], _ReturnType], /) -> readonly[_ReturnType]:
214 """
215 Derive a read-only property with another getter-method from this one.
217 :class:`property` implements this by reconstructing itself as ``type(self)(fget, fset, fdel, doc)``, which is
218 the only reason a setter and a deleter would have to be accepted by :meth:`__init__`. Constructing the
219 property here instead keeps that signature down to what a read-only property actually has.
221 :param fget: The getter-method of the derived property.
222 :returns: A new read-only property using the given getter-method, and its doc-string.
223 """
224 return type(self)(fget)
226 @overload
227 def __get__(self, instance: None, owner: type, /) -> readonly[_ReturnType]:
228 ... # pragma: no cover - an overload carries no implementation
230 @overload
231 def __get__(self, instance: Any, owner: Nullable[type] = None, /) -> _ReturnType:
232 ... # pragma: no cover - an overload carries no implementation
234 def __get__(self, instance: Any, owner: Nullable[type] = None, /) -> Union[readonly[_ReturnType], _ReturnType]:
235 """
236 Return the value of the property, or the property itself when it is read from the class.
238 Declaring this - :class:`property` implements it already - is what tells a type checker that the value has
239 the getter's return type. Without it, every read of a ``@readonly`` property is :data:`~typing.Any`, and that
240 spreads: a comparison of two such values, or a method returning one, becomes ``Any`` as well.
242 :param instance: The object the property is read from, or ``None`` when it is read from the class.
243 :param owner: Optional, the class the property is defined in.
244 :returns: The value the getter returns, or this property when read from the class.
245 """
246 return super().__get__(instance, owner) # type: ignore[no-any-return]
248 def setter(self, fset: Callable[..., Any]) -> NoReturn:
249 """
250 Reject attaching a setter to a read-only property.
252 :param fset: The setter-method that was to be attached.
253 :raises AttributeError: Always, because a read-only property can't have a setter. |br|
254 Use :deco:`property` instead of :deco:`readonly`, if the property should be writable.
255 """
256 ex = AttributeError(f"Property '{self.fget.__name__}' is read-only, so it can't have a setter.")
257 ex.add_note("Use '@property' instead of '@readonly', if the property should be writable.")
258 raise ex
260 def deleter(self, fdel: Callable[..., Any]) -> NoReturn:
261 """
262 Reject attaching a deleter to a read-only property.
264 :param fdel: The deleter-method that was to be attached.
265 :raises AttributeError: Always, because a read-only property can't have a deleter. |br|
266 Use :deco:`property` instead of :deco:`readonly`, if the property should be deletable.
267 """
268 ex = AttributeError(f"Property '{self.fget.__name__}' is read-only, so it can't have a deleter.")
269 ex.add_note("Use '@property' instead of '@readonly', if the property should be deletable.")
270 raise ex
273@export
274@unique
275class DocStringMergeStrategy(Enum):
276 """
277 Strategy :func:`InheritDocString` follows when it combines the base-class' and the derived entity's doc-strings.
279 A doc-string's **summary** is its first paragraph - the text up to the first blank line. Whatever follows is its
280 **body**. A strategy naming *WithoutSummary* drops the summary of the doc-string it is applied to, because the other
281 doc-string already provides one.
283 .. seealso::
285 :deco:`InheritDocString`
286 |rarr| Copy or merge a base-class' doc-string into the derived entity.
287 """
289 SummaryOnly = 0 #: The base-class' summary, then the derived entity's doc-string.
290 BaseLast = 1 #: The derived entity's doc-string, then the base-class' doc-string.
291 BaseLastWithoutSummary = 2 #: The derived entity's doc-string, then the base-class' body.
292 BaseFirst = 3 #: The base-class' doc-string, then the derived entity's doc-string.
293 BaseInBetweenWithoutSummary = 4 #: The derived entity's summary, the base-class' body, then the derived body.
296@export
297def InheritDocString(
298 baseClass: type,
299 strategy: DocStringMergeStrategy = DocStringMergeStrategy.BaseLast,
300 prefix: str = "",
301 interfix: str = "\n\n",
302 postfix: str = ""
303) -> Callable[[Func | type], Func | type]:
304 """
305 Merge the doc-string from given base-class into the class or method this decorator is applied to.
307 The decorated entity keeps what is specific to it and inherits the rest, so a description doesn't have to be
308 repeated. Which parts are taken from which doc-string, and in which order they are arranged, is selected with
309 ``strategy``; by default the base-class' doc-string is appended to the derived entity's doc-string
310 (:attr:`~DocStringMergeStrategy.BaseLast`).
312 A derived entity without a doc-string of its own inherits the base-class' doc-string unchanged - that is the plain
313 copy this decorator started out as, and it needs no special strategy.
315 Both doc-strings are dedented with :func:`inspect.cleandoc` before they are combined. This matters for Python
316 versions before 3.13, where the compiler does not strip a doc-string's indentation: combining a tab-indented
317 base-class doc-string with a space-indented derived doc-string would otherwise leave the first part indented relative
318 to the second, which renders as a block quote.
320 The result is assembled as ``prefix + part + interfix + part ... + postfix``. Parts that are empty - a missing
321 doc-string, or a body the strategy asked for that doesn't exist - are omitted together with their ``interfix``. If
322 nothing remains, the decorated entity's doc-string is left unchanged.
324 .. admonition:: ``example.py``
326 .. code-block:: python
328 from pyTooling.Decorators import InheritDocString, DocStringMergeStrategy
330 class Class1:
331 def method(self):
332 '''Method's doc-string.'''
334 class Class2(Class1):
335 @InheritDocString(Class1)
336 def method(self):
337 super().method()
339 .. admonition:: ``merging.py``
341 .. code-block:: python
343 @InheritDocString(
344 Class1,
345 DocStringMergeStrategy.BaseLastWithoutSummary,
346 interfix="\\n\\n**Inherited:**\\n\\n"
347 )
348 class Class2(Class1):
349 '''What is specific to Class2.'''
351 :param baseClass: Base-class to copy the doc-string from to the class or method being decorated.
352 :param strategy: Optional, which parts of both doc-strings are used and in which order they are arranged; defaults
353 to :attr:`~DocStringMergeStrategy.BaseLast`.
354 :param prefix: Optional, text inserted in front of the merged doc-string; defaults to an empty string.
355 :param interfix: Optional, text inserted between the parts; defaults to a blank line (``"\\n\\n"``).
356 :param postfix: Optional, text appended to the merged doc-string; defaults to an empty string.
357 :returns: Decorator function that merges the doc-string.
359 .. seealso::
361 :class:`DocStringMergeStrategy`
362 |rarr| Selects which parts of both doc-strings are merged, and in which order.
363 """
364 def decorator(param: Func | type) -> Func | type:
365 """
366 Decorator function, which merges the doc-string from base-class' method into method ``m``.
368 :param param: Method to which the doc-string from a method in ``baseClass`` (with same className) should be merged.
369 :returns: Same method, but with overwritten doc-string field (``__doc__``).
370 """
371 # Imported here, because 'pyTooling.Documentation' imports 'export' from this module.
372 from pyTooling.Documentation import splitDocString
374 if isinstance(param, type):
375 baseDoc = baseClass.__doc__
376 elif callable(param): 376 ↛ 379line 376 didn't jump to line 379 because the condition on line 376 was always true
377 baseDoc = getattr(baseClass, param.__name__).__doc__
378 else:
379 return param
381 derivedDoc = param.__doc__
382 baseSummary, baseBody = splitDocString(baseDoc, maxSummaryLength=0) # Disable summary length check
383 derivedSummary, derivedBody = splitDocString(derivedDoc, maxSummaryLength=0) # Disable summary length check
384 base = cleandoc(baseDoc) if baseDoc is not None else ""
385 derived = cleandoc(derivedDoc) if derivedDoc is not None else ""
387 parts: tuple[str, ...]
388 if strategy is DocStringMergeStrategy.SummaryOnly:
389 parts = (baseSummary, derived)
390 elif strategy is DocStringMergeStrategy.BaseLast:
391 parts = (derived, base)
392 elif strategy is DocStringMergeStrategy.BaseLastWithoutSummary:
393 parts = (derived, baseBody)
394 elif strategy is DocStringMergeStrategy.BaseFirst:
395 parts = (base, derived)
396 else:
397 parts = (derivedSummary, baseBody, derivedBody)
399 merged = interfix.join(part for part in parts if part != "")
400 if merged != "":
401 param.__doc__ = f"{prefix}{merged}{postfix}"
403 return param
405 return decorator