Coverage for src/mafw/processor/processor_list.py: 100%

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

5ProcessorList container for orchestrating sequences of processors. 

6 

7This module defines :class:`~mafw.processor.ProcessorList`, a specialised list that holds 

8:class:`~mafw.processor.Processor` instances (or nested ProcessorLists) and 

9executes them sequentially. It manages shared resources — timer, user interface, 

10and database connection — distributing them to each processor before execution. 

11 

12Scientists typically construct a :class:`~mafw.processor.ProcessorList` in their analysis script, 

13populate it with processor instances representing successive analysis steps, and 

14call :meth:`~mafw.processor.ProcessorList.execute` to run the full pipeline. 

15 

16.. versionadded:: 2.3 

17 Extracted from the monolithic ``processor.py`` module for improved 

18 maintainability and focused testing. 

19""" 

20 

21from __future__ import annotations 

22 

23import contextlib 

24import logging 

25from collections.abc import Iterable 

26from typing import ( 

27 Any, 

28 Self, 

29 SupportsIndex, 

30) 

31 

32import peewee 

33from peewee import Database 

34 

35# noinspection PyUnresolvedReferences 

36from playhouse.db_url import connect 

37 

38from mafw.db.db_connection import build_connection_parameters 

39from mafw.db.db_model import database_proxy, mafw_model_register 

40from mafw.enumerators import ProcessorExitStatus 

41from mafw.mafw_errors import AbortProcessorException, MissingDatabase 

42from mafw.processor.base import Processor 

43from mafw.processor.utils import validate_database_conf 

44from mafw.timer import Timer 

45from mafw.ui.abstract_user_interface import UserInterfaceBase 

46from mafw.ui.console_user_interface import ConsoleInterface 

47 

48log = logging.getLogger(__name__) 

49 

50 

51class ProcessorList(list['Processor | ProcessorList']): 

52 """ 

53 A list like collection of processors. 

54 

55 ProcessorList is a subclass of list containing only Processor subclasses or other ProcessorList. 

56 

57 An attempt to add an element that is not a Processor or a ProcessorList will raise a TypeError. 

58 

59 Along with an iterable of processors, a new processor list can be built using the following parameters. 

60 """ 

61 

62 def __init__( 

63 self, 

64 *args: Processor | ProcessorList, 

65 name: str | None = None, 

66 description: str | None = None, 

67 timer: Timer | None = None, 

68 timer_params: dict[str, Any] | None = None, 

69 user_interface: UserInterfaceBase | None = None, 

70 database: Database | None = None, 

71 database_conf: dict[str, Any] | None = None, 

72 create_standard_tables: bool = True, 

73 ): 

74 """ 

75 Constructor parameters: 

76 

77 :param name: The name of the processor list. Defaults to ProcessorList. 

78 :type name: str, Optional 

79 :param description: An optional short description. Default to ProcessorList. 

80 :type description: str, Optional 

81 :param timer: The timer object. If None is provided, a new one will be created. Defaults to None. 

82 :type timer: Timer, Optional 

83 :param timer_params: A dictionary of parameter to build the timer object. Defaults to None. 

84 :type timer_params: dict, Optional 

85 :param user_interface: A user interface. Defaults to None 

86 :type user_interface: UserInterfaceBase, Optional 

87 :param database: A database instance. Defaults to None. 

88 :type database: Database, Optional 

89 :param database_conf: Configuration for the database. Default to None. 

90 :type database_conf: dict, Optional 

91 :param create_standard_tables: Whether or not to create the standard tables. Defaults to True. 

92 :type create_standard_tables: bool, Optional 

93 """ 

94 

95 # validate_items takes a tuple of processors, that's why we don't unpack args. 

96 super().__init__(self.validate_items(args)) 

97 self._name = name or self.__class__.__name__ 

98 self.description = description or self._name 

99 

100 self.timer = timer 

101 self.timer_params = timer_params or {} 

102 self._user_interface = user_interface or ConsoleInterface() 

103 

104 self._resource_stack: contextlib.ExitStack 

105 self._processor_exit_status: ProcessorExitStatus = ProcessorExitStatus.Successful 

106 self.nested_list = False 

107 """ 

108 Boolean flag to identify that this list is actually inside another list. 

109 

110 Similarly to the local resource flag for the :class:`.base.Processor`, this flag prevent the user interface to be 

111 added to the resource stack. 

112 """ 

113 

114 # database stuff 

115 self._database: peewee.Database | None = database 

116 self._database_conf: dict[str, Any] | None = validate_database_conf(database_conf) 

117 self.create_standard_tables = create_standard_tables 

118 """The boolean flag to proceed or skip with standard table creation and initialisation""" 

119 

120 def __setitem__( # type: ignore[override] 

121 self, 

122 __index: SupportsIndex, 

123 __object: Processor | ProcessorList, 

124 ) -> None: 

125 super().__setitem__(__index, self.validate_item(__object)) 

126 

127 def insert(self, __index: SupportsIndex, __object: Processor | ProcessorList) -> None: 

128 """Adds a new processor at the specified index.""" 

129 super().insert(__index, self.validate_item(__object)) 

130 

131 def append(self, __object: Processor | ProcessorList) -> None: 

132 """Appends a new processor at the end of the list.""" 

133 super().append(self.validate_item(__object)) 

134 

135 def extend(self, __iterable: Iterable[Processor | ProcessorList]) -> None: 

136 """Extends the processor list with a list of processors.""" 

137 if isinstance(__iterable, type(self)): 

138 super().extend(__iterable) 

139 else: 

140 super().extend([self.validate_item(item) for item in __iterable]) 

141 

142 @staticmethod 

143 def validate_item(item: Processor | ProcessorList) -> Processor | ProcessorList: 

144 """Validates the item being added.""" 

145 if isinstance(item, Processor): 

146 item.local_resource_acquisition = False 

147 return item 

148 elif isinstance(item, ProcessorList): 

149 item.timer_params = dict(suppress_message=True) 

150 item.nested_list = True 

151 return item 

152 else: 

153 raise TypeError(f'Expected Processor or ProcessorList, got {type(item).__name__}') 

154 

155 @staticmethod 

156 def validate_items(items: tuple[Processor | ProcessorList, ...] = ()) -> tuple[Processor | ProcessorList, ...]: 

157 """Validates a tuple of items being added.""" 

158 if not items: 

159 return tuple() 

160 return tuple([ProcessorList.validate_item(item) for item in items if item is not None]) 

161 

162 @property 

163 def name(self) -> str: 

164 """ 

