Coverage for src/mafw/processor/base.py: 98%

718 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"""Core Processor class for the MAFw pipeline. 

5 

6This module defines the :class:`~mafw.processor.Processor` base class — the fundamental building 

7block of every MAFw analysis pipeline. A Processor encapsulates a single 

8processing step (reading data, applying corrections, fitting models, etc.) and 

9can be orchestrated inside a :class:`~mafw.processor.ProcessorList`. 

10 

11Scientists subclass :class:`~mafw.processor.Processor` and override a small set of methods 

12(:meth:`~mafw.processor.Processor.get_items`, :meth:`~mafw.processor.Processor.process`, 

13:meth:`~mafw.processor.Processor.accept_item`) to define their analysis logic, while the 

14framework manages looping, resource acquisition, parameter configuration, and 

15progress reporting. 

16 

17.. versionadded:: 2.3 

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

19 maintainability and focused testing. 

20""" 

21 

22from __future__ import annotations 

23 

24import contextlib 

25import inspect 

26import logging 

27import os 

28import queue 

29import threading 

30import time 

31import warnings 

32from collections import OrderedDict 

33from collections.abc import Callable, Collection, Iterator 

34from concurrent.futures import FIRST_COMPLETED, Future, ThreadPoolExecutor, wait 

35from copy import copy, deepcopy 

36from functools import wraps 

37from itertools import count 

38from typing import ( 

39 TYPE_CHECKING, 

40 Any, 

41 Union, 

42 cast, 

43 get_args, 

44 get_origin, 

45) 

46 

47import peewee 

48from peewee import Database 

49 

50# noinspection PyUnresolvedReferences 

51from playhouse.db_url import connect 

52 

53import mafw.db.db_filter 

54from mafw.active import Active 

55from mafw.db.db_connection import build_connection_parameters 

56from mafw.db.db_model import MAFwBaseModel, database_proxy, mafw_model_register 

57from mafw.enumerators import LoopingStatus, LoopType, ProcessorExitStatus, ProcessorStatus 

58from mafw.mafw_errors import ( 

59 MissingDatabase, 

60 MissingOverloadedMethod, 

61 MissingSuperCall, 

62 ProcessorParameterError, 

63) 

64from mafw.models.filter_schema import FilterSchema 

65from mafw.models.loop_payloads import LoopItem, LoopResult 

66from mafw.models.parameter_schema import ParameterSchema 

67from mafw.models.processor_schema import ProcessorSchema 

68from mafw.processor.meta import ProcessorMeta 

69from mafw.processor.parameters import ( 

70 ParameterType, 

71 PassiveParameter, 

72 _ensure_parameter_definitions, 

73 ensure_parameter_registration, 

74) 

75from mafw.processor.utils import validate_database_conf 

76from mafw.timer import Timer, pretty_format_duration 

77from mafw.tools.generics import deep_update 

78from mafw.tools.parallel import is_free_threading 

79from mafw.ui.abstract_user_interface import UserInterfaceBase 

80from mafw.ui.console_user_interface import ConsoleInterface 

81 

82log = logging.getLogger(__name__) 

83 

84 

85# noinspection PyProtectedMember 

86class Processor(metaclass=ProcessorMeta): 

87 """ 

88 The basic processor. 

89 

90 A very comprehensive description of what a Processor does and how it works is available at :ref:`doc_processor`. 

91 

92 """ 

93 

94 processor_status = Active(ProcessorStatus.Unknown) 

95 """Processor execution status""" 

96 

97 progress_message: str = f'{__qualname__} is working' 

98 """Message displayed to show the progress. 

99 

100 It can be customized with information about the current item in the loop by overloading the 

101 :meth:`format_progress_message`.""" 

102 

103 #: List of methods that should invoke their super implementation when overridden. 

104 _methods_to_be_checked_for_super: tuple[str, ...] = ('start', 'finish') 

105 

106 @classmethod 

107 def parameter_schema(cls) -> list[ParameterSchema]: 

108 """ 

109 Return the ordered static schema for the processor parameters defined on the class. 

110 

111 The schema is derived without instantiating the processor, keeping toolchains free from side effects. 

112 """ 

113 definitions = getattr(cls, '_parameter_definitions', None) 

114 if definitions is None: 

115 definitions = _ensure_parameter_definitions(cls) 

116 return [param.to_schema() for param in definitions.values()] 

117 

118 @classmethod 

119 def filter_schema(cls) -> FilterSchema | None: 

120 """ 

121 Optional metadata describing the models available for filtering. 

122 """ 

123 return getattr(cls, '_filter_schema', None) 

124 

125 @classmethod 

126 def processor_schema(cls) -> ProcessorSchema: 

127 """ 

128 Return the static schema for the processor. 

129 """ 

130 return ProcessorSchema(parameters=cls.parameter_schema(), filter=cls.filter_schema()) 

131 

132 _ids = count(0) 

133 """A counter for all processor instances""" 

134 

135 new_defaults: dict[str, Any] = {} 

136 """ 

137 A dictionary containing defaults value for the parameters to be overridden 

138 

139 .. versionadded:: v2.0 

140 """ 

141 

142 new_only_flag = 'new_only' 

143 

144 def __init__( 

145 self, 

146 name: str | None = None, 

147 description: str | None = None, 

148 config: dict[str, Any] | None = None, 

149 looper: LoopType | str = LoopType.ForLoop, 

150 user_interface: UserInterfaceBase | None = None, 

151 timer: Timer | None = None, 

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

153 database: Database | None = None, 

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

155 remove_orphan_files: bool = True, 

156 replica_id: str | None = None, 

157 create_standard_tables: bool = True, 

158 max_workers: int | None = None, 

159 queue_size: int | None = None, 

160 queue_batch_size: int | None = None, 

161 *args: Any, 

162 **kwargs: Any, 

163 ) -> None: 

164 """ 

165 Constructor parameters 

166 

167 :param name: The name of the processor. If None is provided, the class name is used instead. Defaults to None. 

168 :type name: str, Optional 

169 :param description: A short description of the processor task. Defaults to the processor name. 

170 :type description: str, Optional 

171 :param config: A configuration dictionary for this processor. Defaults to None. 

172 :type config: dict, Optional 

173 :param looper: Enumerator to define the looping type. Defaults to LoopType.ForLoop 

174 :type looper: LoopType, Optional 

175 :param user_interface: A user interface instance to be used by the processor to interact with the user. 

176 :type user_interface: UserInterfaceBase, Optional 

177 :param timer: A timer object to measure process duration. 

178 :type timer: Timer, Optional 

179 :param timer_params: Parameters for the timer object. 

180 :type timer_params: dict, Optional 

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

182 :type database: Database, Optional 

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

184 :type database_conf: dict, Optional 

185 :param remove_orphan_files: Boolean flag to remove files on disc without a reference to the database. 

186 See :ref:`std_tables` and :meth:`~mafw.processor.Processor._remove_orphan_files`. Defaults to True 

187 :type remove_orphan_files: bool, Optional 

188 :param replica_id: The replica identifier for the current processor. 

189 :type replica_id: str, Optional 

190 :param create_standard_tables: Boolean flag to create std tables on disk. Defaults to True 

191 When a nested steering configuration is loaded, this value can be overridden by the 

192 global create_standard_tables entry. Flat processor configurations keep the constructor value. 

193 :type create_standard_tables: bool, Optional 

194 :param max_workers: Number of worker threads for parallel loops. 

195 :type max_workers: int, Optional 

196 :param queue_size: Maximum size of the internal queue for the queue-based parallel loop. 

197 :type queue_size: int, Optional 

198 :param queue_batch_size: Number of items processed per worker task in the queue-based parallel loop. 

199 :type queue_batch_size: int, Optional 

200 :param kwargs: Keyword arguments that can be used to set processor parameters. 

201 """ 

202 

203 self.name = name or self.__class__.__name__ 

204 """The name of the processor.""" 

205 

206 self.unique_id = next(self._ids) 

207 """A unique identifier representing how many instances of Processor has been created.""" 

208 

209 self.replica_id = replica_id 

210 """ 

211 The replica identifier specified in the constructor 

212 

213 .. versionadded:: v2.0.0 

214 """ 

215 

216 self.description = description or self.name 

217 """A short description of the processor task.""" 

218 

219 self._item: Any = None 

220 """The current item of the loop.""" 

221 

222 self._looping_status: LoopingStatus = LoopingStatus.Continue 

223 """The looping status for the main thread.""" 

224 

225 self._thread_local = threading.local() 

226 """Thread-local storage for loop attributes in parallel execution. 

227 

228 .. versionadded:: v2.1.0 

229 """ 

230 

231 self._wall_clock_start: float | None = None 

232 """Timestamp when the looping execution began.""" 

233 

234 self.processor_exit_status = ProcessorExitStatus.Successful 

