Coverage for src / mafw / decorators.py: 100%

121 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2025-12-09 09:08 +0000

1# Copyright 2025 European Union 

2# Author: Bulgheroni Antonio (antonio.bulgheroni@ec.europa.eu) 

3# SPDX-License-Identifier: EUPL-1.2 

4""" 

5The module provides some general decorator utilities that are used in several parts of the code, and that can be 

6reused by the user community. 

7""" 

8 

9import functools 

10import typing 

11import warnings 

12from importlib.util import find_spec 

13from typing import Any, Callable, Type 

14 

15from mafw.enumerators import LoopType 

16from mafw.mafw_errors import MissingDatabase, MissingOptionalDependency 

17from mafw.processor import Processor 

18 

19F = typing.TypeVar('F', bound=typing.Callable[..., object]) 

20"""TypeVar for generic function.""" 

21 

22# Define a TypeVar to capture Processor subclasses 

23P = typing.TypeVar('P', bound=Processor) 

24"""TypeVar for generic processor.""" 

25 

26 

27def suppress_warnings(func: F) -> F: 

28 """ 

29 Decorator to suppress warnings during the execution of a test function. 

30 

31 This decorator uses the `warnings.catch_warnings()` context manager to 

32 temporarily change the warning filter to ignore all warnings. It is useful 

33 when you want to run a test without having warnings clutter the output. 

34 

35 Usage:: 

36 

37 @suppress_warnings 

38 def test_function(): 

39 # Your test code that might emit warnings 

40 

41 

42 :param func: The test function to be decorated. 

43 :type func: Callable 

44 :return: The wrapped function with suppressed warnings. 

45 :rtype: Callable 

46 """ 

47 

48 @functools.wraps(func) 

49 def wrapper(*args: Any, **kwargs: Any) -> F: 

50 with warnings.catch_warnings(): 

51 warnings.simplefilter('ignore') 

52 return func(*args, **kwargs) # type: ignore[return-value] 

53 

54 return wrapper # type: ignore[return-value] 

55 

56 

57@typing.no_type_check # no idea how to fix it 

58def singleton(cls): 

59 """Make a class a Singleton class (only one instance)""" 

60 

61 @functools.wraps(cls) 

62 def wrapper_singleton(*args: Any, **kwargs: Any): 

63 if wrapper_singleton.instance is None: 

64 wrapper_singleton.instance = cls(*args, **kwargs) 

65 return wrapper_singleton.instance 

66 

67 wrapper_singleton.instance = None 

68 return wrapper_singleton 

69 

70 

71@typing.no_type_check 

72def database_required(cls): 

73 """Modify the processor start method to check if a database object exists. 

74 

75 This decorator must be applied to processors requiring a database connection. 

76 

77 :param cls: A Processor class. 

78 """ 

79 orig_start = cls.start 

80 

81 @functools.wraps(cls.start) 

82 def _start(self) -> None: 

83 if self._database is None: 

84 raise MissingDatabase(f'{self.name} requires an active database.') 

85 orig_start(self) 

86 

87 cls.start = _start 

88 

89 return cls 

90 

91 

92@typing.no_type_check 

93def orphan_protector(cls): 

94 """ 

95 A class decorator to modify the init method of a Processor so that the remove_orphan_files is set to False and 

96 no orphan files will be removed. 

97 """ 

98 old_init = cls.__init__ 

99 

100 @functools.wraps(cls.__init__) 

101 def new_init(self, *args, **kwargs): 

102 old_init(self, *args, remove_orphan_files=False, **kwargs) 

103 

104 cls.__init__ = new_init 

105 return cls 

106 

107 

108@typing.no_type_check 

109def execution_workflow(loop_type: LoopType | str = LoopType.ForLoop): 

110 """ 

111 A decorator factory for the definition of the looping strategy. 

112 

113 This decorator factory must be applied to Processor subclasses to modify their value of loop_type in order to 

114 change the execution workflow. 

115 

116 See :func:`single_loop`, :func:`for_loop` and :func:`while_loop` decorator shortcuts. 

117 

118 :param loop_type: The type of execution workflow requested for the decorated class. Defaults to LoopType.ForLoop. 

119 :type loop_type: LoopType | str, Optional 

120 """ 

121 

122 def dec(cls): 

123 """The class decorator.""" 

124 old_init = cls.__init__ 

125 

126 @functools.wraps(cls.__init__) 

127 def new_init(self, *args, **kwargs): 

128 """The modified Processor init""" 

129 old_init(self, *args, looper=loop_type, **kwargs) 

130 

131 cls.__init__ = new_init 

132 

133 return cls 

134 

135 return dec 

136 

137 

138single_loop = execution_workflow(LoopType.SingleLoop) 