165 The name of the processor list 

166 

167 :return: The name of the processor list 

168 :rtype: str 

169 """ 

170 return self._name 

171 

172 @name.setter 

173 def name(self, name: str) -> None: 

174 self._name = name 

175 

176 @property 

177 def processor_exit_status(self) -> ProcessorExitStatus: 

178 """ 

179 The processor exit status. 

180 

181 It refers to the whole processor list execution. 

182 """ 

183 return self._processor_exit_status 

184 

185 @processor_exit_status.setter 

186 def processor_exit_status(self, status: ProcessorExitStatus) -> None: 

187 self._processor_exit_status = status 

188 

189 @property 

190 def database(self) -> peewee.Database: 

191 """ 

192 Returns the database instance 

193 

194 :return: A database instance 

195 :raises MissingDatabase: if a database connection is missing. 

196 """ 

197 if self._database is None: 

198 raise MissingDatabase('Database connection not initialized') 

199 return self._database 

200 

201 def execute(self) -> ProcessorExitStatus: 

202 """ 

203 Execute the list of processors. 

204 

205 Similarly to the :class:`.processor.Processor`, ProcessorList can be executed. In simple words, the execute 

206 method of each processor in the list is called exactly in the same sequence as they were added. 

207 """ 

208 with contextlib.ExitStack() as self._resource_stack: 

209 self.acquire_resources() 

210 self._user_interface.create_task(self.name, self.description, completed=0, increment=0, total=len(self)) 

211 for i, item in enumerate(self): 

212 if isinstance(item, Processor): 

213 log.info('[bold]Executing [red]%s[/red] processor[/bold]' % item.replica_name) 

214 else: 

215 log.info('[bold]Executing [blue]%s[/blue] processor list[/bold]' % item.name) 

216 self.distribute_resources(item) 

217 item.execute() 

218 self._user_interface.update_task(self.name, increment=1) 

219 self._processor_exit_status = item.processor_exit_status 

220 if self._processor_exit_status == ProcessorExitStatus.Aborted: 

221 msg = 'Processor %s caused the processor list to abort' % item.name 

222 log.error(msg) 

223 raise AbortProcessorException(msg) 

224 self._user_interface.update_task(self.name, completed=len(self), total=len(self)) 

225 return self._processor_exit_status 

226 

227 def acquire_resources(self) -> None: 

228 """ 

229 Acquires external resources. 

230 

231 The resource acquisition strategy mirrors :meth:`.processor.Processor.acquire_resources`: 

232 if a resource (timer, database) is already provided, it is reused; otherwise 

233 a new one is created and registered with the exit stack for automatic cleanup. 

234 """ 

235 # If we do get resources already active (not None) then we use them, 

236 # otherwise, we create them and add them to the resource stack. 

237 if self.timer is None: 

238 self.timer = self._resource_stack.enter_context(Timer(**self.timer_params)) 

239 # The user interface is very likely already initialised by the runner. 

240 # But if this is a nested list, then we must not push the user interface in the stack 

241 # otherwise the user interface context (progress for rich) will be stopped at the end 

242 # of the nested list. 

243 if not self.nested_list: 

244 self._resource_stack.enter_context(self._user_interface) 

245 if self._database is None and self._database_conf is None: 

246 # No database, nor configuration — nothing to do. 

247 pass 

248 elif self._database is None and self._database_conf is not None: 

249 # No database instance, but we have a configuration — create one. 

250 if 'DBConfiguration' in self._database_conf: 

251 conf = self._database_conf['DBConfiguration'] # type1 

252 else: 

253 conf = self._database_conf # type2 

254 

255 db_url, connection_parameters = build_connection_parameters(conf) 

256 

257 self._database = connect(db_url, **connection_parameters) # type: ignore[no-untyped-call] # playhouse.db_url.connect lacks type stubs 

258 try: 

259 self._database.connect() 

260 self._resource_stack.callback(self._database.close) 

261 except peewee.OperationalError as e: 

262 log.critical('Unable to connect to %s', db_url) 

263 raise e 

264 database_proxy.initialize(self._database) 

265 if self.create_standard_tables: 

266 standard_tables = mafw_model_register.get_standard_tables() 

267 self.database.create_tables(standard_tables) 

268 for table in standard_tables: 

269 table.init() 

270 else: # equiv to if self._database is not None: 

271 # We already have a database — likely inside a nested processor list. 

272 # The connection has been already set and initialised. Nothing else to do. 

273 pass 

274 

275 def distribute_resources(self, processor: Processor | Self) -> None: 

276 """Distributes the external resources to the items in the list.""" 

277 processor.timer = self.timer 

278 processor._user_interface = self._user_interface 

279 processor._database = self._database