235 """Processor exit status""" 

236 

237 self.loop_type: LoopType = LoopType(looper) 

238 """ 

239 The loop type. 

240 

241 The value of this parameter can also be changed by the :func:`~mafw.decorators.execution_workflow` decorator 

242 factory. 

243 

244 See :class:`~mafw.enumerators.LoopType` for more details. 

245 """ 

246 

247 self.create_standard_tables = create_standard_tables 

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

249 

250 self.max_workers = max_workers if max_workers is not None else self._compute_default_max_workers() 

251 """Maximum number of worker threads used in parallel loops.""" 

252 

253 computed_queue_size = max(1, self.max_workers * 2) 

254 self.queue_size = queue_size if queue_size is not None else computed_queue_size 

255 """Maximum size of the queue used by :class:`~mafw.enumerators.LoopType.ParallelForLoopWithQueue`.""" 

256 

257 self.queue_batch_size = max(1, queue_batch_size or 1) 

258 """Number of items processed per worker task in :class:`~mafw.enumerators.LoopType.ParallelForLoopWithQueue`.""" 

259 

260 # private attributes 

261 self._config: dict[str, Any] = {} 

262 """ 

263 A dictionary containing the processor configuration object. 

264 

265 This dictionary is populated with configuration parameter (always type 2) during the 

266 :meth:`._load_parameter_configuration` method. 

267 

268 The original value of the configuration dictionary that is passed to the constructor is stored in 

269 :attr:`._orig_config`. 

270 

271 .. versionchanged:: v2.0.0 

272 Now it is an empty dictionary until the :meth:`._load_parameter_configuration` is called. 

273 

274 """ 

275 

276 self._orig_config = deepcopy(config) if config is not None else {} 

277 """ 

278 A copy of the original configuration dictionary. 

279 

280 .. versionadded:: v2.0.0 

281 """ 

282 

283 self._processor_parameters: OrderedDict[str, PassiveParameter[Any]] = OrderedDict() 

284 """ 

285 A dictionary to store all the processor parameter instances. 

286 

287 The name of the parameter is used as a key, while for the value an instance of the 

288 :class:`.processor.PassiveParameter` is used. 

289 """ 

290 self._parameter_registered = False 

291 """A boolean flag to confirm successful parameter registration.""" 

292 self._kwargs = kwargs 

293 

294 # loops attributes 

295 self._i_item: int = -1 

296 self._n_item: int | None = -1 

297 self._process_durations: list[float] = [] 

298 self._super_call_flags: dict[str, bool] = {} 

299 """Tracks super-call usage for methods that require it.""" 

300 

301 # resource stack 

302 self._resource_stack: contextlib.ExitStack 

303 self._resource_acquisition: bool = True 

304 

305 # processor timer 

306 self.timer: Timer | None = timer 

307 self._timer_parameters: dict[str, Any] = timer_params or {} 

308 

309 # user interface 

310 if user_interface is None: 

311 self._user_interface: UserInterfaceBase = ConsoleInterface() 

312 else: 

313 self._user_interface = user_interface 

314 

315 # database stuff 

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

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

318 self.filter_register: mafw.db.db_filter.ProcessorFilter = mafw.db.db_filter.ProcessorFilter() 

319 """The DB filter register of the Processor.""" 

320 self.remove_orphan_files: bool = remove_orphan_files 

321 """The flag to remove or protect the orphan files. Defaults to True""" 

322 

323 self.initialise_parameters() 

324 

325 def initialise_parameters(self) -> None: 

326 """ 

327 Initialises processor parameters by registering them and applying various configuration sources. 

328 

329 This method orchestrates the parameter initialisation process by performing the following steps in order: 

330 

331 #. Registers processor parameters defined as :class:`.processor.ActiveParameter` instances 

332 #. Overrides default parameter values with any configured overrides 

333 #. Loads parameter configuration from the processor's configuration dictionary 

334 #. Applies keyword arguments as parameter overrides 

335 

336 The method ensures that all processor parameters are properly configured before the processor 

337 execution begins. It is automatically called during processor initialisation and should not 

338 typically be called directly by users. 

339 

340 .. seealso:: 

341 :meth:`_register_parameters`, :meth:`_override_defaults`, 

342 :meth:`_load_parameter_configuration`, :meth:`_overrule_kws_parameters` 

343 

344 .. versionadded:: v2.0.0 

345 """ 

346 self._register_parameters() 

347 self._override_defaults() 

348 self._load_parameter_configuration() 

349 self._overrule_kws_parameters() 

350 

351 def __post_init__(self) -> None: 

352 """ 

353 Performs post-initialisation tasks for the processor. 

354 

355 This method is automatically called after the processor initialisation is complete. 

356 It performs validation checks on overloaded methods and sets the initial processor status. 

357 

358 .. seealso:: 

359 :meth:`validate_configuration`, :meth:`_check_method_overload`, 

360 :attr:`~mafw.processor.Processor.processor_status` 

361 

362 .. versionchanged:: v2.0.0 

363 Moved the parameter initialisation to :meth:`initialise_parameters` and now executed as last step of the 

364 init method. 

365 

366 Added the validate configuration check. This method should silently check that configuration provided 

367 with the processor parameters is valid. If not, a :exc:`.ProcessorParameterError` is raised. 

368 """ 

369 self.validate_configuration() 

370 self._check_method_overload() 

371 self.processor_status = ProcessorStatus.Init 

372 

373 def _register_parameters(self) -> None: 

374 """ 

375 Register processor parameters defined as ActiveParameter instances in the class. 

376 

377 This private method scans the class definition for any :class:`.processor.ActiveParameter` instances and creates 

378 corresponding :class:`.processor.PassiveParameter` instances to store the actual parameter values and metadata. 

379 It ensures that all processor parameters are properly initialised and available for configuration 

380 through the processor's configuration system. 

381 

382 The method checks for duplicate parameter names and raises a :exc:`.ProcessorParameterError` if duplicates 

383 are detected. It also sets the internal flag :attr:`.processor.Processor._parameter_registered` to True once 

384 registration is complete. 

385 

386 .. note:: 

387 This method is automatically called during processor initialisation and should not be called directly 

388 by users. 

389 

390 .. seealso:: 

391 :class:`.processor.Processor`, :meth:`.processor.Processor._override_defaults`, 

392 :meth:`.processor.Processor._load_parameter_configuration`, :meth:`.processor.Processor._overrule_kws_parameters` 

393 

394 .. versionchanged:: v2.0.0 

395 Only :class:`.processor.ActiveParameter` are not registered. The use of 

396 :class:`.processor.PassiveParameter` is only meant to store the value and metadata of the active 

397 counterpart. 

398 """ 

399 if self._parameter_registered: 

400 return 

401 

402 definitions = getattr(self.__class__, '_parameter_definitions', None) 

403 if definitions is None: 

404 definitions = _ensure_parameter_definitions(self.__class__) 

405 

406 for attr in definitions.values(): 

407 ext_name = attr._external_name 

408 if ext_name in self._processor_parameters: 

409 raise ProcessorParameterError(f'Duplicated parameter name ({ext_name}).') 

410 self._processor_parameters[ext_name] = PassiveParameter( 

411 ext_name, attr._value, attr._default, attr._help_doc 

412 ) 

413 

414 self._parameter_registered = True 

415 

416 def _override_defaults(self) -> None: 

417 """ 

418 Override default parameter values with values from :attr:`new_defaults`. 

419 

420 This private method iterates through the :attr:`new_defaults` dictionary and updates 

421 the corresponding processor parameters with new values. Only parameters that exist 

422 in both :attr:`new_defaults` and :attr:`_processor_parameters` are updated. 

423 

424 .. versionadded:: v2.0.0 

425 """ 

426 for key, value in self.new_defaults.items(): 

427 if key in self._processor_parameters: 

428 self._processor_parameters[key].value = value 

429 

430 def _reset_parameters(self) -> None: 

431 """ 

432 Reset processor parameters to their initial state. 

433 

434 This method clears all currently registered processor parameters and triggers 

435 a fresh registration process. It's useful when parameter configurations need 

436 to be reinitialized or when parameters have been modified and need to be reset. 

437 

438 .. seealso:: 

439 :meth:`_register_parameters`, :meth:`_register_parameters` 

440 """ 

441 self._processor_parameters = OrderedDict() 

442 self._parameter_registered = False 

443 self._register_parameters() 

444 

445 @ensure_parameter_registration 

446 def _load_parameter_configuration(self) -> None: 

