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

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. 

32 

33.. hint:: 

34 

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

36 

37.. seealso:: 

38 

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 

45 

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 

53 

54__all__ = ["export", "Param", "RetType", "Func", "T"] 

55 

56 

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 

61 

62 

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. 

65 

66 

67def export(entity: T) -> T: 

68 """ 

69 Register the given function or class as publicly accessible in a module. 

70 

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. 

73 

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 +---------------------------------------------+------------------------------------------------+ 

89 

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 

101 

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.") 

104 

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}") 

108 

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__}") 

111 

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().") 

116 

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 

122 

123 return entity 

124 

125 

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`. 

130 

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. 

133 

134 .. admonition:: ``example.py`` 

135 

136 .. code-block:: python 

137 

138 class Data: 

139 @notimplemented 

140 def method(self) -> bool: 

141 '''This method needs to be implemented''' 

142 return True 

143 

144 :param message: Text of the :exc:`NotImplementedError` raised by the replacement method. 

145 :returns: Decorator function that replaces the decorated method. 

146 

147 .. seealso:: 

148 

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 """ 

154 

155 def decorator(method: C) -> C: 

156 """ 

157 Decorator function, which replaces the decorated method by one raising a :exc:`NotImplementedError`. 

158 

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. 

166 

167 :raises NotImplementedError: Always, with the message given to :deco:`notimplemented`. 

168 """ 

169 raise NotImplementedError(message) 

170 

171 func.__notImplemented__ = True 

172 return func 

173 

174 return decorator 

175 

176 

177_ReturnType = TypeVar("_ReturnType") 

178"""A type variable for the value a read-only property hands out.""" 

179 

180 

181@export 

182class readonly(property, Generic[_ReturnType]): 

183 """ 

184 Marks a property as *read-only*. 

185 

186 The doc-string is taken from the getter-method, like :class:`property` does. 

187 

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. 

191 

192 .. seealso:: 

193 

194 :class:`property` 

195 A decorator to convert getter, setter and deleter methods into a property applying the descriptor protocol. 

196 """ 

197 

198 fget: Callable[[Any], _ReturnType] #: The getter-method; a read-only property is always constructed from one. 

199 

200 def __init__(self, fget: Callable[[Any], _ReturnType], doc: Nullable[str] = None) -> None: 

201 """ 

202 Create a read-only property from a getter-method. 

203 

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`. 

207 

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) 

212 

213 def getter(self, fget: Callable[[Any], _ReturnType], /) -> readonly[_ReturnType]: 

214 """ 

215 Derive a read-only property with another getter-method from this one. 

216 

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. 

220 

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) 

225 

226 @overload 

227 def __get__(self, instance: None, owner: type, /) -> readonly[_ReturnType]: 

228 ... # pragma: no cover - an overload carries no implementation 

229 

230 @overload 

231 def __get__(self, instance: Any, owner: Nullable[type] = None, /) -> _ReturnType: 

232 ... # pragma: no cover - an overload carries no implementation 

233 

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. 

237 

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. 

241 

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] 

247 

248 def setter(self, fset: Callable[..., Any]) -> NoReturn: 

249 """ 

250 Reject attaching a setter to a read-only property. 

251 

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 

259 

260 def deleter(self, fdel: Callable[..., Any]) -> NoReturn: 

261 """ 

262 Reject attaching a deleter to a read-only property. 

263 

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 

271 

272 

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. 

278 

279 .. seealso:: 

280 

281 :deco:`InheritDocString` 

282 |rarr| Copy or merge a base-class' doc-string into the derived entity. 

283 """ 

284 

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. 

287 

288 

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. 

300 

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. 

304 

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. 

309 

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. 

312 

313 .. admonition:: ``example.py`` 

314 

315 .. code-block:: python 

316 

317 from pyTooling.Decorators import InheritDocString, DocStringMergeOrder 

318 

319 class Class1: 

320 def method(self): 

321 '''Method's doc-string.''' 

322 

323 class Class2(Class1): 

324 @InheritDocString(Class1) 

325 def method(self): 

326 super().method() 

327 

328 .. admonition:: ``merging.py`` 

329 

330 .. code-block:: python 

331 

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.''' 

340 

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. 

350 

351 .. seealso:: 

352 

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``. 

359 

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 

369 

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 

383 

384 return param 

385 

386 return decorator