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

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 sys import modules 

49from typing import TYPE_CHECKING, Any, Union, TypeVar, Callable, Generic, NoReturn, ParamSpec, overload, \ 

50 Optional as Nullable 

51 

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

53 

54 

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 

59 

60 

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. 

63 

64 

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

66 """ 

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

68 

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. 

71 

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

87 

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 

99 

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

102 

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

106 

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

109 

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

114 

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 

120 

121 return entity 

122 

123 

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

128 

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. 

131 

132 .. admonition:: ``example.py`` 

133 

134 .. code-block:: python 

135 

136 class Data: 

137 @notimplemented 

138 def method(self) -> bool: 

139 '''This method needs to be implemented''' 

140 return True 

141 

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

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

144 

145 .. seealso:: 

146 

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

152 

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

154 """ 

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

156 

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. 

164 

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

166 """ 

167 raise NotImplementedError(message) 

168 

169 func.__notImplemented__ = True 

170 return func 

171 

172 return decorator 

173 

174 

175_ReturnType = TypeVar("_ReturnType") 

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

177 

178 

179@export 

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

181 """ 

182 Marks a property as *read-only*. 

183 

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

185 

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. 

189 

190 .. seealso:: 

191 

192 :class:`property` 

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

194 """ 

195 

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

197 

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

199 """ 

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

201 

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

205 

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) 

210 

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

212 """ 

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

214 

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. 

218 

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) 

223 

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

228 

229 @overload 

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

231 ... 

232 

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. 

236 

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. 

240 

241 It is declared for type checkers only. At runtime, :class:`property`'s own ``__get__`` reads the value. 

242 

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] 

248 

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

250 """ 

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

252 

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 

260 

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

262 """ 

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

264 

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 

272 

273 

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. 

279 

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. 

283 

284 .. seealso:: 

285 

286 :deco:`InheritDocString` 

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

288 """ 

289 

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. 

295 

296 

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. 

307 

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

312 

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. 

315 

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. 

320 

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. 

324 

325 .. admonition:: ``example.py`` 

326 

327 .. code-block:: python 

328 

329 from pyTooling.Decorators import InheritDocString, DocStringMergeStrategy 

330 

331 class Class1: 

332 def method(self): 

333 '''Method's doc-string.''' 

334 

335 class Class2(Class1): 

336 @InheritDocString(Class1) 

337 def method(self): 

338 super().method() 

339 

340 .. admonition:: ``merging.py`` 

341 

342 .. code-block:: python 

343 

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

351 

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. 

359 

360 .. seealso:: 

361 

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

368 

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 

375 

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 

382 

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

388 

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) 

400 

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

402 if merged != "": 

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

404 

405 return param 

406 

407 return decorator