447 """ 

448 Load processor parameter configuration from the internal configuration dictionary. 

449 

450 This method processes the processor's configuration dictionary to set parameter values. 

451 It handles two configuration formats: 

452 

453 1. Nested format: ``{'ProcessorName': {'param1': value1, ...}}`` 

454 2. Flat format: ``{'param1': value1, ...}`` 

455 

456 The method also handles filter configurations by collecting filter table names 

457 and deferring their initialisation until after the global filter has been processed. 

458 

459 .. versionchanged:: v2.0.0 

460 For option 1 combining configuration from name and name_replica 

461 

462 .. versionchanged:: v2.1.2 

463 When a nested steering configuration is loaded, the processor-level 

464 create_standard_tables value is overridden from the global steering-file setting. 

465 Flat configurations keep the constructor value untouched. 

466 

467 :raises ProcessorParameterError: If a parameter in the configuration is not registered. 

468 

469 .. seealso:: 

470 :meth:`mafw.db.db_filter.ModelFilter.from_conf` 

471 """ 

472 original_config = copy(self._orig_config) 

473 flt_list = [] 

474 

475 # by default the flag new_only is set to true 

476 # unless the user specify differently in the general section of the steering file 

477 self.filter_register.new_only = original_config.get(self.new_only_flag, True) 

478 

479 # we need to check if the configuration object is of type 1 or type 2 

480 if any([name for name in [self.name, self.replica_name] if name in original_config]): 

481 # one of the two names (the base or the replica) must be present in case of option 1 

482 # we start from the base name. If not there, then take an empty dict 

483 option1_config_base = original_config.get(self.name, {}) 

484 if self.name != self.replica_name: 

485 # if there is the replica name, then update the base configuration with the replica value 

486 # we get the replica configuration 

487 option1_config_replica = original_config.get(self.replica_name, {}) 

488 

489 # let's check if the user wants to have inheritance default 

490 # by default is True 

491 inheritance = option1_config_replica.get('__inheritance__', True) 

492 if inheritance: 

493 # we update the base with the replica without changing the base 

494 option1_config_update = deep_update(option1_config_base, option1_config_replica, copy_first=True) 

495 else: 

496 # we do not use the base with the replica specific, we pass the replica as the updated 

497 option1_config_update = option1_config_replica 

498 

499 # we modify the type 1 original so that the table for the replica has the updated configuration 

500 # this is used for the filter configuration at the end. 

501 original_config[self.replica_name] = option1_config_update 

502 else: 

503 # there is not replica, so the update is equal to the base. 

504 option1_config_update = option1_config_base 

505 

506 self._config = option1_config_update 

507 if 'create_standard_tables' in original_config: 

508 self.create_standard_tables = bool(original_config['create_standard_tables']) 

509 else: 

510 # for type 2 we are already good to go 

511 self._config = original_config 

512 

513 filter_config = deepcopy(original_config) 

514 

515 def _sanitize_filter_config(processor_name: str) -> None: 

516 processor_config = filter_config.get(processor_name) 

517 if not isinstance(processor_config, dict): 

518 return 

519 filter_table = processor_config.get('__filter__') 

520 if not isinstance(filter_table, dict): 

521 return 

522 

523 sanitized_table: dict[str, Any] = {} 

524 for model_name, model_config in filter_table.items(): 

525 if not isinstance(model_config, dict): 

526 sanitized_table[model_name] = model_config 

527 continue 

528 

529 model_config_copy = deepcopy(model_config) 

530 if not bool(model_config_copy.pop('__enable__', True)): 

531 continue 

532 

533 for field_name, field_value in list(model_config_copy.items()): 

534 if ( 

535 isinstance(field_value, dict) 

536 and not ('op' in field_value and 'value' in field_value) 

537 and '__enable__' in field_value 

538 ): 

539 field_enabled = bool(field_value.pop('__enable__', True)) 

540 if not field_enabled: 540 ↛ 533line 540 didn't jump to line 533 because the condition on line 540 was always true

541 model_config_copy.pop(field_name, None) 

542 

543 conditionals = model_config_copy.get('__conditional__') 

544 if isinstance(conditionals, list): 

545 filtered_conditionals: list[Any] = [] 

546 for conditional in conditionals: 

547 if isinstance(conditional, dict): 

548 conditional_enabled = bool(conditional.pop('__enable__', True)) 

549 if not conditional_enabled: 

550 continue 

551 filtered_conditionals.append(conditional) 

552 model_config_copy['__conditional__'] = filtered_conditionals 

553 

554 sanitized_table[model_name] = model_config_copy 

555 

556 processor_config['__filter__'] = sanitized_table 

557 

558 _sanitize_filter_config(self.replica_name) 

559 

560 for key, value in self._config.items(): 

561 if key in self._processor_parameters: 

562 type_: ParameterType = type(self.get_parameter(key).value) # type: ignore[valid-type] # TypeVar used at runtime for dynamic type inference 

563 self.set_parameter_value(key, type_(value)) # type: ignore[misc] # runtime call on dynamically-inferred type 

564 elif key == '__filter__': 

565 # we got a filter table! 

566 # it should contain one table for each model 

567 # we add all the names to a list for deferred initialisation 

568 flt_table = self._config[key] 

569 if isinstance(flt_table, dict): 

570 for model_name, model_config in flt_table.items(): 

571 if isinstance(model_config, dict) and not bool(model_config.get('__enable__', True)): 

572 continue 

573 flt_list.append(f'{self.replica_name}.__filter__.{model_name}') 

574 elif key == '__logic__': 

575 # we got a filter logic string 

576 # we store it in the filter register directly 

577 self.filter_register._logic = self._config[key] 

578 elif key == '__new_only__': 

579 # we got a new only boolean, we store it in the filter register 

580 self.filter_register.new_only = self._config[key] 

581 

582 # only now, after the configuration file has been totally read, we can do the real filter initialisation. 

583 # This is to be sure that if there were a GlobalFilter table, this has been read. 

584 # The global filter region will be used as a starting point for the construction of a new filter (default 

585 # parameter in the from_conf class method). 

586 for flt_name in flt_list: 

587 model_name = flt_name.split('.')[-1] 

588 self.filter_register[model_name] = mafw.db.db_filter.ModelFilter.from_conf(flt_name, filter_config) 

589 

590 @ensure_parameter_registration 

591 def _overrule_kws_parameters(self) -> None: 

592 """ 

593 Override processor parameters with values from keyword arguments. 

594 

595 This method applies parameter values passed as keyword arguments during processor 

596 initialisation. It ensures that the parameter types match the expected types 

597 before setting the values. 

598 

599 .. seealso:: 

600 :meth:`_register_parameters`, :meth:`_load_parameter_configuration`, 

601 :meth:`set_parameter_value` 

602 """ 

603 for key, value in self._kwargs.items(): 

604 if key in self._processor_parameters: 

605 type_: ParameterType = type(self.get_parameter(key).value) # type: ignore[valid-type] # TypeVar used at runtime for dynamic type inference 

606 self.set_parameter_value(key, type_(value)) # type: ignore[misc] # runtime call on dynamically-inferred type 

607 

608 def validate_configuration(self) -> None: 

609 """ 

610 Validate the configuration provided via the processor parameters. 

611 

612 Method to be implemented by subclasses if a configuration validation is needed. 

613 

614 The method should silently check for the proper configuration, if this is not obtained, 

615 then the :exc:`.InvalidConfigurationError` must be raised. 

616 

617 .. versionadded:: v2.0.0 

618 """ 

619 pass 

620 

621 def _check_method_overload(self) -> None: 

622 """ 

623 Check if the user overloaded the required methods. 

624 

625 Depending on the loop type, the user must overload different methods. 

626 This method is doing the check and if the required methods are not overloaded a warning is emitted. 

627 """ 

628 methods_dict: dict[LoopType, list[str]] = { 

629 LoopType.WhileLoop: ['while_condition'], 

630 LoopType.ForLoop: ['get_items'], 

631 LoopType.ParallelForLoop: ['get_items'], 

632 LoopType.ParallelForLoopWithQueue: ['get_items'], 

633 } 

634 required_methods: list[str] = methods_dict.get(self.loop_type, []) 

635 for method in required_methods: 

636 if getattr(type(self), method) == getattr(Processor, method): 

637 warnings.warn( 

638 MissingOverloadedMethod( 

639 '%s was not overloaded. The process execution workflow might not work.' % method 

640 ) 

641 ) 

642 

643 @classmethod 

644 def _apply_super_call_wrappers(cls) -> None: 

645 """ 

646 Wraps overridden methods so the class can detect whether they called `super()`. 

647 

648 This method runs immediately after the class is created (see :class:`.processor.ProcessorMeta`). For every 

649 method that we expect scientists to extend (start, finish, etc.) we replace their implementation with a 

650 wrapper. The wrapper resets a per-instance flag, invokes the real override, and only after that method returns 

651 it checks whether the real `super()` was ever reached; if not, it emits a :class:`~mafw.mafw_errors.MissingSuperCall` 

652 warning. In other words, the wiring happens while the subclass is defined, and the actual smoke test executes 

653 each time the method runs. 

654 """ 

