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

128 statements  

« prev     ^ index     » next       coverage.py v7.15.0, created at 2026-07-26 09:13 +0000

1# Copyright 2025–2026 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 collections.abc import Callable 

13from importlib.util import find_spec 

14from typing import Any 

15 

16from mafw.enumerators import LoopType 

17from mafw.mafw_errors import MissingDatabase, MissingOptionalDependency 

18from mafw.processor import Processor 

19 

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

21"""TypeVar for generic function.""" 

22 

23# Define a TypeVar to capture Processor subclasses 

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

25"""TypeVar for generic processor.""" 

26 

27 

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

29 """ 

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

31 

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

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

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

35 

36 Usage:: 

37 

38 @suppress_warnings 

39 def test_function(): 

40 # Your test code that might emit warnings 

41 

42 

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

44 :type func: Callable 

45 :return: The wrapped function with suppressed warnings. 

46 :rtype: Callable 

47 """ 

48 

49 @functools.wraps(func) 

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

51 with warnings.catch_warnings(): 

52 warnings.simplefilter('ignore') 

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

54 

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

56 

57 

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

59def singleton(cls): 

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

61 

62 @functools.wraps(cls) 

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

64 if wrapper_singleton.instance is None: 

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

66 return wrapper_singleton.instance 

67 

68 wrapper_singleton.instance = None 

69 return wrapper_singleton 

70 

71 

72@typing.no_type_check 

73def database_required(cls): 

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

75 

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

77 

78 It operates on a processor class modifying its start method. Since the start method is also one of those for 

79 which the presence of the super call is verified, this decorator is also taking care of re-applying the super 

80 call wrapper. 

81 

82 :param cls: A Processor class. 

83 """ 

84 orig_start = cls.start 

85 

86 @functools.wraps(cls.start) 

87 def _start(self) -> None: 

88 if self._database is None: 

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

90 orig_start(self) 

91 

92 cls.start = _start 

93 if hasattr(cls, '_apply_super_call_wrappers'): 93 ↛ 96line 93 didn't jump to line 96 because the condition on line 93 was always true

94 cls._apply_super_call_wrappers() 

95 

96 return cls 

97 

98 

99@typing.no_type_check 

100def orphan_protector(cls): 

101 """ 

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

103 no orphan files will be removed. 

104 """ 

105 old_init = cls.__init__ 

106 

107 @functools.wraps(cls.__init__) 

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

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

110 

111 cls.__init__ = new_init 

112 return cls 

113 

114 

115@typing.no_type_check 

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

117 """ 

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

119 

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

121 change the execution workflow. 

122 

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

124 

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

126 :type loop_type: LoopType | str, Optional 

127 """ 

128 

129 def dec(cls): 

130 """The class decorator.""" 

131 old_init = cls.__init__ 

132 

133 @functools.wraps(cls.__init__) 

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

135 """The modified Processor init""" 

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

137 

138 cls.__init__ = new_init 

139 

140 return cls 

141 

142 return dec 

143 

144 

145single_loop = execution_workflow(LoopType.SingleLoop) 

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

147 

148for_loop = execution_workflow(LoopType.ForLoop) 

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

150 

151while_loop = execution_workflow(LoopType.WhileLoop) 

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

153 

154parallel_for_loop = execution_workflow(LoopType.ParallelForLoop) 

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

156 

157parallel_for_loop_with_queue = execution_workflow(LoopType.ParallelForLoopWithQueue) 

158"""A decorator shortcut to define a parallel for loop with queue execution processor.""" 

159 

160 

161def depends_on_optional( 

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

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

164 """ 

165 Function decorator to check if module_name is available. 

166 

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

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

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

170 silently skipped. 

171 

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

173 

174 **Typical usage** 

175 

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

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

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

179 execute method with this decorator. 

180 

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

182 also be provided. 

183 :type module_name: str 

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

185 :type raise_ex: bool, Optional 

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

