Coverage for pyTooling/Decorators/__init__.py: 91%
88 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-22 21:29 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-22 21:29 +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(f"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(f"Use '@property' instead of '@readonly', if the property should be deletable.")
270 raise ex
273@export
274@unique
275class DocStringMergeOrder(Enum):
276 """
277 Order in which :func:`InheritDocString` arranges the base-class' and the derived entity's doc-strings.
279 .. seealso::
281 :deco:`InheritDocString`
282 |rarr| Copy or merge a base-class' doc-string into the derived entity.
283 """
285 BaseFirst = 0 #: The base-class' doc-string comes first, the derived entity's doc-string second.
286 DerivedFirst = 1 #: The derived entity's doc-string comes first, the base-class' doc-string second.
289@export
290def InheritDocString(
291 baseClass: type,
292 merge: bool = False,
293 order: DocStringMergeOrder = DocStringMergeOrder.BaseFirst,
294 prefix: str = "",
295 interfix: str = "\n\n",
296 postfix: str = ""
297) -> Callable[[Func | type], Func | type]:
298 """
299 Copy the doc-string from given base-class to the class or method this decorator is applied to.
301 By default, the base-class' doc-string *replaces* the doc-string of the decorated class or method. If ``merge`` is
302 enabled, both doc-strings are combined instead, so a derived entity can add what is specific to it without repeating
303 the description it inherits.
305 When merging, both doc-strings are dedented with :func:`inspect.cleandoc` before they are combined. This matters for
306 Python versions before 3.13, where the compiler does not strip a doc-string's indentation: combining a tab-indented
307 base-class doc-string with a space-indented derived doc-string would otherwise leave the first part indented relative
308 to the second, which renders as a block quote.
310 The merged doc-string is assembled as ``prefix + first + interfix + second + postfix``. If either doc-string is
311 missing, that part and the ``interfix`` are omitted. If both are missing, the doc-string is left unchanged.
313 .. admonition:: ``example.py``
315 .. code-block:: python
317 from pyTooling.Decorators import InheritDocString, DocStringMergeOrder
319 class Class1:
320 def method(self):
321 '''Method's doc-string.'''
323 class Class2(Class1):
324 @InheritDocString(Class1)
325 def method(self):
326 super().method()
328 .. admonition:: ``merging.py``
330 .. code-block:: python
332 @InheritDocString(
333 Class1,
334 merge=True,
335 order=DocStringMergeOrder.DerivedFirst,
336 interfix="\\n\\n**Inherited:**\\n\\n"
337 )
338 class Class2(Class1):
339 '''What is specific to Class2.'''
341 :param baseClass: Base-class to copy the doc-string from to the class or method being decorated.
342 :param merge: Optional, if ``True``, combine both doc-strings instead of replacing the derived one; defaults to
343 ``False``.
344 :param order: Optional, order in which both doc-strings are arranged when merging; defaults to
345 :attr:`~DocStringMergeOrder.BaseFirst`.
346 :param prefix: Optional, text inserted in front of the merged doc-string; defaults to an empty string.
347 :param interfix: Optional, text inserted between both doc-strings; defaults to a blank line (``"\\n\\n"``).
348 :param postfix: Optional, text appended to the merged doc-string; defaults to an empty string.
349 :returns: Decorator function that copies or merges the doc-string.
351 .. seealso::
353 :class:`DocStringMergeOrder`
354 |rarr| Selects which doc-string comes first when both are merged.
355 """
356 def decorator(param: Func | type) -> Func | type:
357 """
358 Decorator function, which copies or merges the doc-string from base-class' method to method ``m``.
360 :param param: Method to which the doc-string from a method in ``baseClass`` (with same className) should be copied.
361 :returns: Same method, but with overwritten doc-string field (``__doc__``).
362 """
363 if isinstance(param, type):
364 baseDoc = baseClass.__doc__
365 elif callable(param): 365 ↛ 368line 365 didn't jump to line 368 because the condition on line 365 was always true
366 baseDoc = getattr(baseClass, param.__name__).__doc__
367 else:
368 return param
370 if merge:
371 derivedDoc = param.__doc__
372 if baseDoc is None:
373 if derivedDoc is not None:
374 param.__doc__ = f"{prefix}{cleandoc(derivedDoc)}{postfix}"
375 elif derivedDoc is None:
376 param.__doc__ = f"{prefix}{cleandoc(baseDoc)}{postfix}"
377 elif order is DocStringMergeOrder.DerivedFirst:
378 param.__doc__ = f"{prefix}{cleandoc(derivedDoc)}{interfix}{cleandoc(baseDoc)}{postfix}"
379 else:
380 param.__doc__ = f"{prefix}{cleandoc(baseDoc)}{interfix}{cleandoc(derivedDoc)}{postfix}"
381 else:
382 param.__doc__ = baseDoc
384 return param
386 return decorator