655 methods = getattr(cls, '_methods_to_be_checked_for_super', ()) 

656 for method in methods: 

657 if method not in cls.__dict__: 

658 continue 

659 if not any(hasattr(base, method) for base in cls.__mro__[1:]): 

660 continue 

661 original: Callable[..., Any] = getattr(cls, method) 

662 # we are adding an attribute to the method, it looks strange, but it is possible 

663 # in this way we avoid wrapping the same method more than once. 

664 if getattr(original, '_mafw_super_check_wrapped', False): 

665 continue 

666 

667 def _make_wrapper( 

668 __orig: Callable[..., Any], 

669 __method: str, # pragma: no cover 

670 ) -> Callable[..., Any]: 

671 @wraps(__orig) 

672 def _wrapper(self: Any, *args: Any, **kwargs: Any) -> Any: 

673 # self is the processor instance. 

674 # _reset_super_call_flag is resetting the call status 

675 # for method as False 

676 self._reset_super_call_flag(__method) 

677 # in the base method, the super call flag is set to True 

678 result = __orig(self, *args, **kwargs) 

679 # if the super call flag is not True, then it is because the base 

680 # method was not called. 

681 # emit the warning and return the original method return value 

682 if not self._did_call_super(__method): 

683 warnings.warn( 

684 MissingSuperCall( 

685 'The overloaded %s is not invoking its super method. The processor might not work.' 

686 % __method 

687 ) 

688 ) 

689 return result 

690 

691 return _wrapper 

692 

693 # we create a wrapped method from the original 

694 wrapper = _make_wrapper(original, method) 

695 # we set a flag for the method to avoid multiple wrapping 

696 wrapper._mafw_super_check_wrapped = True # type: ignore[attr-defined] 

697 setattr(cls, method, wrapper) 

698 

699 def _reset_super_call_flag(self, method: str) -> None: 

700 """ 

701 Reset the super-call flag for a method. 

702 """ 

703 self._super_call_flags[method] = False 

704 

705 def _mark_super_call(self, method: str) -> None: 

706 """ 

707 Mark a method as having called its super implementation. 

708 """ 

709 self._super_call_flags[method] = True 

710 

711 def _did_call_super(self, method: str) -> bool: 

712 """ 

713 Check whether a method called its super implementation. 

714 """ 

715 return self._super_call_flags.get(method, False) 

716 

717 @ensure_parameter_registration 

718 def dump_parameter_configuration(self, option: int = 1) -> dict[str, Any]: 

719 """ 

720 Dumps the processor parameter values in a dictionary. 

721 

722 The snippet below explains the meaning of `option`. 

723 

724 .. code-block:: python 

725 

726 # option 1 

727 conf_dict1 = { 

728 'Processor': {'param1': 5, 'input_table': 'my_table'} 

729 } 

730 

731 # option 2 

732 conf_dict2 = {'param1': 5, 'input_table': 'my_table'} 

733 

734 In the case of option 1, the replica aware name (:meth:`.replica_name`) will be used as a key for the 

735 configuration dictionary. 

736 

737 .. versionchanged:: v2.0.0 

738 With option 1, using :meth:`.replica_name` instead of :attr:`~.processor.Processor.name` as key of the configuration 

739 dictionary. 

740 

741 :param option: Select the dictionary style. Defaults to 1. 

742 :type option: int, Optional 

743 :return: A parameter configuration dictionary. 

744 :rtype: dict 

745 """ 

746 inner_dict = {} 

747 for key, value in self._processor_parameters.items(): 

748 inner_dict[key] = value.value 

749 

750 if option == 1: 

751 outer_dict = {self.replica_name: inner_dict} 

752 elif option == 2: 

753 outer_dict = inner_dict 

754 else: 

755 log.warning('Unknown option %s. Using option 2' % option) 

756 outer_dict = inner_dict 

757 return outer_dict 

758 

759 @ensure_parameter_registration 

760 def get_parameter(self, name: str) -> PassiveParameter[Any]: 

761 """ 

762 Gets the processor parameter named name. 

763 

764 :param name: The name of the parameter. 

765 :type name: str 

766 :return: The processor parameter 

767 :rtype: processor.PassiveParameter 

768 :raises ProcessorParameterError: If a parameter with `name` is not registered. 

769 """ 

770 if name in self._processor_parameters: 

771 return self._processor_parameters[name] 

772 raise ProcessorParameterError(f'No parameter ({name}) found for {self.name}') 

773 

774 @ensure_parameter_registration 

775 def get_parameters(self) -> dict[str, PassiveParameter[Any]]: 

776 """ 

777 Returns the full dictionary of registered parameters for this processor. 

778 

779 Useful when dumping the parameter specification in a configuration file, for example. 

780 

781 :return: The dictionary with the registered parameters. 

782 :rtype: dict[str, processor.PassiveParameter[ParameterType] 

783 """ 

784 return self._processor_parameters 

785 

786 @ensure_parameter_registration 

787 def delete_parameter(self, name: str) -> None: 

788 """ 

789 Deletes a processor parameter. 

790 

791 :param name: The name of the parameter to be deleted. 

792 :type name: str 

793 :raises ProcessorParameterError: If a parameter with `name` is not registered. 

794 """ 

795 if name in self._processor_parameters: 

796 del self._processor_parameters[name] 

797 else: 

798 raise ProcessorParameterError(f'No parameter ({name}) found for {self.name}') 

799 

800 @ensure_parameter_registration 

801 def set_parameter_value(self, name: str, value: ParameterType) -> None: 

802 """ 

803 Sets the value of a processor parameter. 

804 

805 :param name: The name of the parameter to be deleted. 

806 :type name: str 

807 :param value: The value to be assigned to the parameter. 

808 :type value: ParameterType 

809 :raises ProcessorParameterError: If a parameter with `name` is not registered. 

810 """ 

811 if name in self._processor_parameters: 

812 self._processor_parameters[name].value = value 

813 else: 

814 raise ProcessorParameterError(f'No parameter ({name}) found for {self.name}') 

815 

816 def get_filter(self, model_name: str) -> mafw.db.db_filter.ModelFilter: 

817 """ 

818 Returns a registered :class:`~mafw.db.db_filter.ModelFilter` via the model name. 

819 

820 If a filter for the provided model_name does not exist, a KeyError is raised. 

821 

822 :param model_name: The model name for which the filter will be returned. 

823 :type model_name: str 

824 :return: The registered filter 

825 :rtype: mafw.db.db_filter.ModelFilter 

826 :raises: KeyError is a filter with the give name is not found. 

827 """ 

828 return self.filter_register[model_name] 

829 

830 def on_processor_status_change(self, old_status: ProcessorStatus, new_status: ProcessorStatus) -> None: 

831 """ 

832 Callback invoked when the processor status is changed. 

833 

834 :param old_status: The old processor status. 

835 :type old_status: ProcessorStatus 

836 :param new_status: The new processor status. 

837 :type new_status: ProcessorStatus 

838 """ 

839 self._user_interface.change_of_processor_status(self.name, old_status, new_status) 

840 

841 def on_looping_status_set(self, status: LoopingStatus) -> None: 

842 """ 

843 Call back invoked when the looping status is set. 

844 

845 The user can overload this method according to the needs. 

846 

847 :param status: The set looping status. 

848 :type status: LoopingStatus 

849 """ 

850 if status == LoopingStatus.Skip: 

851 log.warning('Skipping item %s' % self.i_item) 

852 elif status == LoopingStatus.Abort: 

853 log.error('Looping has been aborted') 

854 elif status == LoopingStatus.Quit: 

855 log.warning('Looping has been quit') 

856 

857 def format_progress_message(self) -> None: 

858 """Customizes the progress message with information about the current item. 

859 

860 The user can overload this method in order to modify the message being displayed during the process loop with 

861 information about the current item. 

862 

863 The user can access the current value, its position in the looping cycle and the total number of items using 

864 :attr:`.processor.Processor.item`, :obj:`.processor.Processor.i_item` and :obj:`.processor.Processor.n_item`. 

865 """ 

866 pass 

867 

868 @contextlib.contextmanager 

869 def _thread_loop_context(self, i_item: int, n_item: int, item: Any) -> Iterator[None]: 

870 """ 

871 Context manager to set thread-local loop attributes for parallel execution. 

872 

873 :param i_item: Item index for the loop. 

874 :type i_item: int 

875 :param n_item: Total number of items in the loop. 

876 :type n_item: int 

877 :param item: Item payload. 

878 :type item: Any 

879 """ 

880 self._thread_local.in_worker = True 

881 self._thread_local.i_item = i_item 

882 self._thread_local.n_item = n_item 

883 self._thread_local.item = item 

884 self._thread_local.looping_status = LoopingStatus.Continue 