139"""A decorator shortcut to define a single execution processor.""" 

140 

141for_loop = execution_workflow(LoopType.ForLoop) 

142"""A decorator shortcut to define a for loop execution processor.""" 

143 

144while_loop = execution_workflow(LoopType.WhileLoop) 

145"""A decorator shortcut to define a while loop execution processor.""" 

146 

147 

148def depends_on_optional( 

149 module_name: str, raise_ex: bool = False, warn: bool = True 

150) -> Callable[[F], Callable[..., Any]]: 

151 """ 

152 Function decorator to check if module_name is available. 

153 

154 If module_name is found, then returns the wrapped function. If not, its behavior depends on the raise_ex and 

155 warn_only values. If raise_ex is True, then an ImportError exception is raised. If it is False and warn is 

156 True, then a warning message is displayed but no exception is raised. If they are both False, then function is 

157 silently skipped. 

158 

159 If raise_ex is True, the value of `warn` is not taken into account. 

160 

161 **Typical usage** 

162 

163 The user should decorate functions or class methods when they cannot be executed without the optional library. 

164 In the specific case of Processor subclass, where the class itself can be created also without the missing 

165 library, but it is required somewhere in the processor execution, then the user is suggested to decorate the 

166 execute method with this decorator. 

167 

168 :param module_name: The optional module(s) from which the function depends on. A ";" separated list of modules can 

169 also be provided. 

170 :type module_name: str 

171 :param raise_ex: Flag to raise an exception if module_name is not found, defaults to False. 

172 :type raise_ex: bool, Optional 

173 :param warn: Flag to display a warning message if module_name is not found, default to True. 

174 :type warn: bool, Optional 

175 :return: The wrapped function 

176 :rtype: Callable 

177 :raise ImportError: if module_name is not found and raise_ex is True. 

178 """ 

179 

180 def decorator(func: F) -> Callable[..., Any]: 

181 @functools.wraps(func) 

182 def wrapper(*args: Any, **kwargs: Any) -> Any: 

183 all_mods_found = all(find_spec(mod.strip()) is not None for mod in module_name.split(';')) 

184 if not all_mods_found: 

185 msg = f'Optional dependency {module_name} not found ({func.__qualname__})' 

186 if raise_ex: 

187 raise ImportError(msg) 

188 else: 

189 if warn: 

190 warnings.warn(MissingOptionalDependency(msg), stacklevel=2) 

191 return None # Explicitly return None when skipping the function 

192 else: 

193 return func(*args, **kwargs) 

194 

195 return wrapper 

196 

197 return decorator 

198 

199 

200def processor_depends_on_optional( 

201 module_name: str, raise_ex: bool = False, warn: bool = True 

202) -> Callable[[Type[P]], Type[Processor]]: 

203 """ 

204 Class decorator factory to check if module module_name is available. 

205 

206 It checks if all the optional modules listed in `module_name` separated by a ';' can be found. 

207 

208 If all modules are found, then the class is returned as it is. 

209 

210 If at least one module is not found: 

211 - and raise_ex is True, an ImportError exception is raised and the user is responsible to deal with it. 

212 - if raise_ex is False, instead of returning the class, the :class:`~.Processor` is returned. 

213 - depending on the value of warn, the user will be informed with a warning message or not. 

214 

215 **Typical usage** 

216 

217 The user should decorate Processor subclasses everytime the optional module is required in their __init__ method. 

218 Should the check on the optional module have a positive outcome, then the Processor subclass is returned. 

219 Otherwise, if raise_ex is False, an instance of the base :py:class:`~.Processor` is returned. In 

220 this way, the returned class can still be executed without breaking the execution scheme but of course, without 

221 producing any output. 

222 

223 Should be possible to run the __init__ method of the class without the missing library, then the user can also 

224 follow the approach described in this other :func:`example <depends_on_optional>`. 

225 

226 :param module_name: The optional module(s) from which the class depends on. A ";" separated list of modules can 

227 also be provided. 

228 :type module_name: str 

229 :param raise_ex: Flag to raise an exception if module_name not found, defaults to False. 

230 :type raise_ex: bool, Optional 

231 :param warn: Flag to display a warning message if module_name is not found, defaults to True. 

232 :type warn: bool, Optional 

233 :return: The wrapped processor. 

234 :rtype: type(Processor) 

235 :raise ImportError: if module_name is not found and raise_ex is True. 

236 """ 

237 

238 def decorator(cls: Type[P]) -> Type[Processor]: 

239 """ 

240 The class decorator. 

241 

242 It checks if all the modules provided by the decorator factory are available on the systems. 

243 If yes, then it simply returns `cls`. If no, it returns a subclass of the :class:`~.Processor` 

244 after all the introspection properties have been taken from `cls`. 

245 

246 :param cls: The class being decorated. 

247 :type cls: type(Processor) 

248 :return: The decorated class, either cls or a subclass of :class:`~autorad.processor.Processor`. 

249 :rtype: type(Processor) 

250 """ 

