Coverage for pyTooling/Decorators/__init__.py: 86%

74 statements  

« prev     ^ index     » next       coverage.py v7.8.0, created at 2025-04-25 22:22 +0000

1# ==================================================================================================================== # 

2# _____ _ _ ____ _ # 

3# _ __ _ |_ _|__ ___ | (_)_ __ __ _ | _ \ ___ ___ ___ _ __ __ _| |_ ___ _ __ ___ # 

4# | '_ \| | | || |/ _ \ / _ \| | | '_ \ / _` | | | | |/ _ \/ __/ _ \| '__/ _` | __/ _ \| '__/ __| # 

5# | |_) | |_| || | (_) | (_) | | | | | | (_| |_| |_| | __/ (_| (_) | | | (_| | || (_) | | \__ \ # 

6# | .__/ \__, ||_|\___/ \___/|_|_|_| |_|\__, (_)____/ \___|\___\___/|_| \__,_|\__\___/|_| |___/ # 

7# |_| |___/ |___/ # 

8# ==================================================================================================================== # 

9# Authors: # 

10# Patrick Lehmann # 

11# # 

12# License: # 

13# ==================================================================================================================== # 

14# Copyright 2017-2025 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:: See :ref:`high-level help <DECO>` for explanations and usage examples. 

34""" 

35import sys 

36from functools import wraps 

37from types import FunctionType 

38from typing import Union, Type, TypeVar, Callable, Any, Optional as Nullable 

39 

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

41 

42 

43try: 

44 # See https://stackoverflow.com/questions/47060133/python-3-type-hinting-for-decorator 

45 from typing import ParamSpec # WORKAROUND: exists since Python 3.10 

46 

47 Param = ParamSpec("Param") #: A parameter specification for function or method 

48 RetType = TypeVar("RetType") #: Type variable for a return type 

49 Func = Callable[Param, RetType] #: Type specification for a function 

50except ImportError: # pragma: no cover 

51 Param = ... #: A parameter specification for function or method 

52 RetType = TypeVar("RetType") #: Type variable for a return type 

53 Func = Callable[..., RetType] #: Type specification for a function 

54 

55 

56T = TypeVar("T", bound=Union[Type, FunctionType]) #: A type variable for a classes or functions. 

57C = TypeVar("C", bound=Callable) #: A type variable for functions or methods. 

58 

59 

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

61 """ 

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

63 

64 Creates or updates the ``__all__`` attribute in the module in which the decorated entity is defined to include the 

65 name of the decorated entity. 

66 

67 +---------------------------------------------+------------------------------------------------+ 

68 | ``to_export.py`` | ``another_file.py`` | 

69 +=============================================+================================================+ 

70 | .. code-block:: python | .. code-block:: python | 

71 | | | 

72 | from pyTooling.Decorators import export | from .to_export import * | 

73 | | | 

74 | @export | | 

75 | def exported(): | # 'exported' will be listed in __all__ | 

76 | pass | assert "exported" in globals() | 

77 | | | 

78 | def not_exported(): | # 'not_exported' won't be listed in __all__ | 

79 | pass | assert "not_exported" not in globals() | 

80 | | | 

81 +---------------------------------------------+------------------------------------------------+ 

82 

83 :param entity: The function or class to include in `__all__`. 

84 :returns: The unmodified function or class. 

85 :raises AttributeError: If parameter ``entity`` has no ``__module__`` member. 

86 :raises TypeError: If parameter ``entity`` is not a top-level entity in a module. 

87 :raises TypeError: If parameter ``entity`` has no ``__name__``. 

88 """ 

89 # * Based on an idea by Duncan Booth: 

90 # http://groups.google.com/group/comp.lang.python/msg/11cbb03e09611b8a 

91 # * Improved via a suggestion by Dave Angel: 

92 # http://groups.google.com/group/comp.lang.python/msg/3d400fb22d8a42e1 

93 

94 if not hasattr(entity, "__module__"): 94 ↛ 95line 94 didn't jump to line 95 because the condition on line 94 was never true

95 raise AttributeError(f"{entity} has no __module__ attribute. Please ensure it is a top-level function or class reference defined in a module.") 

96 

97 if hasattr(entity, "__qualname__"): 97 ↛ 101line 97 didn't jump to line 101 because the condition on line 97 was always true

98 if any(i in entity.__qualname__ for i in (".", "<locals>", "<lambda>")): 

99 raise TypeError(f"Only named top-level functions and classes may be exported, not {entity}") 

100 

101 if not hasattr(entity, "__name__") or entity.__name__ == "<lambda>": 101 ↛ 102line 101 didn't jump to line 102 because the condition on line 101 was never true

102 raise TypeError(f"Entity must be a named top-level function or class, not {entity.__class__}") 

103 

104 try: 

105 module = sys.modules[entity.__module__] 

106 except KeyError: 

107 raise ValueError(f"Module {entity.__module__} is not present in sys.modules. Please ensure it is in the import path before calling export().") 

108 

109 if hasattr(module, "__all__"): 

110 if entity.__name__ not in module.__all__: # type: ignore 110 ↛ 115line 110 didn't jump to line 115 because the condition on line 110 was always true

111 module.__all__.append(entity.__name__) # type: ignore 

112 else: 

113 module.__all__ = [entity.__name__] # type: ignore 

114 

115 return entity 

116 

117 

118@export 

119def notimplemented(message: str) -> Callable: 

120 """ 