885 try: 

886 yield 

887 finally: 

888 for name in ('i_item', 'n_item', 'item', 'looping_status', 'in_worker'): 

889 if hasattr(self._thread_local, name): 889 ↛ 888line 889 didn't jump to line 888 because the condition on line 889 was always true

890 delattr(self._thread_local, name) 

891 

892 def _in_thread_context(self) -> bool: 

893 """Return True when running inside a parallel worker thread.""" 

894 return bool(getattr(self._thread_local, 'in_worker', False)) 

895 

896 @property 

897 def item(self) -> Any: 

898 """The current item of the loop.""" 

899 if self._in_thread_context() and hasattr(self._thread_local, 'item'): 

900 return self._thread_local.item 

901 return self._item 

902 

903 @item.setter 

904 def item(self, value: Any) -> None: 

905 if self._in_thread_context(): 

906 self._thread_local.item = value 

907 else: 

908 self._item = value 

909 

910 @property 

911 def i_item(self) -> int: 

912 """The enumeration of the current item being processed.""" 

913 if self._in_thread_context() and hasattr(self._thread_local, 'i_item'): 

914 return cast(int, self._thread_local.i_item) 

915 return self._i_item 

916 

917 @i_item.setter 

918 def i_item(self, value: int) -> None: 

919 if self._in_thread_context(): 

920 self._thread_local.i_item = value 

921 else: 

922 self._i_item = value 

923 

924 @property 

925 def n_item(self) -> int | None: 

926 """The total number of items to be processed or None for an undefined loop""" 

927 if self._in_thread_context() and hasattr(self._thread_local, 'n_item'): 

928 return cast(int | None, self._thread_local.n_item) 

929 return self._n_item 

930 

931 @n_item.setter 

932 def n_item(self, value: int | None) -> None: 

933 if self._in_thread_context(): 

934 self._thread_local.n_item = value 

935 else: 

936 self._n_item = value 

937 

938 @property 

939 def looping_status(self) -> LoopingStatus: 

940 """The looping status for the current thread context.""" 

941 if self._in_thread_context(): 

942 value = getattr(self._thread_local, 'looping_status', LoopingStatus.Continue) 

943 else: 

944 value = self._looping_status 

945 if hasattr(self, 'on_looping_status_get'): 

946 self.on_looping_status_get(value) 

947 return value 

948 

949 @looping_status.setter 

950 def looping_status(self, value: LoopingStatus) -> None: 

951 if self._in_thread_context(): 

952 current = getattr(self._thread_local, 'looping_status', LoopingStatus.Continue) 

953 self._thread_local.looping_status = value 

954 else: 

955 current = self._looping_status 

956 self._looping_status = value 

957 if current != value: 

958 if hasattr(self, 'on_looping_status_change'): 

959 self.on_looping_status_change(current, value) 

960 else: 

961 if hasattr(self, 'on_looping_status_set'): 961 ↛ exitline 961 didn't return from function 'looping_status' because the condition on line 961 was always true

962 self.on_looping_status_set(value) 

963 

964 @property 

965 def unique_name(self) -> str: 

966 """Returns the unique name for the processor.""" 

967 return f'{self.name}_{self.unique_id}' 

968 

969 @property 

970 def replica_name(self) -> str: 

971 """ 

972 Returns the replica aware name of the processor. 

973 

974 If no replica_id is specified, then return the pure name, otherwise join the two string using the '#' symbol. 

975 

976 .. versionadded:: v2.0.0 

977 

978 :return: The replica aware name of the processor. 

979 :rtype: str 

980 """ 

981 if self.replica_id is None: 

982 return self.name 

983 else: 

984 return self.name + '#' + self.replica_id 

985 

986 @property 

987 def local_resource_acquisition(self) -> bool: 

988 """ 

989 Checks if resources should be acquired locally. 

990 

991 When the processor is executed in stand-alone mode, it is responsible to acquire and release its own external 

992 resources, but when it is executed from a ProcessorList, then is a good practice to share and distribute 

993 resources among the whole processor list. In this case, resources should not be acquired locally by the 

994 single processor, but from the parent execution context. 

995 

996 :return: True if resources are to be acquired locally by the processor. False, otherwise. 

997 :rtype: bool 

998 """ 

999 return self._resource_acquisition 

1000 

1001 @local_resource_acquisition.setter 

1002 def local_resource_acquisition(self, flag: bool) -> None: 

1003 self._resource_acquisition = flag 

1004 

1005 @property 

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

1007 """ 

1008 Returns the database instance 

1009 

1010 :return: A database object. 

1011 :raises MissingDatabase: If the database connection has not been established. 

1012 """ 

1013 if self._database is None: 

1014 raise MissingDatabase('Database connection not initialized') 

1015 return self._database 

1016 

1017 def execute(self) -> None: 

1018 """Execute the processor tasks. 

1019 

1020 This method works as a dispatcher, reassigning the call to a more specific execution implementation depending 

1021 on the :attr:`~mafw.processor.Processor.loop_type`. 

1022 """ 

1023 dispatcher: dict[LoopType, Callable[[], None]] = { 

1024 LoopType.SingleLoop: self._execute_single, 

1025 LoopType.ForLoop: self._execute_for_loop, 

1026 LoopType.ParallelForLoop: self._execute_for_loop, 

1027 LoopType.ParallelForLoopWithQueue: self._execute_for_loop, 

1028 LoopType.WhileLoop: self._execute_while_loop, 

1029 } 

1030 dispatcher[self.loop_type]() 

1031 

1032 @staticmethod 

1033 def _compute_default_max_workers() -> int: 

1034 """ 

1035 Helper to compute the default number of workers for parallel execution. 

1036 

1037 Returns min(32, cpu_count + 4). 

1038 """ 

1039 return min(32, (os.cpu_count() or 1) + 4) 

1040 

1041 def _execute_single(self) -> None: 

1042 """Execute the processor in single mode. 

1043 

1044 **Private method**. Do not overload nor invoke it directly. The :meth:`execute` method will call the 

1045 appropriate implementation depending on the processor LoopType. 

1046 """ 

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

1048 self.acquire_resources() 

1049 self._wall_clock_start = time.perf_counter() 

1050 self.start() 

1051 self.processor_status = ProcessorStatus.Run 

1052 self.process() 

1053 self.finish() 

1054 

1055 def _execute_for_loop(self) -> None: 

1056 """Executes the processor within a for loop. 

1057 

1058 **Private method**. Do not overload nor invoke it directly. The :meth:`execute` method will call the 

1059 appropriate implementation depending on the processor LoopType. 

1060 """ 

1061 

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

1063 self.acquire_resources() 

1064 # we cannot use a Timer context here to measure the whole duration because it spans 

1065 # over different methods. Instead we directly use a performance clock. 

1066 self._wall_clock_start = time.perf_counter() 

1067 self.start() 

1068 

1069 # get the input item list and filter it 

1070 item_list = self.get_items() 

1071 

1072 # get the total number of items. 

1073 self.n_item = len(item_list) 

1074 

1075 # turn the processor status to run 

1076 self.processor_status = ProcessorStatus.Run 

1077 

1078 # create a new task in the progress bar interface 

1079 self._user_interface.create_task(self.replica_name, self.description, completed=0, total=self.n_item) 

1080 

1081 # verify if we can use parallel for loop. If not, switch back to a serial for loop. 

1082 if ( 

1083 self.loop_type in (LoopType.ParallelForLoop, LoopType.ParallelForLoopWithQueue) 

1084 and not is_free_threading() 

1085 ): 

1086 warnings.warn( 

1087 'Parallel for-loop requires free-threading; falling back to serial for loop.', 

1088 stacklevel=2, 

1089 ) 

1090 self.loop_type = LoopType.ForLoop 

1091 

1092 if self.loop_type == LoopType.ParallelForLoopWithQueue: 

1093 self._process_parallel_for_loop_with_queue(item_list) 

1094 elif self.loop_type == LoopType.ParallelForLoop: 

1095 self._process_parallel_for_loop(item_list) 

1096 else: 

1097 self._process_for_loop(item_list) 

1098 

1099 self._user_interface.update_task(self.replica_name, completed=self.n_item, total=self.n_item) 

1100 

1101 self.finish() 

1102 

1103 def _build_loop_item(self) -> LoopItem: 

1104 """ 

1105 Build a LoopItem payload for the current loop context. 

1106 

1107 :return: The LoopItem payload. 

1108 :rtype: mafw.models.LoopItem 

1109 """ 

1110 return LoopItem(self.i_item, int(self.n_item or 0), self.item) 

1111 

1112 def _build_loop_result(self, payload: Any, duration: float) -> LoopResult: 

