Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 36 additions & 15 deletions docs/adr/003-pydantic-config.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# ADR-003: Config Pydantic con migrazione graduale da dict

**Status:** implemented (2026-04), bridge `_compat_*` rimosso (2026-05)
**Status:** superseded (2026-07-30) — sostituito da dataclass semplici (PR #435)

## Contesto

Expand All @@ -11,7 +11,7 @@ a ~20 sezioni annidate con tipi specifici (Path, liste, enum, bool da stringa).
Serviva un modo per validare il YAML all'ingresso con errori espliciti,
mantenendo la compatibilità con le config esistenti in `dataset-incubator`.

## Decisione
## Decisione originale

**Fase 1 (v1.0, 2026-02):** Pydantic v2 per il parsing + bridge `_compat_*` per
convertire i modelli in dict, mantenendo tutta la pipeline downstream su dict.
Expand All @@ -30,19 +30,40 @@ YAML → Pydantic models → _CompatModel wrapper → pipeline
dict-style .get() per retrocompat
```

## Conseguenze
## Conseguenze (all'epoca)

**Positive:**
- Errori di configurazione espliciti e leggibili (DCL001-DCL013)
- Campi legacy rifiutati con messaggio chiaro invece di warning ignorati
- Type checking progressivo (mypy ora rileva accessi a campi inesistenti)
- `_CompatModel.__eq__` confronta automaticamente con dict per test retrocompat
- Errori di configurazione espliciti e leggibili
- Campi legacy rifiutati con messaggio chiaro
- Type checking progressivo (mypy)
- Complessità del wrapper `_CompatModel`
- `isinstance(x, dict)` non funzionava più
- Doppia manutenzione per interfaccia dict

**Negative:**
- Complessità del wrapper `_CompatModel` (mantenere due interfacce sul mismo oggetto)
- `isinstance(x, dict)` nei consumatori non funziona più — richiesto refactor
- `exclude_unset=True` significa che campi non configurati non appaiono in model_dump
- Doppia manutenzione finché tutti i consumatori non migrano ad accesso tipizzato
## Perché è stato superseded

**Status attuale:** tutti i consumatori interni del toolkit migrati. Dataset-incubator
usa ancora l'interfaccia dict (compatibile via `_CompatModel.get()`).
L'architettura Pydantic + `_CompatModel` introduceva complessità sproporzionata
per il beneficio. Il wrapper `_CompatModel` doveva mantenere due interfacce
sullo stesso oggetto, e i consumatori downstream (soprattutto dataset-incubator)
continuavano a usare l'interfaccia dict.

**PR #435 (2026-07-30):** 24 modelli Pydantic → 1 dataclass `PipelineConfig`.

| Componente | Prima | Dopo |
|---|---|---|
| Modelli | 24 Pydantic models | 1 dataclass (`PipelineConfig`) |
| Parsing | Pydantic v2 | `yaml.safe_load` + validazione inline |
| Wrapper | `_CompatModel` (dict access) | Nessuno — dataclass pura |
| Righe nette | ~1.200 | ~300 (-1.089) |
| `isinstance(x, dict)` | Non funzionava | Funziona (`PipelineConfig` è un oggetto normale) |
| Type checking | mypy su Pydantic generics | mypy su dataclass semplici |

La validazione è ora inline in `load_config()`: errori espliciti
(es. "Sezione 'dataset' mancante") senza codici DCL né schema Pydantic.

## Lezioni apprese

- Pydantic è overengineering per un carico di configurazione stabile (< 20 sezioni).
- Un wrapper di compatibilità raddoppia la superficie di manutenzione.
- La validazione inline con messaggi espliciti è più facile da debuggare
rispetto a errori Pydantic generici.
- Le dataclass sono sufficienti quando il modello è conosciuto a compile-time.
64 changes: 45 additions & 19 deletions tests/test_batch_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ def _write_batch_project(project_dir: Path, dataset: str, year: int) -> Path:
decimal: ","
mode: explicit
include: {dataset}_{year}.csv
columns:
comune: VARCHAR
anno: INTEGER
valore: DOUBLE
required_columns: comune
validate:
not_null: valore
Expand Down Expand Up @@ -171,21 +175,20 @@ def test_batch_smoke_flag(tmp_path: Path) -> None:


def test_batch_dry_run_flag(tmp_path: Path) -> None:
"""Backward compat: ``toolkit batch --step raw --dry-run`` (non full SQL)."""
"""``toolkit run --batch --dry-run`` non crea file di output."""
project = tmp_path / "project"
_write_batch_project(project, "batch_dry", 2023)
configs_file = _write_configs_file(tmp_path, "project")

runner = CliRunner()
result = runner.invoke(
app,
["batch", "--configs", str(configs_file), "--step", "raw", "--dry-run"],
["run", "--batch", str(configs_file), "--dry-run"],
catch_exceptions=False,
)

assert result.exit_code == 0
assert "batch_dry" in result.output
assert "DRY_RUN" in result.output

# --dry-run non deve creare file di output
raw_out = project / "out" / "data" / "raw" / "batch_dry" / "2023"
Expand Down Expand Up @@ -215,57 +218,52 @@ def test_batch_json_output(tmp_path: Path) -> None:


def test_batch_step_probe(tmp_path: Path) -> None:
"""Backward compat: ``toolkit batch --step probe`` funziona ancora (deprecato)."""
"""``toolkit run --batch --dry-run`` esegue validazione senza output fisici."""
project = tmp_path / "project"
_write_batch_project(project, "batch_probe", 2023)
configs_file = _write_configs_file(tmp_path, "project")

runner = CliRunner()
result = runner.invoke(
app,
["batch", "--configs", str(configs_file), "--step", "probe"],
["run", "--batch", str(configs_file), "--dry-run"],
catch_exceptions=False,
)

assert result.exit_code == 0
assert "deprecato" in result.stderr
assert "Batch Report" in result.output
assert "batch_probe" in result.output
assert "SUCCESS" in result.output


def test_batch_step_probe_json_output(tmp_path: Path) -> None:
"""Backward compat: ``toolkit batch --step probe --json``."""
"""``toolkit run --batch --dry-run --json`` produce JSON parsabile."""
project = tmp_path / "project"
_write_batch_project(project, "batch_probe_json", 2023)
configs_file = _write_configs_file(tmp_path, "project")

runner = CliRunner()
result = runner.invoke(
app,
["batch", "--configs", str(configs_file), "--step", "probe", "--json"],
["run", "--batch", str(configs_file), "--dry-run", "--json"],
catch_exceptions=False,
)

assert result.exit_code == 0
report = json.loads(result.stdout)
assert report["summary"]["total"] == 1
assert report["summary"]["passed"] == 1
assert report["rows"][0]["dataset"] == "batch_probe_json"
assert report["rows"][0]["step"] == "probe"
assert report["rows"][0]["status"] == "SUCCESS"


def test_batch_dry_run_with_json(tmp_path: Path) -> None:
"""Backward compat: ``toolkit batch --step raw --dry-run --json``."""
"""``toolkit run --batch --dry-run --json`` produce JSON e non crea file."""
project = tmp_path / "project"
_write_batch_project(project, "batch_dry_json", 2023)
configs_file = _write_configs_file(tmp_path, "project")

runner = CliRunner()
result = runner.invoke(
app,
["batch", "--configs", str(configs_file), "--step", "raw", "--dry-run", "--json"],
["run", "--batch", str(configs_file), "--dry-run", "--json"],
catch_exceptions=False,
)

Expand All @@ -274,7 +272,6 @@ def test_batch_dry_run_with_json(tmp_path: Path) -> None:
assert report["summary"]["total"] == 1
assert report["summary"]["passed"] == 1
assert report["rows"][0]["dataset"] == "batch_dry_json"
assert report["rows"][0]["status"] == "DRY_RUN"

# Nessun file creato (dry-run)
raw_out = project / "out" / "data" / "raw" / "batch_dry_json" / "2023"
Expand All @@ -283,15 +280,15 @@ def test_batch_dry_run_with_json(tmp_path: Path) -> None:

@pytest.mark.policy
def test_batch_step_probe_dry_run_reports_dry_run(tmp_path: Path) -> None:
"""Backward compat: ``toolkit batch --step probe --dry-run --json``."""
"""``toolkit run --batch --dry-run --json`` riporta DRY_RUN e non crea file."""
project = tmp_path / "project"
_write_batch_project(project, "batch_probe_dry", 2023)
configs_file = _write_configs_file(tmp_path, "project")

runner = CliRunner()
result = runner.invoke(
app,
["batch", "--configs", str(configs_file), "--step", "probe", "--dry-run", "--json"],
["run", "--batch", str(configs_file), "--dry-run", "--json"],
catch_exceptions=False,
)

Expand All @@ -301,10 +298,14 @@ def test_batch_step_probe_dry_run_reports_dry_run(tmp_path: Path) -> None:
assert report["summary"]["passed"] == 1
assert report["rows"][0]["status"] == "DRY_RUN"

# Dry-run non crea output fisici
raw_out = project / "out" / "data" / "raw" / "batch_probe_dry" / "2023"
assert not raw_out.exists()


@pytest.mark.policy
def test_batch_step_raw_dry_run_reuses_runner_across_configs(tmp_path: Path) -> None:
"""Backward compat: ``toolkit batch --step raw --dry-run``."""
"""``toolkit run --batch --dry-run`` processa piu' config."""
project_a = tmp_path / "proj_a"
_write_batch_project(project_a, "batch_raw_a", 2023)
project_b = tmp_path / "proj_b"
Expand All @@ -316,7 +317,7 @@ def test_batch_step_raw_dry_run_reuses_runner_across_configs(tmp_path: Path) ->
runner = CliRunner()
result = runner.invoke(
app,
["batch", "--configs", str(configs_file), "--step", "raw", "--dry-run"],
["run", "--batch", str(configs_file), "--dry-run"],
catch_exceptions=False,
)

Expand Down Expand Up @@ -376,3 +377,28 @@ def test_run_batch_end_to_end(tmp_path: Path) -> None:
# Verifica output fisici
assert (project_a / "out" / "data" / "mart" / "e2e_a" / "2022" / "mart_totali.parquet").exists()
assert (project_b / "out" / "data" / "mart" / "e2e_b" / "2023" / "mart_totali.parquet").exists()


@pytest.mark.contract
def test_run_batch_exit_nonzero_when_config_invalid(tmp_path: Path) -> None:
"""``toolkit run --batch`` con config non bootstrappata deve uscire con codice != 0.

Regressione: _run_pipeline ritorna ``status="failed"`` senza eccezione
quando il run fallisce (es. clean.sql mancante). _run_batch deve
propagare l'esito fallito (riga FAILED) come exit code 1, non uscire con 0.
"""
project = tmp_path / "project"
_write_batch_project(project, "batch_invalid", 2023)
# Rimuove clean.sql: il run fallisce con "CLEAN SQL file not found"
(project / "sql" / "clean.sql").unlink()
configs_file = _write_configs_file(tmp_path, "project")

runner = CliRunner()
result = runner.invoke(
app,
["run", "--batch", str(configs_file)],
catch_exceptions=False,
)

assert result.exit_code != 0
assert "FAILED" in result.output
35 changes: 0 additions & 35 deletions tests/test_cmd_batch.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@

import pytest

from toolkit.cli._batch_helpers import format_duration, format_years
from toolkit.cli.cmd_run import _read_config_list

pytestmark = pytest.mark.pure_unit
Expand Down Expand Up @@ -142,37 +141,3 @@ def test_blank_lines_and_spaces_skipped(self, tmp_path: pytest.TempPathFactory)
)
result = _read_config_list(configs_file)
assert len(result) == 1


class TestFormatYears:
def test_none(self) -> None:
assert format_years(None) == "-"

def test_empty_list(self) -> None:
assert format_years([]) == "-"

def test_single_year(self) -> None:
assert format_years([2023]) == "2023"

def test_multiple_years(self) -> None:
assert format_years([2021, 2022, 2023]) == "2021,2022,2023"

def test_returns_string(self) -> None:
assert isinstance(format_years([2020]), str)


class TestFormatDuration:
def test_none_returns_dash(self) -> None:
assert format_duration(None) == "-"

def test_seconds_formatted(self) -> None:
assert format_duration(1.234) == "1.234s"

def test_zero(self) -> None:
assert format_duration(0.0) == "0.000s"

def test_rounds_to_3_decimals(self) -> None:
assert format_duration(1.23456789) == "1.235s"

def test_returns_string(self) -> None:
assert isinstance(format_duration(1.0), str)
23 changes: 18 additions & 5 deletions tests/test_run_dry_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import json
import logging
import re
import time
from pathlib import Path

Expand All @@ -16,6 +17,11 @@
from tests.helpers import make_dataset_yml, make_standard_sql


def _normalized(text: str) -> str:
"""Collassa spazi e newline (il logger rich spezza le righe)."""
return re.sub(r"\s+", " ", text)


# ── Basic patterns ──────────────────────────────────────────────────────────


Expand Down Expand Up @@ -66,7 +72,12 @@ def test_run_dry_run_fails_on_clean_sql_syntax_error(tmp_path: Path, runner) ->
result = runner.invoke(app, ["run", "--config", str(config_path), "--dry-run"])

assert result.exit_code != 0
assert "CLEAN SQL dry-run failed" in str(result.exception)
# Il logger rich spezza le righe e inserisce il path del file:
# "CLEAN SQL cmd_run.py:986 dry-run failed (...)". Verifichiamo le parti.
normalized = _normalized(result.output)
assert "CLEAN SQL" in normalized
assert "dry-run failed" in normalized
assert "Parser Error" in normalized


@pytest.mark.policy
Expand All @@ -90,7 +101,10 @@ def test_run_dry_run_fails_on_mart_sql_binding_error(tmp_path: Path, runner) ->
result = runner.invoke(app, ["run", "--config", str(config_path), "--dry-run"])

assert result.exit_code != 0
assert "MART SQL dry-run failed" in str(result.exception)
normalized = _normalized(result.output)
assert "MART SQL" in normalized
assert "dry-run failed" in normalized
assert "Binder Error" in normalized


@pytest.mark.policy
Expand Down Expand Up @@ -532,9 +546,8 @@ def test_run_all_fails_with_bootstrap_hint_when_clean_sql_missing(
result = runner.invoke(app, ["run", "--config", str(config_path)])

assert result.exit_code != 0
exc_text = str(result.exception)
assert "CLEAN SQL file not found" in exc_text
assert "toolkit run raw" in exc_text
assert "CLEAN SQL" in result.output
assert "toolkit run raw" in result.output


# ── Probe step contract tests ────────────────────────────────────────────────
Expand Down
2 changes: 1 addition & 1 deletion tests/test_sql_dry_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from lab_connectors.duckdb import safe_connect
import pytest

from toolkit.cli.sql_dry_run import (
from toolkit.core.sql_validation import (
_build_clean_preview,
_create_placeholder_raw_input_with_columns,
_dedupe_preserve_order,
Expand Down
Loading
Loading