187 :type warn: bool, Optional 

188 :return: The wrapped function 

189 :rtype: Callable 

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

191 """ 

192 

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

194 @functools.wraps(func) 

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

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

197 if not all_mods_found: 

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

199 if raise_ex: 

200 raise ImportError(msg) 

201 else: 

202 if warn: 

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

204 return None # Explicitly return None when skipping the function 

205 else: 

206 return func(*args, **kwargs) 

207 

208 return wrapper 

209 

210 return decorator 

211 

212 

213def processor_depends_on_optional( 

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

215) -> Callable[[type[P]], type[Processor]]: 

216 """ 

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

218 

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

220 

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

222 

223 If at least one module is not found: 

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

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

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

227 

228 **Typical usage** 

229 

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

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

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

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

234 producing any output. 

235 

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

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

238 

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

240 also be provided. 

241 :type module_name: str 

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

243 :type raise_ex: bool, Optional 

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

245 :type warn: bool, Optional 

246 :return: The wrapped processor. 

247 :rtype: type(processor.Processor) 

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

249 """ 

250 

251 def decorator(cls: type[P]) -> type[Processor]: 

252 """ 

253 The class decorator. 

254 

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

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

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

258 

259 :param cls: The class being decorated. 

260 :type cls: type(Processor) 

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

262 :rtype: type(Processor) 

263 """ 

264 

265 def class_wrapper(klass: type[Processor]) -> type[Processor]: 

266 """ 

267 Copy introspection properties from cls to klass. 

268 

269 :param klass: The class to be modified. 

270 :type klass: class. 

271 :return: The modified class. 

272 :rtype: class. 

273 """ 

274 klass.__module__ = cls.__module__ 

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

276 klass.__qualname__ = cls.__qualname__ 

277 klass.__annotations__ = cls.__annotations__ 

278 klass.__doc__ = cls.__doc__ 

279 return klass 

280 

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

282 if not all_mods_found: 

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

284 if raise_ex: 

285 raise ImportError(msg) 

286 else: 

287 if warn: 

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

289 

290 # We subclass the basic processor. 

291 class NewClass(Processor): 

292 pass 

293 

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

295 new_class = class_wrapper(NewClass) 

296 

297 else: 

298 new_class = cls 

299 return new_class 

300 

301 return decorator 

302 

303 

304def class_depends_on_optional( 

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

306) -> Callable[[type[Any]], type[Any]]: 

307 """ 

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

309 

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

311 

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

313 

314 If at least one module is not found: 

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

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

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

318 

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

320 also be provided. 

321 :type module_name: str 

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

323 :type raise_ex: bool, Optional 

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

325 :type warn: bool, Optional 

326 :return: The wrapped class. 

327 :rtype: type(object) 

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

329 """ 

330 

331 def decorator(cls: type[Any]) -> type[Any]: 

332 """ 

333 The class decorator. 

334 

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

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

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

338 

339 :param cls: The class being decorated. 

340 :type cls: type(cls) 

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

342 :rtype: type(cls) 

343 """ 

344 

345 def class_wrapper(klass: type[Any]) -> type[Any]: 

346 """ 

347 Copy introspection properties from cls to klass. 

348 

349 :param klass: The class to be modified. 

350 :type klass: class. 

351 :return: The modified class. 

352 :rtype: class. 

353 """ 

354 klass.__module__ = cls.__module__ 

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

356 klass.__qualname__ = cls.__qualname__ 

357 klass.__annotations__ = cls.__annotations__ 

358 klass.__doc__ = cls.__doc__ 

359 return klass 

360 

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

362 if not all_mods_found: 

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

364 if raise_ex: 

365 raise ImportError(msg) 

366 else: 

367 if warn: 

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

369 

370 # we subclass the original class. 

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

372 pass 

373 

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

375 new_class = class_wrapper(NewClass) 

376 

377 else: 

378 new_class = cls 

379 return new_class 

380 

381 return decorator