Coverage for src/mafw/processor/parameters.py: 99%

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

5Parameter descriptors for the Processor framework. 

6 

7This module defines the parameter descriptor system that underpins all 

8:class:`~mafw.processor.Processor` subclass configuration. It provides: 

9 

10- :class:`~mafw.processor.PassiveParameter` — the private storage backing each parameter. 

11- :class:`~mafw.processor.ActiveParameter` — the public descriptor interface for declaring 

12 processor parameters in a class body. 

13- :func:`ensure_parameter_registration` — a decorator guaranteeing parameters 

14 are registered before access. 

15- :data:`ParameterType` — a generic type variable binding the parameter value. 

16- :data:`_ParameterRegistry` — the ordered mapping of parameter names to their 

17 descriptors. 

18 

19The helper functions :func:`_copy_parent_parameter_definitions`, 

20:func:`_ensure_parameter_definitions`, and :func:`_validate_filter_schema` are 

21used by the metaclass to finalise parameter bookkeeping at class-creation time. 

22 

23.. versionadded:: 2.3 

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

25 maintainability and focused testing. 

26""" 

27 

28from __future__ import annotations 

29 

30import inspect 

31from collections import OrderedDict 

32from collections.abc import Callable, Iterator 

33from copy import deepcopy 

34from functools import wraps 

35from typing import ( 

36 TYPE_CHECKING, 

37 Any, 

38 Generic, 

39 TypeVar, 

40 cast, 

41 get_args, 

42 get_origin, 

43 get_type_hints, 

44) 

45 

46from mafw.db.db_model import MAFwBaseModel 

47from mafw.mafw_errors import ProcessorParameterError 

48from mafw.models.filter_schema import FilterSchema 

49from mafw.models.parameter_schema import ParameterSchema 

50 

51if TYPE_CHECKING: 

52 import mafw.processor 

53 

54# --------------------------------------------------------------------------- 

55# Type variables 

56# --------------------------------------------------------------------------- 

57 

58ParameterType = TypeVar('ParameterType') 

59"""Generic variable type for the :class:`~mafw.processor.ActiveParameter` and :class:`~mafw.processor.PassiveParameter`.""" 

60 

61_F = TypeVar('_F', bound=Callable[..., Any]) 

62"""Type variable for generic callable with any return value (private to avoid Sphinx collision).""" 

63 

64# --------------------------------------------------------------------------- 

65# Type aliases 

66# --------------------------------------------------------------------------- 

67 

68_ParameterRegistry = OrderedDict[str, 'ActiveParameter[Any]'] 

69"""Ordered mapping of external parameter names to their :class:`~mafw.processor.ActiveParameter` descriptors.""" 

70 

71 

72# --------------------------------------------------------------------------- 

73# Helper functions (used by ProcessorMeta during class creation) 

74# --------------------------------------------------------------------------- 

75 

76 

77def _copy_parent_parameter_definitions(owner: type[mafw.processor.Processor]) -> _ParameterRegistry: 

78 """ 

79 Build an ordered dictionary of ActiveParameters inherited from base classes. 

80 

81 Walks the MRO (skipping the class itself) and collects all parameter 

82 descriptors declared on ancestor classes. This ensures that subclasses 

83 inherit parameters defined higher in the hierarchy. 

84 

85 :param owner: The Processor subclass whose ancestors are inspected. 

86 :return: Aggregated parameter definitions from all base classes. 

87 :rtype: _ParameterRegistry 

88 """ 

89 definitions: _ParameterRegistry = OrderedDict() 

90 for base in owner.__mro__[1:]: 

91 base_definitions = getattr(base, '_parameter_definitions', None) 

92 if base_definitions: 

93 for name, descriptor in base_definitions.items(): 

94 definitions[name] = descriptor 

95 return definitions 

96 

97 

98def _ensure_parameter_definitions(owner: type[mafw.processor.Processor]) -> _ParameterRegistry: 

99 """ 

100 Return the class-level parameter registry, creating it if necessary. 

101 

102 If the class does not yet have its own ``_parameter_definitions`` dict 

103 (i.e. it has not been touched by the metaclass), one is created by 

