Coverage for src / mafw / steering / models.py: 97%
220 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-30 16:10 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-30 16:10 +0000
1# Copyright 2026 European Union
2# Author: Bulgheroni Antonio (antonio.bulgheroni@ec.europa.eu)
3# SPDX-License-Identifier: EUPL-1.2
4"""Domain models for steering metadata construction.
6:Author: Bulgheroni Antonio
7:Description: Simple dataclasses that hold editable steering metadata without execution logic.
8"""
10from __future__ import annotations
12from abc import ABC
13from dataclasses import dataclass, field
14from enum import StrEnum
15from typing import TYPE_CHECKING, Any, Dict, List, Optional, cast
17from mafw.db.db_filter import ExprParser
19if TYPE_CHECKING:
20 from mafw.db.db_filter import ExprNode
22from mafw.enumerators import LogicalOp
25class ParameterSource(StrEnum):
26 """Origin of a processor parameter value in the GUI pipeline."""
28 CONFIG = 'config'
29 INHERITED = 'inherited'
30 DEFAULT = 'default'
33class ParameterSchemaStatus(StrEnum):
34 """Schema reconciliation status for a processor parameter."""
36 OK = 'OK'
37 DEPRECATED = 'Deprecated'
38 NEW = 'New'
39 UNKNOWN = 'Unknown'
42class ProcessorSchemaStatus(StrEnum):
43 """Schema reconciliation status for a processor."""
45 OK = 'OK'
46 UNKNOWN = 'Unknown'
49class FilterKind(StrEnum):
50 """The kind of filter configuration."""
52 MODEL = 'model'
53 FIELD = 'field'
54 CONDITIONAL = 'conditional'
57@dataclass
58class Condition:
59 """A single filter condition."""
61 operator: str | LogicalOp
62 value: Any
63 is_implicit: bool = True
64 is_valid: bool = True
67@dataclass
68class FieldInfo:
69 """Metadata about a model field."""
71 name: str
72 type: type
73 help_text: Optional[str] = None
76@dataclass
77class ModelInfo:
78 """Metadata about a database model."""
80 field_info: Dict[str, FieldInfo] = field(default_factory=dict)
83@dataclass
84class FilterConfig(ABC):
85 """Base configuration for a filter."""
87 name: str
88 kind: FilterKind = field(init=False)
89 enabled: bool = True
90 model: str = '' # Common field for all filters
93@dataclass
94class ModelFilterConfig(FilterConfig):
95 """
96 Filter applied to a specific database model.
98 Each field condition is AND'ed by default unless a custom
99 logical expression is provided via `logic`.
100 """
102 conditions: Dict[str, Condition] = field(default_factory=dict)
103 logic_str_original: Optional[str] = None
104 logic_buffer: Optional[str] = None
105 logic_ast: Optional[ExprNode] = None
106 logic_dirty: bool = False
107 logic_is_valid: bool = True
108 model_info: Optional[ModelInfo] = field(default=None, init=False)
110 @property
111 def logic(self) -> Optional[str]:
112 """Backward compatibility for logic string."""
113 return self.logic_str_original
115 @logic.setter
116 def logic(self, value: Optional[str]) -> None:
117 self.logic_str_original = value
118 if value is not None:
119 try:
120 self.logic_ast = ExprParser(value).parse()
121 except Exception:
122 self.logic_ast = None
123 else:
124 self.logic_ast = None
125 self.logic_dirty = True
127 def __post_init__(self) -> None:
128 self.kind = FilterKind.MODEL
129 self.fill_model_info()
131 def fill_model_info(self) -> None:
132 """Populate model_info by inspecting the database model."""
133 from mafw.db.db_model import mafw_model_register
135 if not self.model:
136 return
138 try:
139 model_class = mafw_model_register.get_model(self.model)
140 except KeyError:
141 return
143 self.model_info = ModelInfo()
144 for field_obj in model_class._meta.sorted_fields: # type: ignore[attr-defined]
145 f_type = _field_type_from_peewee(field_obj)
146 f_help = getattr(field_obj, 'help_text', None)
147 self.model_info.field_info[field_obj.name] = FieldInfo(name=field_obj.name, type=f_type, help_text=f_help)
150@dataclass
151class FieldFilterConfig(FilterConfig):
152 """
153 Filter applied to a single field of a specific model.
155 Conditions are AND'ed by default unless a custom logical
156 expression is defined via `logic`.
157 """
159 field_name: str = ''
160 conditions: Dict[str, Condition] = field(default_factory=dict)
161 logic_str_original: Optional[str] = None
162 logic_buffer: Optional[str] = None
163 logic_ast: Optional[ExprNode] = None
164 logic_dirty: bool = False
165 logic_is_valid: bool = True
167 @property
168 def logic(self) -> Optional[str]:
169 """Backward compatibility for logic string."""
170 return self.logic_str_original
172 @logic.setter
173 def logic(self, value: Optional[str]) -> None:
174 self.logic_str_original = value
175 if value is not None:
176 try:
177 self.logic_ast = ExprParser(value).parse()
178 except Exception:
179 self.logic_ast = None
180 else:
181 self.logic_ast = None
182 self.logic_dirty = True
184 def __post_init__(self) -> None:
185 self.kind = FilterKind.FIELD
188@dataclass
189class ConditionalFilterConfig(FilterConfig):
190 """
191 Conditional filter applied to a model.
193 Semantics:
194 IF condition THEN then_clause ELSE else_clause
195 """
197 condition: Optional[Condition] = None
198 then_clause: Optional[Condition] = None
199 else_clause: Optional[Condition] = None
200 condition_field: str = ''
201 then_field: str = ''
202 else_field: Optional[str] = None
204 auto_named: bool = False
205 model_info: Optional[ModelInfo] = field(default=None, init=False)
207 def __post_init__(self) -> None:
208 self.kind = FilterKind.CONDITIONAL
209 self.fill_model_info()
211 def is_complete(self) -> bool:
212 """Return whether every active clause of the conditional filter is valid."""
214 if not self.condition or not self.condition.is_valid:
215 return False
216 if not self.then_clause or not self.then_clause.is_valid:
217 return False
218 if self.else_clause and not self.else_clause.is_valid:
219 return False
220 return True
222 def fill_model_info(self) -> None:
223 """Populate model_info by inspecting the database model."""
224 from mafw.db.db_model import mafw_model_register
226 if not self.model:
227 return
229 try:
230 model_class = mafw_model_register.get_model(self.model)
231 except KeyError:
232 return
234 self.model_info = ModelInfo()
235 for field_obj in model_class._meta.sorted_fields: # type: ignore[attr-defined]
236 f_type = _field_type_from_peewee(field_obj)
237 f_help = getattr(field_obj, 'help_text', None)
238 self.model_info.field_info[field_obj.name] = FieldInfo(name=field_obj.name, type=f_type, help_text=f_help)
241@dataclass
242class ParameterConfig:
243 """Configuration and status for a single processor parameter.
245 :param name: The name of the parameter.
246 :param value: The current value of the parameter.
247 :param default: The default value of the parameter.
248 :param source: The source of the parameter value.
249 :param status: The schema reconciliation status of the parameter.
250 :param help: Documentation text for the parameter.
251 :param type: The type hint for the parameter.
252 """
254 name: str
255 value: Any = None
256 default: Any = None
257 source: ParameterSource = ParameterSource.CONFIG
258 status: ParameterSchemaStatus = ParameterSchemaStatus.OK
259 help: str | None = None
260 active_override: bool = False
261 type: Any = None
264@dataclass
265class ProcessorRef:
266 """Reference to a processor entry used in processors_to_run lists.
268 :param base_name: The canonical name of the processor without replica suffix.
269 :param replica: Optional replica identifier appended to the base name with ``#``.
270 """
272 base_name: str
273 replica: Optional[str] = None
275 @property
276 def full_name(self) -> str:
277 """Return the replica-aware full identifier for this reference."""
278 return f'{self.base_name}#{self.replica}' if self.replica else self.base_name
281@dataclass
282class ProcessorConfig:
283 """Configuration store for an individual processor or replica section.
285 :param name: Replica-aware section name (``Processor`` or ``Processor#Replica``).
286 :param parameters: Parameter configurations and status.
287 :param processor_status: Schema reconciliation status for the processor.
288 :param filters: Mapping from model name to list of filter configuration objects.
289 :param logic_str_original: Optional ``__logic__`` string stored in the per-filter table.
290 :param new_only: Optional ``__new_only__`` override.
291 :param inheritance: Optional ``__inheritance__`` flag for replicas.
292 :param has_filter_root: Whether the filter table includes a root entry.
293 """
295 name: str
296 parameters: Dict[str, ParameterConfig] = field(default_factory=dict)
297 processor_status: ProcessorSchemaStatus = ProcessorSchemaStatus.OK
298 filters: Dict[str, List[FilterConfig]] = field(default_factory=dict)
299 logic_str_original: Optional[str] = None
300 logic_buffer: Optional[str] = None
301 logic_ast: Optional[ExprNode] = None
302 logic_dirty: bool = False
303 logic_is_valid: bool = True
304 new_only: Optional[bool] = None
305 inheritance: Optional[bool] = None
306 has_filter_root: bool = True
307 schema_filter_models: List[str] = field(default_factory=list)
309 @property
310 def filter_logic(self) -> Optional[str]:
311 """Backward compatibility for filter_logic string."""
312 return self.logic_str_original
314 @filter_logic.setter
315 def filter_logic(self, value: Optional[str]) -> None:
316 self.logic_str_original = value
317 if value is not None:
318 try:
319 self.logic_ast = ExprParser(value).parse()
320 except Exception:
321 self.logic_ast = None
322 else:
323 self.logic_ast = None
324 self.logic_dirty = True
327@dataclass
328class GroupConfig:
329 """Configuration for a logical processor group section.
331 :param name: Section name used to reference the group in ``processors_to_run``.
332 :param processors: Ordered list of processor names and/or nested group names.
333 :param description: Optional documentation string for the group.
334 :param attributes: Additional arbitrary attributes for the group.
335 """
337 name: str
338 processors: List[str]
339 description: Optional[str] = None
340 attributes: Dict[str, Any] = field(default_factory=dict)
343@dataclass
344class GlobalSettings:
345 """Top-level globals that appear outside any processor or group table.
347 :param processors_to_run: Ordered list of processor or group names to execute.
348 :param analysis_name: Optional name for the analysis.
349 :param analysis_description: Optional description for the analysis.
350 :param new_only: Optional flag to process only new data.
351 :param create_standard_tables: Optional flag to create standard output tables.
352 """
354 processors_to_run: List[str] = field(default_factory=list)
355 analysis_name: Optional[str] = None
356 analysis_description: Optional[str] = None
357 new_only: Optional[bool] = None
358 create_standard_tables: Optional[bool] = None
361@dataclass
362class DBConfiguration:
363 """Database section content mirrored from the steering file.
365 :param url: Optional database URL connection string.
366 :param pragmas: SQLite pragma settings to apply on connection.
367 :param attributes: Additional arbitrary attributes for the database configuration.
368 :param enabled: Whether ``DBConfiguration`` should be serialized.
369 """
371 url: Optional[str] = None
372 pragmas: Dict[str, Any] = field(default_factory=dict)
373 attributes: Dict[str, Any] = field(default_factory=dict)
374 enabled: bool = True
377@dataclass
378class UIConfiguration:
379 """User-interface section content mirrored from the steering file.
381 :param interface: The interface type to use (e.g., ``rich``).
382 """
384 interface: str = 'rich'
387_FIELD_TYPE_STRINGS: dict[str, type] = {
388 'INT': int,
389 'INTEGER': int,
390 'BIGINT': int,
391 'SMALLINT': int,
392 'TINYINT': int,
393 'FLOAT': float,
394 'REAL': float,
395 'DOUBLE': float,
396 'DECIMAL': float,
397 'NUMERIC': float,
398 'CHAR': str,
399 'VARCHAR': str,
400 'TEXT': str,
401 'STRING': str,
402 'DATE': str,
403 'DATETIME': str,
404 'TIME': str,
405 'BOOLEAN': bool,
406 'BOOL': bool,
407 'AUTO': int,
408}
409"""Mapping from Peewee field_type strings to Python primitives."""
412def _normalize_field_type(field_type: Any) -> type:
413 """Normalize a field descriptor to a supported Python primitive."""
415 if field_type in (int, float, str, bool):
416 return cast(type, field_type)
417 if isinstance(field_type, str):
418 return _FIELD_TYPE_STRINGS.get(field_type.upper(), str)
419 return str
422def _field_type_from_peewee(field_obj: Any) -> type:
423 """Extract a primitive type from the given Peewee field definition."""
425 for attr in ('field_type', 'data_type'):
426 raw_type = getattr(field_obj, attr, None)
427 if raw_type is not None:
428 return _normalize_field_type(raw_type)
429 return str