1113 """ 

1114 Build a LoopResult payload for the current loop context. 

1115 

1116 :param payload: The optional payload returned by process. 

1117 :type payload: Any 

1118 :param duration: Wall-clock duration of the item processing. 

1119 :type duration: float 

1120 :return: The LoopResult payload. 

1121 :rtype: mafw.models.LoopResult 

1122 """ 

1123 return LoopResult(self.i_item, int(self.n_item or 0), self.looping_status, payload, duration) 

1124 

1125 def _payload_annotation_matches(self, annotation: Any) -> bool: 

1126 """ 

1127 Check whether an annotation matches LoopItem or LoopResult. 

1128 

1129 Handles both ``typing.Union[X, Y]`` and the built-in ``X | Y`` union syntax. 

1130 

1131 :param annotation: The annotation to inspect. 

1132 :type annotation: Any 

1133 :return: True if the annotation matches LoopItem or LoopResult. 

1134 :rtype: bool 

1135 """ 

1136 import types 

1137 

1138 if annotation in (LoopItem, LoopResult): 

1139 return True 

1140 origin = get_origin(annotation) 

1141 if origin is Union or isinstance(annotation, types.UnionType): 

1142 return any(arg in (LoopItem, LoopResult) for arg in get_args(annotation)) 

1143 return False 

1144 

1145 def _call_with_optional_payload(self, func: Callable[..., Any], payload: Any) -> Any: 

1146 """ 

1147 Invoke a processor hook with an optional payload if the signature allows it. 

1148 

1149 :param func: The callable to invoke. 

1150 :type func: Callable 

1151 :param payload: The payload to pass if supported. 

1152 :type payload: Any 

1153 :return: The callable return value. 

1154 :rtype: Any 

1155 """ 

1156 signature = inspect.signature(func) 

1157 parameters = list(signature.parameters.values()) 

1158 if parameters and parameters[0].name == 'self': 

1159 parameters = parameters[1:] 

1160 if not parameters: 

1161 return func() 

1162 if any(param.kind in (param.VAR_POSITIONAL, param.VAR_KEYWORD) for param in parameters): 

1163 return func(payload) 

1164 first = parameters[0] 

1165 if first.kind in (first.POSITIONAL_ONLY, first.POSITIONAL_OR_KEYWORD): 1165 ↛ 1170line 1165 didn't jump to line 1170 because the condition on line 1165 was always true

1166 if first.name in ('item', 'loop_item', 'result', 'loop_result', 'payload'): 

1167 return func(payload) 

1168 if self._payload_annotation_matches(first.annotation): 1168 ↛ 1169line 1168 didn't jump to line 1169 because the condition on line 1168 was never true

1169 return func(payload) 

1170 return func() 

1171 

1172 def _process_for_loop(self, item_list: Collection[Any]) -> None: 

1173 """ 

1174 Process items with the standard serial for-loop. 

1175 

1176 :param item_list: The list of items to process. 

1177 :type item_list: Collection[Any] 

1178 """ 

1179 for self.i_item, self.item in enumerate(item_list): 

1180 self.looping_status = LoopingStatus.Continue 

1181 

1182 self.format_progress_message() 

1183 self._user_interface.display_progress_message(self.progress_message, self.i_item, self.n_item, 0.1) 

1184 

1185 loop_item = self._build_loop_item() 

1186 with Timer(suppress_message=True) as timer: 

1187 payload = self._call_with_optional_payload(self.process, loop_item) 

1188 self._process_durations.append(timer.duration) 

1189 

1190 loop_result = self._build_loop_result(payload, timer.duration) 

1191 

1192 if self.looping_status == LoopingStatus.Continue: 

1193 self._call_with_optional_payload(self.accept_item, loop_result) 

1194 elif self.looping_status == LoopingStatus.Skip: 

1195 self._call_with_optional_payload(self.skip_item, loop_result) 

1196 else: # Abort or Quit 

1197 break 

1198 

1199 self._user_interface.update_task(self.replica_name, increment=1) 

1200 

1201 def _process_parallel_for_loop(self, item_list: Collection[Any]) -> None: 

1202 """ 

1203 Process items in parallel using a thread pool. 

1204 

1205 :param item_list: The list of items to process. 

1206 :type item_list: Collection[Any] 

1207 """ 

1208 max_workers = self.max_workers 

1209 abort_event = threading.Event() 

1210 abort_lock = threading.Lock() 

1211 abort_status: LoopingStatus | None = None 

1212 

1213 def _set_abort(status: LoopingStatus) -> None: 

1214 nonlocal abort_status 

1215 with abort_lock: 

1216 if abort_status == LoopingStatus.Abort: 

1217 return 

1218 if status == LoopingStatus.Abort: 

1219 abort_status = LoopingStatus.Abort 

1220 elif abort_status is None: 1220 ↛ 1222line 1220 didn't jump to line 1222

1221 abort_status = LoopingStatus.Quit 

1222 abort_event.set() 

1223 

1224 def _worker(i_item: int, item: Any) -> tuple[int, Any, LoopingStatus, Any, float]: 

1225 with self._thread_loop_context(i_item, int(self.n_item or 0), item): 

1226 self.looping_status = LoopingStatus.Continue 

1227 loop_item = self._build_loop_item() 

1228 with Timer(suppress_message=True) as timer: 

1229 payload = self._call_with_optional_payload(self.process, loop_item) 

1230 status = self.looping_status 

1231 

1232 if status in (LoopingStatus.Abort, LoopingStatus.Quit): 

1233 _set_abort(status) 

1234 return i_item, item, status, payload, timer.duration 

1235 

1236 if abort_event.is_set(): 

1237 return i_item, item, status, payload, timer.duration 

1238 

1239 loop_result = self._build_loop_result(payload, timer.duration) 

1240 if status == LoopingStatus.Continue: 

1241 self._call_with_optional_payload(self.accept_item, loop_result) 

1242 elif status == LoopingStatus.Skip: 1242 ↛ 1245line 1242 didn't jump to line 1245 because the condition on line 1242 was always true

1243 self._call_with_optional_payload(self.skip_item, loop_result) 

1244 

1245 return i_item, item, status, payload, timer.duration 

1246 

1247 pending: set[Future[tuple[int, Any, LoopingStatus, Any, float]]] = set() 

1248 items_iter = iter(enumerate(item_list)) 

1249 

1250 def _submit_next() -> bool: 

1251 try: 

1252 idx, itm = next(items_iter) 

1253 except StopIteration: 

1254 return False 

1255 fut: Future[tuple[int, Any, LoopingStatus, Any, float]] = executor.submit(_worker, idx, itm) 

1256 pending.add(fut) 

1257 return True 

1258 

1259 with ThreadPoolExecutor(max_workers=max_workers) as executor: 

1260 while len(pending) < max_workers and _submit_next(): 

1261 pass 

1262 

1263 while pending: 

1264 done, _ = wait(pending, return_when=FIRST_COMPLETED) 

1265 for fut in done: 

1266 pending.remove(fut) 

1267 i_item, item, status, _payload, duration = fut.result() 

1268 self._process_durations.append(duration) 

1269 self.item = item 

1270 self.i_item = i_item 

1271 self.format_progress_message() 

1272 self._user_interface.display_progress_message(self.progress_message, i_item, self.n_item, 0.1) 

1273 self._user_interface.update_task(self.replica_name, increment=1) 

1274 

1275 if abort_event.is_set(): 

1276 continue 

1277 

1278 while len(pending) < max_workers and _submit_next(): 

1279 pass 

1280 

1281 if abort_status is not None: 

1282 self.looping_status = abort_status 

1283 

1284 def _process_parallel_for_loop_with_queue(self, item_list: Collection[Any]) -> None: 

1285 """ 

1286 Process items in parallel using a producer/consumer queue. 

1287 

1288 Items are processed in worker threads, while a dedicated consumer thread handles the post-processing hooks. 

1289 

1290 :param item_list: The list of items to process. 

1291 :type item_list: Collection[Any] 

1292 """ 

1293 # the idea is the following, we have a single consumer thread that is constantly trying to pull 

1294 # items out of a shared queue and we have a pool of producer threads that are putting items in the queue as 

1295 # long as there is space into it (back-pressure). 

1296 # it the queue is full, then the producer threads are set to sleep for a short interval of time and waken up 

1297 # again after some times for another attempt to write the output in the queue. 

1298 # when there are no more items to execute, the main thread is pushing a sentinel object into the queue 

1299 # marking the end of the processing, so that the consumer thread can be gracefully terminated. 

1300 max_workers = self.max_workers 

1301 result_queue: queue.Queue[object] = queue.Queue(maxsize=self.queue_size) 

1302 abort_event = threading.Event() 

1303 abort_lock = threading.Lock() 

1304 abort_status: LoopingStatus | None = None 

1305 sentinel = object() 

1306 

1307 def _set_abort(status: LoopingStatus) -> None: 

1308 nonlocal abort_status 