104 copying definitions from the parent classes. 

105 

106 :param owner: The Processor subclass to inspect/initialise. 

107 :type owner: type[mafw.processor.Processor] 

108 :return: The parameter registry for *owner*. 

109 :rtype: _ParameterRegistry 

110 """ 

111 definitions: _ParameterRegistry | None = owner.__dict__.get('_parameter_definitions') 

112 if definitions is not None: 

113 return definitions 

114 

115 definitions = _copy_parent_parameter_definitions(owner) 

116 setattr(owner, '_parameter_definitions', definitions) 

117 return definitions 

118 

119 

120def _validate_filter_schema(owner: type[mafw.processor.Processor]) -> None: 

121 """ 

122 Validate the optional :class:`~mafw.models.filter_schema.FilterSchema` declared on a Processor. 

123 

124 Checks that: 

125 

126 - ``_filter_schema`` is an instance of :class:`FilterSchema` (if present). 

127 - ``root_model`` inherits from :class:`~mafw.db.db_model.MAFwBaseModel`. 

128 - All entries in ``allowed_models`` are unique MAFwBaseModel subclasses. 

129 

130 :param owner: The Processor subclass being validated. 

131 :type owner: type[mafw.processor.Processor] 

132 :raises ProcessorParameterError: If any of the above constraints are violated. 

133 """ 

134 schema = getattr(owner, '_filter_schema', None) 

135 if schema is None: 

136 return 

137 if not isinstance(schema, FilterSchema): 

138 raise ProcessorParameterError('Processor._filter_schema must be a FilterSchema instance') 

139 

140 root_model = schema.root_model 

141 if not (inspect.isclass(root_model) and issubclass(root_model, MAFwBaseModel)): 

142 raise ProcessorParameterError('FilterSchema.root_model must inherit from MAFwBaseModel') 

143 

144 # Collect all models to detect duplicates across root and allowed lists. 

145 seen_models = {root_model} 

146 for model in schema.allowed_models: 

147 if not (inspect.isclass(model) and issubclass(model, MAFwBaseModel)): 

148 raise ProcessorParameterError('FilterSchema.allowed_models must contain MAFwBaseModel subclasses') 

149 if model in seen_models: 

150 raise ProcessorParameterError(f'Model {model!r} already declared in FilterSchema') 

151 seen_models.add(model) 

152 

153 

154# --------------------------------------------------------------------------- 

155# Decorator 

156# --------------------------------------------------------------------------- 

157 

158 

159def ensure_parameter_registration(func: _F) -> _F: 

160 """ 

161 Decorator to ensure that processor parameters are registered before *func* executes. 

162 

163 This is applied to methods that access ``self._processor_parameters`` and need 

164 the registration step to have completed first. 

165 

166 :param func: The method to wrap. 

167 :type func: _F 

168 :return: The wrapped method. 

169 :rtype: _F 

170 :raises ProcessorParameterError: If applied to something that is not a Processor instance method. 

171 """ 

172 

173 @wraps(func) 

174 def wrapper(*args: mafw.processor.Processor, **kwargs: Any) -> _F: 

175 # Lazy import to avoid circular dependency at module load time. 

176 from mafw.processor.base import Processor as _Processor 

177 

178 # The first positional argument must be *self* — a Processor instance. 

179 if len(args) == 0: 

180 raise ProcessorParameterError( 

181 'Attempt to apply the ensure_parameter_registration to something different to a Processor subclass.' 

182 ) 

183 self = args[0] 

184 if not isinstance(self, _Processor): 

185 raise ProcessorParameterError( 

186 'Attempt to apply the ensure_parameter_registration to something different to a Processor subclass.' 

187 ) 

188 if self._parameter_registered is False: 

189 self._register_parameters() 

190 return cast(_F, func(*args, **kwargs)) 

191 

192 return cast(_F, wrapper) 

193 

194 

195# --------------------------------------------------------------------------- 

196# PassiveParameter — private storage 

197# --------------------------------------------------------------------------- 

198 

199 

200class PassiveParameter(Generic[ParameterType]): 

201 """ 

202 An helper class to store processor parameter value and metadata. 

203 

