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

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("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("Use '@property' instead of '@readonly', if the property should be deletable.") 

270 raise ex 

271 

272 

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. 

278 

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. 

282 

283 .. seealso:: 

284 

285 :deco:`InheritDocString` 

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

287 """ 

288 

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. 

294 

295 

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. 

306 

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

311 

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. 

314 

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. 

319 

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. 

323 

324 .. admonition:: ``example.py`` 

325 

326 .. code-block:: python 

327 

328 from pyTooling.Decorators import InheritDocString, DocStringMergeStrategy 

329 

330 class Class1: 

331 def method(self): 

332 '''Method's doc-string.''' 

333 

334 class Class2(Class1): 

335 @InheritDocString(Class1) 

336 def method(self): 

337 super().method() 

338 

339 .. admonition:: ``merging.py`` 

340 

341 .. code-block:: python 

342 

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

350 

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. 

358 

359 .. seealso:: 

360 

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

367 

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 

373 

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 

380 

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

386 

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) 

398 

399 merged = interfix.join(part for part in parts if part != "") 

400 if merged != "": 

401 param.__doc__ = f"{prefix}{merged}{postfix}" 

402 

403 return param 

404 

405 return decorator