121 Mark a method as *not implemented* and replace the implementation with a new method raising a :exc:`NotImplementedError`. 

122 

123 The original method is stored in ``<method>.__wrapped__`` and it's doc-string is copied to the replacing method. In 

124 additional the field ``<method>.__notImplemented__`` is added. 

125 

126 .. admonition:: ``example.py`` 

127 

128 .. code-block:: python 

129 

130 class Data: 

131 @notimplemented 

132 def method(self) -> bool: 

133 '''This method needs to be implemented''' 

134 return True 

135 

136 :param method: Method that is marked as *not implemented*. 

137 :returns: Replacement method, which raises a :exc:`NotImplementedError`. 

138 

139 .. seealso:: 

140 

141 * :func:`~pyTooling.Metaclasses.abstractmethod` 

142 * :func:`~pyTooling.Metaclasses.mustoverride` 

143 """ 

144 

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

146 @wraps(method) 

147 def func(*_, **__): 

148 raise NotImplementedError(message) 

149 

150 func.__notImplemented__ = True 

151 return func 

152 

153 return decorator 

154 

155 

156# Further Reading: 

157# * https://github.com/python/cpython/issues/89519#issuecomment-1397534245 

158# * https://stackoverflow.com/questions/128573/using-property-on-classmethods/64738850#64738850 

159# * https://stackoverflow.com/questions/128573/using-property-on-classmethods 

160# * https://stackoverflow.com/questions/5189699/how-to-make-a-class-property 

161 

162@export 

163def classproperty(method): 

164 

165 class Descriptor: 

166 """A decorator adding properties to classes.""" 

167 _getter: Callable 

168 _setter: Callable 

169 

170 def __init__(self, getter: Nullable[Callable] = None, setter: Nullable[Callable] = None) -> None: 

171 self._getter = getter 

172 self._setter = setter 

173 self.__doc__ = getter.__doc__ 

174 

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

176 return self._getter(owner) 

177 

178 def __set__(self, instance: Any, value: Any) -> None: 

179 self._setter(instance.__class__, value) 

180 

181 def setter(self, setter: Callable): 

182 return self.__class__(self._getter, setter) 

183 

184 descriptor = Descriptor(method) 

185 return descriptor 

186 

187 

188@export 

189def readonly(func: Callable) -> property: 

190 """ 

191 Marks a property as *read-only*. 

192 

193 The doc-string will be taken from the getter-function. 

194 

195 It will remove ``<property>.setter`` and ``<property>.deleter`` from the property descriptor. 

196 

197 :param func: Function to convert to a read-only property. 

198 :returns: A property object with just a getter. 

199 

200 .. seealso:: 

201 

202 :class:`property` 

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

204 """ 

205 prop = property(fget=func, fset=None, fdel=None, doc=func.__doc__) 

206 

207 return prop 

208 

209 

210@export 

211def InheritDocString(baseClass: type, merge: bool = False) -> Callable[[Union[Func, type]], Union[Func, type]]: 

212 """ 

213 Copy the doc-string from given base-class to the method this decorator is applied to. 

214 

215 .. admonition:: ``example.py`` 

216 

217 .. code-block:: python 

218 

219 from pyTooling.Decorators import InheritDocString 

220 

221 class Class1: 

222 def method(self): 

223 '''Method's doc-string.''' 

224 

225 class Class2(Class1): 

226 @InheritDocString(Class1) 

227 def method(self): 

228 super().method() 

229 

230 :param baseClass: Base-class to copy the doc-string from to the new method being decorated. 

231 :returns: Decorator function that copies the doc-string. 

232 """ 

233 def decorator(param: Union[Func, type]) -> Union[Func, type]: 

234 """ 

235 Decorator function, which copies the doc-string from base-class' method to method ``m``. 

236 

237 :param param: Method to which the doc-string from a method in ``baseClass`` (with same className) should be copied. 

238 :returns: Same method, but with overwritten doc-string field (``__doc__``). 

239 """ 

240 if isinstance(param, type): 

241 baseDoc = baseClass.__doc__ 

242 elif callable(param): 242 ↛ 245line 242 didn't jump to line 245 because the condition on line 242 was always true

243 baseDoc = getattr(baseClass, param.__name__).__doc__ 

244 else: 

245 return param 

246 

247 if merge: 

248 if param.__doc__ is None: 248 ↛ 249line 248 didn't jump to line 249 because the condition on line 248 was never true

249 param.__doc__ = baseDoc 

250 elif baseDoc is not None: 

251 param.__doc__ = baseDoc + "\n\n" + param.__doc__ 

252 else: 

253 param.__doc__ = baseDoc 

254 

255 return param 

256 

257 return decorator