1309 with abort_lock: 

1310 if abort_status == LoopingStatus.Abort: 1310 ↛ 1311line 1310 didn't jump to line 1311 because the condition on line 1310 was never true

1311 return 

1312 if status == LoopingStatus.Abort: 1312 ↛ 1314line 1312 didn't jump to line 1314 because the condition on line 1312 was always true

1313 abort_status = LoopingStatus.Abort 

1314 elif abort_status is None: 

1315 abort_status = LoopingStatus.Quit 

1316 abort_event.set() 

1317 

1318 def _get_abort_status() -> LoopingStatus | None: 

1319 with abort_lock: 

1320 return abort_status 

1321 

1322 def _worker(batch_items: list[tuple[int, Any]]) -> None: 

1323 batch_results: list[tuple[LoopItem, LoopResult]] = [] 

1324 for i_item, item in batch_items: 

1325 if abort_event.is_set(): 1325 ↛ 1326line 1325 didn't jump to line 1326 because the condition on line 1325 was never true

1326 break 

1327 with self._thread_loop_context(i_item, int(self.n_item or 0), item): 

1328 self.looping_status = LoopingStatus.Continue 

1329 loop_item = self._build_loop_item() 

1330 with Timer(suppress_message=True) as timer: 

1331 payload = self._call_with_optional_payload(self.process, loop_item) 

1332 status = self.looping_status 

1333 if status in (LoopingStatus.Abort, LoopingStatus.Quit): 

1334 _set_abort(status) 

1335 loop_result = self._build_loop_result(payload, timer.duration) 

1336 batch_results.append((loop_item, loop_result)) 

1337 if status in (LoopingStatus.Abort, LoopingStatus.Quit): 

1338 break 

1339 if batch_results: 1339 ↛ exitline 1339 didn't return from function '_worker' because the condition on line 1339 was always true

1340 result_queue.put(batch_results) 

1341 

1342 def _consumer() -> None: 

1343 self.consumer_start() 

1344 try: 

1345 while True: 

1346 queued = result_queue.get() 

1347 if queued is sentinel: 

1348 break 

1349 batch_results = cast(list[tuple[LoopItem, LoopResult]], queued) 

1350 for loop_item, loop_result in batch_results: 

1351 with self._thread_loop_context(loop_item.i_item, loop_item.n_item, loop_item.payload): 

1352 self.looping_status = loop_result.looping_status 

1353 self.format_progress_message() 

1354 self._user_interface.display_progress_message( 

1355 self.progress_message, loop_item.i_item, self.n_item, 0.1 

1356 ) 

1357 self._process_durations.append(loop_result.duration) 

1358 self._user_interface.update_task(self.replica_name, increment=1) 

1359 

1360 if _get_abort_status() is None and loop_result.looping_status in ( 

1361 LoopingStatus.Continue, 

1362 LoopingStatus.Skip, 

1363 ): 

1364 self._call_with_optional_payload(self.consumer_process, loop_result) 

1365 finally: 

1366 self.consumer_finish() 

1367 

1368 pending: set[Future[None]] = set() 

1369 items_iter = iter(enumerate(item_list)) 

1370 

1371 def _submit_next() -> bool: 

1372 if abort_event.is_set(): 

1373 return False 

1374 batch_items: list[tuple[int, Any]] = [] 

1375 for _ in range(self.queue_batch_size): 

1376 try: 

1377 idx, itm = next(items_iter) 

1378 except StopIteration: 

1379 break 

1380 batch_items.append((idx, itm)) 

1381 if not batch_items: 

1382 return False 

1383 fut: Future[None] = executor.submit(_worker, batch_items) 

1384 pending.add(fut) 

1385 return True 

1386 

1387 consumer_thread = threading.Thread(target=_consumer, name=f'{self.name}-consumer') 

1388 consumer_thread.start() 

1389 

1390 try: 

1391 with ThreadPoolExecutor(max_workers=max_workers) as executor: 

1392 while len(pending) < max_workers and _submit_next(): 

1393 pass 

1394 

1395 while pending: 

1396 done, _ = wait(pending, return_when=FIRST_COMPLETED) 

1397 for fut in done: 

1398 pending.remove(fut) 

1399 fut.result() 

1400 

1401 if abort_event.is_set(): 

1402 continue 

1403 

1404 while len(pending) < max_workers and _submit_next(): 1404 ↛ 1405line 1404 didn't jump to line 1405 because the condition on line 1404 was never true

1405 pass 

1406 finally: 

1407 result_queue.put(sentinel) 

1408 consumer_thread.join() 

1409 

1410 if abort_status is not None: 

1411 self.looping_status = abort_status 

1412 

1413 def _execute_while_loop(self) -> None: 

1414 """Executes the processor within a while loop. 

1415 

1416 **Private method**. Do not overload nor invoke it directly. The :meth:`execute` method will call the 

1417 appropriate implementation depending on the processor LoopType. 

1418 """ 

1419 # it is a while loop, so a priori we don't know how many iterations we will have, nevertheless, we 

1420 # can have a progress bar with 'total' set to None, so that it goes in the so-called indeterminate 

1421 # progress. See https://rich.readthedocs.io/en/stable/progress.html#indeterminate-progress 

1422 # we initialise n_item outside the loop, because it is possible that the user has a way to define n_item 

1423 # and he can do it within the loop. 

1424 self.n_item = None 

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

1426 self.acquire_resources() 

1427 self._wall_clock_start = time.perf_counter() 

1428 self.start() 

1429 

1430 # turn the processor status to run 

1431 self.processor_status = ProcessorStatus.Run 

1432 

1433 self._user_interface.create_task(self.replica_name, self.description, completed=0, total=self.n_item) 

1434 

1435 # we are ready to start the looping. For statistics, we can count the iterations. 

1436 self.i_item = 0 

1437 while self.while_condition(): 

1438 # set the looping status to Continue. The user may want to change it in the process method. 

1439 self.looping_status = LoopingStatus.Continue 

1440 

1441 # send a message to the user interface 

1442 self.format_progress_message() 

1443 self._user_interface.display_progress_message( 

1444 self.progress_message, self.i_item, self.n_item, frequency=0.1 

1445 ) 

1446 

1447 # wrap the execution in a timer to measure how long it too for statistical reasons. 

1448 with Timer(suppress_message=True) as timer: 

1449 self.process() 

1450 self._process_durations.append(timer.duration) 

1451 

1452 # modify the loop depending on the looping status 

1453 if self.looping_status == LoopingStatus.Continue: 

1454 self.accept_item() 

1455 elif self.looping_status == LoopingStatus.Skip: 

1456 self.skip_item() 

1457 else: # equiv to if self.looping_status in [LoopingStatus.Abort, LoopingStatus.Quit]: 

1458 break 

1459 

1460 # update the progress bar. if self.n_item is still None, then the progress bar will show indeterminate 

1461 # progress. 

1462 self._user_interface.update_task(self.replica_name, self.i_item + 1, 1, self.n_item) 

1463 

1464 # now that the loop is finished, we know how many elements we processed 

1465 if self.n_item is None: 

1466 self.n_item = self.i_item 

1467 self._user_interface.update_task(self.replica_name, completed=self.n_item, total=self.n_item) 

1468 

1469 self.finish() 

1470 

1471 def acquire_resources(self) -> None: 

1472 """ 

1473 Acquires resources and add them to the resource stack. 

1474 

1475 The whole body of the :meth:`execute` method is within a context structure. The idea is that if any part of 

1476 the code inside should throw an exception that breaking the execution, we want to be sure that all stateful 

1477 resources are properly closed. 

1478 

1479 Since the number of resources may vary, the variable number of nested `with` statements has been replaced by 

1480 an `ExitStack <https://docs.python.org/3/library/contextlib.html#contextlib.ExitStack>`_. Resources, 

1481 like open files, timers, db connections, need to be added to the resource stacks in this method. 

1482 

1483 In the case a processor is being executed within a :class:`~mafw.processor.ProcessorList`, then some resources might be shared, and 

1484 for this reason they are not added to the stack. This selection can be done via the private 

1485 :attr:`local_resource_acquisition`. This is normally True, meaning that the processor will handle its resources 

1486 independently, but when the processor is executed from a :class:`~mafw.processor.ProcessorList`, this flag is automatically turned to 

1487 False. 

1488 

1489 If the user wants to add additional resources, he has to overload this method calling the super to preserve 

1490 the original resources. If he wants to have shared resources among different processors executed from inside 

1491 a processor list, he has to overload the :class:`~mafw.processor.ProcessorList` class as well. 

1492 """ 

1493 # Both the timer and the user interface will be added to the processor resource stack only if the processor is 

1494 # set to acquire its own resources. 

1495 # The timer and the user interface have in-built enter and exit method. 

1496 if self._resource_acquisition: 