251 

252 def class_wrapper(klass: Type[Processor]) -> Type[Processor]: 

253 """ 

254 Copy introspection properties from cls to klass. 

255 

256 :param klass: The class to be modified. 

257 :type klass: class. 

258 :return: The modified class. 

259 :rtype: class. 

260 """ 

261 klass.__module__ = cls.__module__ 

262 klass.__name__ = f'{cls.__name__} (Missing {module_name})' 

263 klass.__qualname__ = cls.__qualname__ 

264 klass.__annotations__ = cls.__annotations__ 

265 klass.__doc__ = cls.__doc__ 

266 return klass 

267 

268 all_mods_found = all([find_spec(mod.strip()) is not None for mod in module_name.split(';')]) 

269 if not all_mods_found: 

270 msg = f'Optional dependency {module_name} not found ({cls.__qualname__})' 

271 if raise_ex: 

272 raise ImportError(msg) 

273 else: 

274 if warn: 

275 warnings.warn(MissingOptionalDependency(msg), stacklevel=2) 

276 

277 # We subclass the basic processor. 

278 class NewClass(Processor): 

279 pass 

280 

281 # The class wrapper is copying introspection properties from the cls to the NewClass 

282 new_class = class_wrapper(NewClass) 

283 

284 else: 

285 new_class = cls 

286 return new_class 

287 

288 return decorator 

289 

290 

291def class_depends_on_optional( 

292 module_name: str, raise_ex: bool = False, warn: bool = True 

293) -> Callable[[Type[Any]], Type[Any]]: 

294 """ 

295 Class decorator factory to check if module module_name is available. 

296 

297 It checks if all the optional modules listed in `module_name` separated by a ';' can be found. 

298 

299 If all modules are found, then the class is returned as it is. 

300 

301 If at least one module is not found: 

302 - and raise_ex is True, an ImportError exception is raised and the user is responsible to deal with it. 

303 - if raise_ex is False, instead of returning the class, a new empty class is returned. 

304 - depending on the value of warn, the user will be informed with a warning message or not. 

305 

306 :param module_name: The optional module(s) from which the class depends on. A ";" separated list of modules can 

307 also be provided. 

308 :type module_name: str 

309 :param raise_ex: Flag to raise an exception if module_name not found, defaults to False. 

310 :type raise_ex: bool, Optional 

311 :param warn: Flag to display a warning message if module_name is not found, defaults to True. 

312 :type warn: bool, Optional 

313 :return: The wrapped class. 

314 :rtype: type(object) 

315 :raise ImportError: if module_name is not found and raise_ex is True. 

316 """ 

317 

318 def decorator(cls: Type[Any]) -> Type[Any]: 

319 """ 

320 The class decorator. 

321 

322 It checks if all the modules provided by the decorator factory are available on the systems. 

323 If yes, then it simply returns `cls`. If no, it returns a subclass of the cls bases. 

324 after all the introspection properties have been taken from `cls`. 

325 

326 :param cls: The class being decorated. 

327 :type cls: type(cls) 

328 :return: The decorated class, either cls or a subclass of cls. 

329 :rtype: type(cls) 

330 """ 

331 

332 def class_wrapper(klass: Type[Any]) -> Type[Any]: 

333 """ 

334 Copy introspection properties from cls to klass. 

335 

336 :param klass: The class to be modified. 

337 :type klass: class. 

338 :return: The modified class. 

339 :rtype: class. 

340 """ 

341 klass.__module__ = cls.__module__ 

342 klass.__name__ = f'{cls.__name__} (Missing {module_name})' 

343 klass.__qualname__ = cls.__qualname__ 

344 klass.__annotations__ = cls.__annotations__ 

345 klass.__doc__ = cls.__doc__ 

346 return klass 

347 

348 all_mods_found = all([find_spec(mod.strip()) is not None for mod in module_name.split(';')]) 

349 if not all_mods_found: 

350 msg = f'Optional dependency {module_name} not found ({cls.__qualname__})' 

351 if raise_ex: 

352 raise ImportError(msg) 

353 else: 

354 if warn: 

355 warnings.warn(MissingOptionalDependency(msg), stacklevel=2) 

356 

357 # we subclass the original class. 

358 class NewClass(*cls.__bases__): # type: ignore 

359 pass 

360 

361 # the class wrapper is copying introspection properties from the cls to the NewClass 

362 new_class = class_wrapper(NewClass) 

363 

364 else: 

365 new_class = cls 

366 return new_class 

367 

368 return decorator