204 This class is the private interface used by the :class:`ActiveParameter` descriptor to store its value and metadata. 

205 

206 When a new :class:`.ActiveParameter` is added to a class, an instance of a PassiveParameter is added to the 

207 processor parameter :attr:`register <.processor.Processor._processor_parameters>`. 

208 

209 .. seealso:: 

210 

211 An explanation on how processor parameters work and should be used is given in :ref:`Understanding processor 

212 parameters <parameters>` 

213 

214 .. versionchanged:: v2.0.0 

215 

216 User should only use :class:`ActiveParameter` and never manually instantiate :class:`PassiveParameter`. 

217 """ 

218 

219 def __init__( 

220 self, name: str, value: ParameterType | None = None, default: ParameterType | None = None, help_doc: str = '' 

221 ): 

222 """ 

223 Constructor parameters: 

224 

225 :param name: The name of the parameter. It must be a valid python identifier. 

226 :type name: str 

227 :param value: The set value of the parameter. If None, then the default value will be used. Defaults to None. 

228 :type value: ParameterType, Optional 

229 :param default: The default value for the parameter. It is used if the :attr:`value` is not provided. Defaults to None. 

230 :type default: ParameterType, Optional 

231 :param help_doc: A brief explanation of the parameter. 

232 :type help_doc: str, Optional 

233 :raises ProcessorParameterError: if both `value` and `default` are not provided or if `name` is not a valid identifier. 

234 """ 

235 if not name.isidentifier(): 

236 raise ProcessorParameterError(f'{name} is not a valid python identifier.') 

237 

238 self.name = name 

239 

240 if value is not None: 

241 self._value: ParameterType = value 

242 self._is_set = True 

243 self._is_optional = False 

244 elif default is not None: 

245 self._value = default 

246 self._is_set = False 

247 self._is_optional = True 

248 else: 

249 raise ProcessorParameterError('Processor parameter cannot have both value and default value set to None') 

250 

251 self.doc = help_doc 

252 

253 def __rich_repr__(self) -> Iterator[Any]: 

254 yield 'name', self.name 

255 yield 'value', self.value, None 

256 yield 'help_doc', self.doc, '' 

257 

258 @property 

259 def is_set(self) -> bool: 

260 """ 

261 Property to check if the value has been set. 

262 

263 It is useful for optional parameter to see if the current value is the default one, or if the user set it. 

264 """ 

265 return self._is_set 

266 

267 @property 

268 def value(self) -> ParameterType: 

269 """ 

270 Gets the parameter value. 

271 

272 :return: The parameter value. 

273 :rtype: ParameterType 

274 :raises ProcessorParameterError: if both value and default were not defined. 

275 """ 

276 return self._value 

277 

278 @value.setter 

279 def value(self, value: ParameterType) -> None: 

280 """ 

281 Sets the parameter value. 

282 

283 :param value: The value to be set. 

284 :type value: ParameterType 

285 """ 

286 self._value = value 

287 self._is_set = True 

288 

289 @property 

290 def is_optional(self) -> bool: 

291 """ 

292 Property to check if the parameter is optional. 

293 

294 :return: True if the parameter is optional 

295 :rtype: bool 

296 """ 

297 return self._is_optional 

298 

299 def __repr__(self) -> str: 

300 args = ['name', 'value', 'doc'] 

301 values = [getattr(self, arg) for arg in args] 

302 return '{klass}({attrs})'.format( 

303 klass=self.__class__.__name__, 

304 attrs=', '.join(f'{k}={v!r}' for k, v in zip(args, values)), 

305 ) 

306 

307 

308# --------------------------------------------------------------------------- 

309# ActiveParameter — public descriptor interface 

310# --------------------------------------------------------------------------- 

311 

312 

313class ActiveParameter(Generic[ParameterType]): 

314 r""" 

315 The public interface to the processor parameter. 

316 

317 The behaviour of a :class:`.processor.Processor` can be customised by using processor parameters. The value of these 

318 parameters can be either set via a configuration file or directly when creating the class. 

319 

320 If the user wants to benefit from this facility, they have to add in the instance of the Processor subclass an 

321 ActiveParameter instance in this way: 

322 