1497 self.timer = self._resource_stack.enter_context(Timer(**self._timer_parameters)) 

1498 self._resource_stack.enter_context(self._user_interface) 

1499 

1500 # For the database it is a bit different. 

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

1502 # no database, nor configuration. 

1503 # we cannot do anything 

1504 pass 

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

1506 # no db, but we got a configuration. 

1507 # we can make a db. 

1508 # This processor will try to make a valid connection, and in case it succeeds, it will add the database to 

1509 # the resource stack. 

1510 # The database has an enter method, but it is to generate transaction. 

1511 # We will add the database.close via the callback method. 

1512 if 'DBConfiguration' in self._database_conf: 

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

1514 else: 

1515 conf = self._database_conf # type2 

1516 

1517 db_url, connection_parameters = build_connection_parameters(conf) 

1518 

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

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

1521 try: 

1522 self._database.connect() 

1523 except peewee.OperationalError as e: 

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

1525 raise e 

1526 database_proxy.initialize(self._database) 

1527 if self.create_standard_tables: 

1528 standard_tables = mafw_model_register.get_standard_tables() 

1529 self.database.create_tables(standard_tables) 

1530 for table in standard_tables: 

1531 table.init() 

1532 

1533 else: # equivalent to: if self._database is not None: 

1534 # we got a database, so very likely we are inside a processor list 

1535 # the connection has been already set and the initialisation as well. 

1536 # nothing else to do here. 

1537 # do not put the database in the exit stack. who create it has also to close it. 

1538 pass 

1539 

1540 def start(self) -> None: 

1541 """ 

1542 Start method. 

1543 

1544 The user can overload this method, including all steps that should be performed at the beginning of the 

1545 operation. 

1546 

1547 If the user decides to overload it, it should include a call to the super method. 

1548 """ 

1549 self._mark_super_call('start') 

1550 self.processor_status = ProcessorStatus.Start 

1551 self._remove_orphan_files() 

1552 

1553 def get_items(self) -> Collection[Any]: 

1554 """ 

1555 Returns the item collections for the processor loop. 

1556 

1557 This method must be overloaded for the processor to work. Generally, this is getting a list of rows from the 

1558 database, or a list of files from the disk to be processed. 

1559 

1560 :return: A collection of items for the loop 

1561 :rtype: Collection[Any] 

1562 """ 

1563 return [] 

1564 

1565 def while_condition(self) -> bool: 

1566 """ 

1567 Return the while condition 

1568 

1569 :return: True if the while loop has to continue, false otherwise. 

1570 :rtype: bool 

1571 """ 

1572 return False 

1573 

1574 def process(self) -> None: 

1575 """ 

1576 Processes the current item. 

1577 

1578 This is the core of the Processor, where the user has to define the calculations required. 

1579 

1580 In parallel for loops, the method can optionally accept a :class:`~mafw.models.loop_payloads.LoopItem` 

1581 parameter if the user prefers not to rely on thread-local access to :attr:`.processor.Processor.item`, :attr:`.processor.Processor.i_item` and 

1582 :attr:`.processor.Processor.n_item`. 

1583 """ 

1584 pass 

1585 

1586 def accept_item(self) -> None: 

1587 """ 

1588 Does post process actions on a successfully processed item. 

1589 

1590 Within the :meth:`process`, the user left the looping status to Continue, so it means that everything looks 

1591 good and this is the right place to perform database updates or file savings. 

1592 

1593 .. seealso: 

1594 Have a look at :meth:`skip_item` for what to do in case something went wrong. 

1595 

1596 In parallel for loops, the method can optionally accept a :class:`~mafw.models.loop_payloads.LoopResult` 

1597 parameter for direct access to the processed payload and looping status. 

1598 """ 

1599 pass 

1600 

1601 def skip_item(self) -> None: 

1602 """ 

1603 Does post process actions on a *NOT* successfully processed item. 

1604 

1605 Within the :meth:`process`, the user set the looping status to Skip, so it means that something went wrong 

1606 and here corrective actions can be taken if needed. 

1607 

1608 .. seealso: 

1609 Have a look at :meth:`accept_item` for what to do in case everything was OK. 

1610 

1611 In parallel for loops, the method can optionally accept a :class:`~mafw.models.loop_payloads.LoopResult` 

1612 parameter for direct access to the processed payload and looping status. 

1613 """ 

1614 pass 

1615 

1616 def consumer_start(self) -> None: 

1617 """ 

1618 Executes once in the consumer thread for the parallel queue loop. 

1619 

1620 This hook mirrors :meth:`start` but is only used with the queue-based parallel loop. 

1621 """ 

1622 pass 

1623 

1624 def consumer_process(self, loop_result: LoopResult | None = None) -> None: 

1625 """ 

1626 Handle processed items in the consumer thread for the parallel queue loop. 

1627 

1628 By default this dispatches to :meth:`accept_item` or :meth:`skip_item` based on the looping status. The method 

1629 can optionally accept a :class:`~mafw.models.loop_payloads.LoopResult` payload. 

1630 """ 

1631 if loop_result is None: 

1632 loop_result = self._build_loop_result(None, 0.0) 

1633 if self.looping_status == LoopingStatus.Continue: 

1634 self._call_with_optional_payload(self.accept_item, loop_result) 

1635 elif self.looping_status == LoopingStatus.Skip: 1635 ↛ exitline 1635 didn't return from function 'consumer_process' because the condition on line 1635 was always true

1636 self._call_with_optional_payload(self.skip_item, loop_result) 

1637 

1638 def consumer_finish(self) -> None: 

1639 """ 

1640 Executes once in the consumer thread after the queue has been drained. 

1641 """ 

1642 pass 

1643 

1644 def finish(self) -> None: 

1645 """ 

1646 Concludes the execution. 

1647 

1648 The user can reimplement this method if there are some conclusive tasks that must be achieved. 

1649 Always include a call to super(). 

1650 """ 

1651 self._mark_super_call('finish') 

1652 self.processor_status = ProcessorStatus.Finish 

1653 if self.looping_status == LoopingStatus.Abort: 

1654 self.processor_exit_status = ProcessorExitStatus.Aborted 

1655 self.print_process_statistics() 

1656 

1657 def print_process_statistics(self) -> None: 

1658 """ 

1659 Print the process statistics. 

1660 

1661 A utility method to display the fastest, the slowest and the average timing required to process on a single 

1662 item. This is particularly useful when the looping processor is part of a ProcessorList. 

1663 """ 

1664 if len(self._process_durations): 

1665 log.info('[cyan] Processed %s items.' % len(self._process_durations)) 

1666 log.info( 

1667 '[cyan] Fastest item process duration: %s ' 

1668 % pretty_format_duration(min(self._process_durations), n_digits=3) 

1669 ) 

1670 log.info( 

1671 '[cyan] Slowest item process duration: %s ' 

1672 % pretty_format_duration(max(self._process_durations), n_digits=3) 

1673 ) 

1674 log.info( 

1675 '[cyan] Average item process duration: %s ' 

1676 % pretty_format_duration((sum(self._process_durations) / len(self._process_durations)), n_digits=3) 

1677 ) 

1678 if self._wall_clock_start is not None: 

1679 total_duration = time.perf_counter() - self._wall_clock_start 

1680 else: 

1681 total_duration = sum(self._process_durations) 

1682 log.info('[cyan] Total process duration: %s' % pretty_format_duration(total_duration, n_digits=3)) 

1683 

1684 def _remove_orphan_files(self) -> None: 

1685 """ 

1686 Remove orphan files. 

1687 

1688 If a connection to the database is available, then the OrphanFile standard table is queried for all its entries, 

1689 and all the files are then removed. 

1690 

1691 The user can turn off this behaviour by switching the :attr:`~mafw.processor.Processor.remove_orphan_files` to False. 

1692 

1693 """ 

1694 if self._database is None or self.remove_orphan_files is False: 

1695 # no database connection or no wish to remove orphan files, it does not make sense to continue 

1696 return 

1697 

1698 try: 

1699 OrphanFile = cast(MAFwBaseModel, mafw_model_register.get_model('OrphanFile')) 

1700 except KeyError: 

1701 log.warning('OrphanFile table not found in DB. Please verify database integrity') 

1702 return 

1703 

1704 if TYPE_CHECKING: 

1705 assert hasattr(OrphanFile, '_meta') 

1706 

1707 orphan_files = OrphanFile.select().execute() 

1708 if len(orphan_files) != 0: 

1709 msg = f'[yellow]Pruning orphan files ({sum(len(f.filenames) for f in orphan_files)})...' 

1710 log.info(msg) 

1711 for orphan in orphan_files: 

1712 # filenames is a list of files: 

1713 for f in orphan.filenames: 

1714 f.unlink(missing_ok=True) 

1715 

1716 OrphanFile.delete().execute()