323 .. code-block:: 

324 

325 class MyProcessor(Processor): 

326 

327 # this is the input folder 

328 input_folder = ActiveParameter('input_folder', Path(r'C:\'), help_doc='This is where to look for input files') 

329 

330 def __init__(self, *args, **kwargs): 

331 super().__init(*args, **kwargs) 

332 

333 # change the input folder to something else 

334 self.input_folder = Path(r'D:\data') 

335 

336 # get the value of the parameter 

337 print(self.input_folder) 

338 

339 The ActiveParameter is a `descriptor <https://docs.python.org/3/glossary.html#term-descriptor>`_, it means that 

340 when you create one of them, a lot of work is done behind the scene. 

341 

342 In simple words, a processor parameter is made by two objects: a public interface where the user can easily 

343 access the value of the parameter and a private interface where all other information (default, documentation...) 

344 is also stored. 

345 

346 The user does not have to take care of all of this. When a new ActiveParameter instance is added to the class as 

347 in the code snippet above, the private interface is automatically created and will stay in the class instance 

348 until the end of the class lifetime. 

349 

350 To access the private interface, the user can use the :meth:`.processor.Processor.get_parameter` method using the 

351 parameter 

352 name as a key. 

353 

354 The user can assign to an ActiveParameter almost any name. There are just a few invalid parameter names that are 

355 used for other purposes. The list of reserved names is available :attr:`here <reserved_names>`. Should the user 

356 inadvertently use a reserved named, a :exc:`.ProcessorParameterError` is raised. 

357 

358 .. seealso:: 

359 

360 The private counterpart in the :class:`.processor.PassiveParameter`. 

361 

362 An explanation on how processor parameters work and should be used is given in :ref:`Understanding processor 

363 parameters <parameters>` 

364 

365 The list of :attr:`reserved names <reserved_names>`. 

366 """ 

367 

368 reserved_names: list[str] = ['__logic__', '__filter__', '__new_only__', '__inheritance__', '__enable__'] 

369 """A list of names that cannot be used as processor parameter names. 

370 

371 - `__logic__` 

372 - `__filter__` 

373 - `__new_only__` 

374 - `__inheritance__` 

375 - `__enable__` 

376 """ 

377 

378 def __init__( 

379 self, name: str, value: ParameterType | None = None, default: ParameterType | None = None, help_doc: str = '' 

380 ): 

381 """ 

382 Constructor parameters: 

383 

384 :param name: The name of the parameter. 

385 :type name: str 

386 :param value: The initial value of the parameter. Defaults to None. 

387 :type value: ParameterType, Optional 

388 :param default: The default value of the parameter, to be used when ``value`` is not set., Defaults to None. 

389 :type default: ParameterType, Optional 

390 :param help_doc: An explanatory text describing the parameter. 

391 :type help_doc: str, Optional 

392 """ 

393 self._value = value 

394 self._default = default 

395 self._help_doc = help_doc 

396 self._external_name = self._validate_name(name) 

397 

398 def _validate_name(self, proposed_name: str) -> str: 

399 """ 

400 Validate that the proposed parameter name is not in the list of forbidden names. 

401 

402 This private method checks if the provided name is allowed for use as a processor parameter. 

403 Names that are listed in :attr:`reserved_names` cannot be used as parameter names. 

404 

405 :param proposed_name: The name to be validated for use as a processor parameter. 

406 :type proposed_name: str 

407 :return: The validated name if it passes the forbidden names check. 

408 :rtype: str 

409 :raises ProcessorParameterError: If the proposed name is in the list of forbidden names. 

410 """ 

411 if proposed_name not in self.reserved_names: 

412 return proposed_name 

413 raise ProcessorParameterError(f'Attempt to use a forbidden name ({proposed_name})') 

414 

415 def __set_name__(self, owner: type[mafw.processor.Processor], name: str) -> None: 

416 self.public_name = name 

417 self.private_name = f'param_{name}' 

418 self._owner = owner 

419 

420 # Register this descriptor in the class-level parameter registry. 

421 definitions = _ensure_parameter_definitions(owner) 

422 existing = definitions.get(self._external_name) 

423 if existing is not None and getattr(existing, '_owner', None) is owner: 

424 # Duplicate parameter name on the same class — defer the error until metaclass __init__. 

425 if not hasattr(owner, '_parameter_definition_error'): 425 ↛ 431line 425 didn't jump to line 431 because the condition on line 425 was always true

426 setattr( 

427 owner, 

428 '_parameter_definition_error', 

429 ProcessorParameterError(f'Duplicated parameter name ({self._external_name}).'), 

430 ) 

431 return 

432 definitions[self._external_name] = self 

433 

434 def __get__( 

435 self, obj: mafw.processor.Processor, obj_type: type[mafw.processor.Processor] 

436 ) -> ActiveParameter[ParameterType] | ParameterType: 

437 if obj is None: 

438 # Class-level access returns the descriptor itself. 

439 return self 

440 

441 # Retrieve the instance-level passive parameter holding the current value. 

442 param = cast(PassiveParameter[ParameterType], obj._processor_parameters[self._external_name]) 

443 return param.value 

444 

445 def __set__(self, obj: mafw.processor.Processor, value: ParameterType) -> None: 

446 param = obj._processor_parameters[self._external_name] 

447 param.value = value 

448 

449 def to_schema(self) -> ParameterSchema: 

450 """ 

451 Returns the static schema describing this parameter. 

452 

453 The schema is derived solely from the descriptor metadata and does not instantiate the owning processor. 

454 """ 

455 annotation = self._resolve_parameter_annotation() 

456 default_value = self._schema_default_value() 

457 help_text = self._help_doc or None 

458 is_list = self._is_list_annotation(annotation, default_value) 

459 is_dict = self._is_dict_annotation(annotation, default_value) 

460 return ParameterSchema( 

461 name=self._external_name, 

462 annotation=annotation, 

463 default=default_value, 

464 help=help_text, 

465 is_list=is_list, 

466 is_dict=is_dict, 

467 ) 

468 

469 def _resolve_parameter_annotation(self) -> type | None: 

470 """Resolve the type annotation for this parameter from the owner class hints.""" 

471 if not hasattr(self, 'public_name') or getattr(self, '_owner', None) is None: 

472 return None 

473 

474 target = getattr(self, '_owner', None) 

475 

476 try: 

477 hints = get_type_hints(target) 

478 except Exception: 

479 hints = {} 

480 

481 hint = hints.get(self.public_name) 

482 if hint is None: 

483 # Fall back to inferring from the default/value. 

484 fallback = self._default if self._default is not None else self._value 

485 if fallback is not None: 

486 return type(fallback) 

487 return None 

488 

489 origin = get_origin(hint) 

490 if origin is ActiveParameter: 

491 args = get_args(hint) 

492 if args: 

493 return cast(type | None, args[0]) 

494 return None 

495 return cast(type | None, hint) 

496 

497 def _schema_default_value(self) -> Any: 

498 """Return a safe copy of the default/initial value for schema reporting.""" 

499 candidate = self._default if self._default is not None else self._value 

500 if candidate is None: 

501 return None 

502 try: 

503 return deepcopy(candidate) 

504 except Exception: 

505 return candidate 

506 

507 def _is_list_annotation(self, annotation: type | None, default_value: Any) -> bool: 

508 """Check whether the annotation or default value indicates a list type.""" 

509 return self._matches_container(annotation, default_value, list) 

510 

511 def _is_dict_annotation(self, annotation: type | None, default_value: Any) -> bool: 

512 """Check whether the annotation or default value indicates a dict type.""" 

513 return self._matches_container(annotation, default_value, dict) 

514 

515 @staticmethod 

516 def _matches_container(annotation: type | None, default_value: Any, container: type) -> bool: 

517 """Generic check for whether annotation/default matches a given container type.""" 

518 type_hint = annotation 

519 if type_hint is None and default_value is not None: 

520 type_hint = type(default_value) 

521 

522 if type_hint is None: 

523 return False 

524 

525 origin = get_origin(type_hint) 

526 if origin is container: 

527 return True 

528 

529 if isinstance(type_hint, type) and issubclass(type_hint, container): 

530 return True 

531 

532 return False