diff --git a/.github/seed-issues/00_kickoff.md b/.github/seed-issues/00_kickoff.md new file mode 100644 index 0000000..5671614 --- /dev/null +++ b/.github/seed-issues/00_kickoff.md @@ -0,0 +1,48 @@ +--- +title: "[Kickoff] Definire domanda civica, perimetro e contratto iniziale del dataset" +labels: ["LEAD", "METODO"] +assignees: [] +--- +## Perche questa fase conta + +Qui si decide se il progetto ha una domanda utile, comprensibile e davvero sostenibile nel tempo. +Un kickoff fatto bene evita di costruire dati che poi non rispondono alla domanda iniziale. + +## Output visibile al pubblico + +Una spiegazione chiara di cosa vuole capire il progetto e di quali dati usera. + +## Obiettivo + +Avviare il progetto dataset con perimetro chiaro, domanda civica misurabile e contratto toolkit-first coerente con il template. + +## Checklist + +- [ ] Definire una sola domanda civica, chiara e misurabile +- [ ] Compilare `dataset.yml` con `dataset.name`, `dataset.years` e `root` +- [ ] Verificare che i path in config siano root-relative POSIX +- [ ] Confermare la struttura canonica `sql/clean.sql` e `sql/mart/.sql` +- [ ] Allineare ruoli e ownership con `docs/lab_links.md` +- [ ] Verificare che `tests/test_contract.py` sia verde in locale + +## Output atteso + +Progetto inizializzato con contratto di base valido e documentazione minima pronta per il source onboarding. + +## Supporto operativo + +- notebook consigliato: `notebooks/00_quickstart.ipynb` +- comando minimo: `py -m pytest tests/test_contract.py` + +## File da toccare + +- `dataset.yml` +- `README.md` +- `docs/lab_links.md` + +## Acceptance criteria + +- `dataset.yml` esiste in root ed e coerente con il contratto smoke reale +- il perimetro del progetto e documentato in modo comprensibile +- `pytest tests/test_contract.py` passa +- il progetto puo passare alla fase Source onboarding senza ambiguita su nome dataset, anni e scope diff --git a/.github/seed-issues/01_civic_questions.md b/.github/seed-issues/01_civic_questions.md new file mode 100644 index 0000000..3a0aeaf --- /dev/null +++ b/.github/seed-issues/01_civic_questions.md @@ -0,0 +1,47 @@ +--- +title: "[Questions] Esplicitare domande civiche, metriche e ipotesi iniziali" +labels: ["METODO", "LEAD"] +assignees: [] +--- +## Perche questa fase conta + +Un progetto dati utile nasce da domande chiare, non da una pipeline costruita nel vuoto. +Questa fase serve a collegare il lavoro tecnico a un problema pubblico leggibile. + +## Output visibile al pubblico + +Un set di domande guida che spiega cosa il progetto prova a capire e con quali misure. + +## Obiettivo + +Definire le domande civiche che guideranno fonti, metriche, unità di analisi e output del progetto. + +## Checklist + +- [ ] 3-5 domande civiche esplicite +- [ ] 3-5 metriche collegate +- [ ] Ipotesi dichiarate +- [ ] Coerenza con unità di analisi + +## Output atteso + +Una base pubblica e metodologica chiara da cui far discendere le scelte su fonti, CLEAN, MART e output finali. + +## Supporto operativo + +- notebook consigliato: nessuno, il lavoro e principalmente metodologico e documentale +- file guida: `README.md`, `docs/overview.md`, `docs/decisions.md` + +## File da toccare + +- `README.md` +- `docs/overview.md` +- `docs/lab_links.md` +- `docs/decisions.md` + +## Acceptance criteria + +- le domande guida sono scritte in linguaggio semplice +- le metriche proposte sono coerenti con le domande +- le ipotesi iniziali sono esplicitate +- c'e coerenza fra domande, metriche e unità di analisi diff --git a/.github/seed-issues/01_setup.md b/.github/seed-issues/01_setup.md deleted file mode 100644 index b57ede7..0000000 --- a/.github/seed-issues/01_setup.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -title: "M1-01 — Setup progetto (repo, ruoli, board, convenzioni)" -labels: ["LEAD"] -assignees: [] ---- -## 🎯 Obiettivo -Rendere il progetto operabile in modo replicabile. - -## ✅ Task -- [ ] Verificare struttura cartelle (data/, notebooks/, docs/, src/ se serve) -- [ ] Collegare repo alla Board (Roadmap/Open) e creare views minime -- [ ] Definire ruoli (Data/Method/Viz/QA/Doc) e owners -- [ ] Allineare naming: dataset_id, paths, convenzioni file -- [ ] Inserire link a WORKFLOW / CONTRIBUTING / GOVERNANCE (Lab) - -## 📦 Output atteso -Repo pronta per partire con la pipeline e PR cadence. \ No newline at end of file diff --git a/.github/seed-issues/02_dataset_scoping.md b/.github/seed-issues/02_dataset_scoping.md deleted file mode 100644 index 661e846..0000000 --- a/.github/seed-issues/02_dataset_scoping.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -title: "M1-02 — Scoping dataset (fonte, perimetro, limiti, chiavi)" -labels: ["METODO"] -assignees: [] ---- -## 🎯 Obiettivo -Mettere nero su bianco cosa stiamo usando e cosa NON stiamo facendo. - -## ✅ Task -- [ ] Link fonte ufficiale + eventuali mirror -- [ ] Definire unità di analisi (comune/provincia/anno/voce ecc.) -- [ ] Identificare chiavi e campi core (ID, anno, territorio, misura) -- [ ] Elencare limiti noti (mancanti, revisioni, definizioni ambigue) -- [ ] Definire assunzioni minime (es. normalizzazioni, conversioni) - -## 📦 Output atteso -Bozza `docs/dataset.md` o sezione README con scoping chiaro. \ No newline at end of file diff --git a/.github/seed-issues/02_sources.md b/.github/seed-issues/02_sources.md new file mode 100644 index 0000000..725c0e0 --- /dev/null +++ b/.github/seed-issues/02_sources.md @@ -0,0 +1,49 @@ +--- +title: "[Sources] Onboarding fonti, licenza, refresh e decisioni di ingestione" +labels: ["DATA", "METODO"] +assignees: [] +--- +## Perche questa fase conta + +Se la fonte non e chiara, tutto il resto del progetto diventa fragile. +Questa fase serve a capire da dove arrivano i dati e con quali limiti. + +## Output visibile al pubblico + +Una scheda semplice delle fonti usate, con link e note di contesto. + +## Obiettivo + +Qualificare la fonte e codificare in modo riproducibile come il toolkit deve leggerla. + +## Checklist + +- [ ] Identificare fonte primaria, URL canonico e frequenza di aggiornamento +- [ ] Aggiornare `raw.sources[].type` e `raw.sources[].args` in `dataset.yml` +- [ ] Documentare licenza, coverage, refresh cadence e note in `docs/sources.md` +- [ ] Registrare trade-off e assunzioni di ingestione in `docs/decisions.md` +- [ ] Verificare che non esistano path assoluti o riferimenti locali +- [ ] Rieseguire i contract tests + +## Output atteso + +Fonte verificata e configurata in `dataset.yml`, con documentazione sufficiente per procedere al layer RAW. + +## Supporto operativo + +- notebook consigliato: `notebooks/01_inspect_raw.ipynb` +- comando minimo: `toolkit run raw --config dataset.yml` +- dopo il run usa: `toolkit inspect paths --config dataset.yml --year --json` + +## File da toccare + +- `dataset.yml` +- `docs/sources.md` +- `docs/decisions.md` + +## Acceptance criteria + +- la fonte e verificabile e documentata +- `raw.sources` e compilato con campi sufficienti all'esecuzione +- `docs/sources.md` contiene note su licenza, refresh e limiti noti +- `pytest tests/test_contract.py` passa diff --git a/.github/seed-issues/03_raw.md b/.github/seed-issues/03_raw.md new file mode 100644 index 0000000..7f62545 --- /dev/null +++ b/.github/seed-issues/03_raw.md @@ -0,0 +1,49 @@ +--- +title: "[RAW] Rendere riproducibile l'ingestione RAW con il toolkit" +labels: ["DATA"] +assignees: [] +--- +## Perche questa fase conta + +Questa e la base del progetto: se il dato in ingresso non e stabile o tracciabile, anche le analisi finali diventano deboli. + +## Output visibile al pubblico + +Una fonte acquisita in modo ripetibile, con traccia di cosa e stato usato e quando. + +## Obiettivo + +Ottenere un layer RAW eseguibile e ripetibile, senza committare output in repo. + +## Checklist + +- [ ] Verificare `raw.sources[]`, `primary` ed eventuale extractor in `dataset.yml` +- [ ] Eseguire `toolkit run raw --config dataset.yml` +- [ ] Usare `toolkit inspect paths --config dataset.yml --year --json` per localizzare gli artifact RAW +- [ ] Controllare `manifest.json`, `metadata.json` e `raw_validation.json` +- [ ] Controllare metadata, manifest e validation report del RAW +- [ ] Confermare che `data/` non contenga output committati +- [ ] Aggiornare `docs/decisions.md` con eventuali eccezioni o failure modes + +## Output atteso + +RAW eseguibile con report minimi di validazione e metadata disponibili negli artifact di run. + +## Supporto operativo + +- notebook consigliato: `notebooks/01_inspect_raw.ipynb` +- path attesi: `root/data/raw///` +- comando di discovery: `toolkit inspect paths --config dataset.yml --year --json` + +## File da toccare + +- `dataset.yml` +- `docs/decisions.md` +- `docs/sources.md` + +## Acceptance criteria + +- il run RAW completa o il blocco e documentato in modo riproducibile +- nessun output RAW viene aggiunto sotto `data/` +- gli artifact minimi del RAW sono attesi sotto `root/data/raw///` +- il progetto puo passare a CLEAN con input RAW deterministico diff --git a/.github/seed-issues/03_raw_ingestion.md b/.github/seed-issues/03_raw_ingestion.md deleted file mode 100644 index 5e50dfa..0000000 --- a/.github/seed-issues/03_raw_ingestion.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -title: "M1-03 — RAW ingestion (download, snapshot, metadata)" -labels: ["DATA"] -assignees: [] ---- -## 🎯 Obiettivo -Acquisire RAW in modo ripetibile e tracciabile. - -## ✅ Task -- [ ] Definire path RAW standard (Drive/FS) + naming files -- [ ] Script/notebook per download o import (no trasformazioni) -- [ ] Salvare metadata: url, timestamp, checksum/size, note versione -- [ ] Gestire encoding e separatore (senza “pulire” i dati) -- [ ] Log minimo (righe, colonne, errori) - -## 📦 Output atteso -RAW disponibile + manifest/metadata (anche solo JSON/MD). \ No newline at end of file diff --git a/.github/seed-issues/04_clean.md b/.github/seed-issues/04_clean.md new file mode 100644 index 0000000..fc40d99 --- /dev/null +++ b/.github/seed-issues/04_clean.md @@ -0,0 +1,53 @@ +--- +title: "[CLEAN] Implementare normalizzazione, required columns e validazioni CLEAN" +labels: ["DATA"] +assignees: [] +--- +## Perche questa fase conta + +Qui il dato diventa davvero leggibile e confrontabile. +Una buona fase CLEAN riduce errori, ambiguita e lavoro manuale futuro. + +## Output visibile al pubblico + +Un dataset piu chiaro, con colonne coerenti e significato documentato. + +## Obiettivo + +Portare il dataset da RAW a CLEAN con SQL esplicita, schema documentato e validazioni minime. + +## Checklist + +- [ ] Implementare o aggiornare `sql/clean.sql` +- [ ] Allineare `clean.read`, `clean.required_columns` e `clean.validate` in `dataset.yml` +- [ ] Verificare chiavi logiche, `not_null`, `min_rows` e duplicati +- [ ] Eseguire `toolkit run clean --config dataset.yml` +- [ ] Eseguire `toolkit validate clean --config dataset.yml` +- [ ] Usare `toolkit inspect paths --config dataset.yml --year --json` per localizzare il layer CLEAN +- [ ] Aggiornare `docs/data_dictionary.md` per il layer CLEAN +- [ ] Loggare assunzioni e mapping in `docs/decisions.md` + +## Output atteso + +Layer CLEAN riproducibile, con schema e regole di validazione sufficienti per alimentare i mart. + +## Supporto operativo + +- notebook consigliato: `notebooks/02_inspect_clean.ipynb` +- path attesi: `root/data/clean///` +- comando di discovery: `toolkit inspect paths --config dataset.yml --year --json` + +## File da toccare + +- `sql/clean.sql` +- `dataset.yml` +- `docs/data_dictionary.md` +- `docs/decisions.md` + +## Acceptance criteria + +- `sql/clean.sql` legge da `raw_input` +- `clean.required_columns` e aggiornato +- `clean.validate` copre almeno chiavi, `not_null` e `min_rows` quando applicabile +- rowcount sanity e duplicate check sono stati eseguiti +- il progetto puo passare a MART senza ambiguita sullo schema CLEAN diff --git a/.github/seed-issues/04_raw_to_clean.md b/.github/seed-issues/04_raw_to_clean.md deleted file mode 100644 index 60a20cc..0000000 --- a/.github/seed-issues/04_raw_to_clean.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -title: "M1-04 — RAW → CLEAN (standard colonne, parsing, validazioni base)" -labels: ["DATA"] -assignees: [] ---- -## 🎯 Obiettivo -Produrre CLEAN coerente multi-anno e pronto per analisi. - -## ✅ Task -- [ ] Standardizzare nomi colonne (snake_case) + dizionario -- [ ] Parsing numeri IT (., , , %, -, null) -- [ ] Tipi coerenti (string/int/float/date) e regole di casting -- [ ] Gestire valori speciali (n.d., 0, vuoti) con policy esplicita -- [ ] Validazioni base: righe attese, colonne obbligatorie, duplicati chiave - -## 📦 Output atteso -File CLEAN (parquet/csv) + mini data dictionary. \ No newline at end of file diff --git a/.github/seed-issues/05_clean_to_mart.md b/.github/seed-issues/05_clean_to_mart.md deleted file mode 100644 index 377a9fb..0000000 --- a/.github/seed-issues/05_clean_to_mart.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -title: "M1-05 — CLEAN → MART (metriche, aggregazioni, modelli per dashboard)" -labels: ["DATA"] -assignees: [] ---- -## 🎯 Obiettivo -Creare dataset MART orientato a domande e dashboard. - -## ✅ Task -- [ ] Definire “domande base” (3–5) che il MART deve supportare -- [ ] Selezionare metriche e dimensioni (tempo, territorio, categoria) -- [ ] Creare tabelle MART (fact + dimensioni se serve) o dataset wide -- [ ] Documentare formule (KPI, percentuali, normalizzazioni) -- [ ] Export su Drive / BigQuery (se previsto) con naming standard - -## 📦 Output atteso -MART pronto per Looker/PowerBI + note KPI. \ No newline at end of file diff --git a/.github/seed-issues/05_mart.md b/.github/seed-issues/05_mart.md new file mode 100644 index 0000000..ff39aea --- /dev/null +++ b/.github/seed-issues/05_mart.md @@ -0,0 +1,51 @@ +--- +title: "[MART] Costruire tabelle analitiche e regole di validazione MART" +labels: ["DATA", "METODO"] +assignees: [] +--- +## Perche questa fase conta + +Qui il progetto inizia a produrre risposte utilizzabili. +I mart sono la parte che alimenta analisi, dashboard e insight condivisibili. + +## Output visibile al pubblico + +Tabelle finali leggibili, da cui ricavare indicatori e confronti. + +## Obiettivo + +Produrre uno o piu mart orientati a KPI e output finali, con tabella/e e validation rules esplicite. + +## Checklist + +- [ ] Creare o aggiornare `sql/mart/
.sql` per ogni tabella dichiarata +- [ ] Allineare `mart.tables` in `dataset.yml` e aggiungere eventuali regole di validazione supportate dal toolkit +- [ ] Eseguire `toolkit run mart --config dataset.yml --year ` +- [ ] Eseguire `toolkit validate --config dataset.yml --year ` +- [ ] Usare `toolkit inspect paths --config dataset.yml --year --json` per localizzare i mart +- [ ] Verificare required columns, chiavi, `not_null`, `min_rows` e KPI sanity +- [ ] Aggiornare `docs/data_dictionary.md` con granularita, KPI e semantica dei mart + +## Output atteso + +Mart pronti per dashboard o report, con SQL separata per tabella e regole di validazione chiare. + +## Supporto operativo + +- notebook consigliato: `notebooks/03_explore_mart.ipynb` +- comando minimo: `toolkit run mart --config dataset.yml` +- comando di discovery: `toolkit inspect paths --config dataset.yml --year --json` + +## File da toccare + +- `sql/mart/
.sql` +- `dataset.yml` +- `docs/data_dictionary.md` +- `docs/decisions.md` + +## Acceptance criteria + +- ogni tabella dichiarata in `mart.tables[]` ha un file SQL dedicato +- eventuali regole MART dichiarate in `dataset.yml` sono coerenti con le tabelle pubblicate +- rowcount sanity e duplicate check sono stati eseguiti +- il progetto puo passare a QA con mart leggibili e validabili diff --git a/.github/seed-issues/06_qa_checks.md b/.github/seed-issues/06_qa_checks.md deleted file mode 100644 index 65423ab..0000000 --- a/.github/seed-issues/06_qa_checks.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -title: "M1-06 — QA (controlli qualità, coerenza, regressioni)" -labels: ["QA"] -assignees: [] ---- -## 🎯 Obiettivo -Evitare che il progetto “sembri ok” ma sia sbagliato. - -## ✅ Task -- [ ] Check coerenza per anno/territorio (buchi, outlier grossi) -- [ ] Check somme e percentuali (range 0–100, totali coerenti) -- [ ] Confronto RAW vs CLEAN: righe perse/aggiunte spiegate -- [ ] Regole di regressione (se rifacciamo pipeline, cosa non deve cambiare) -- [ ] Output QA: report breve (MD) con esito e anomalie note - -## 📦 Output atteso -`docs/qa.md` + checklist QA riusabile per dataset futuri. \ No newline at end of file diff --git a/.github/seed-issues/06_validation_qa.md b/.github/seed-issues/06_validation_qa.md new file mode 100644 index 0000000..50232e1 --- /dev/null +++ b/.github/seed-issues/06_validation_qa.md @@ -0,0 +1,49 @@ +--- +title: "[QA] Chiudere validazioni, contract tests e smoke opzionale" +labels: ["QA", "DATA"] +assignees: [] +--- +## Perche questa fase conta + +E il momento in cui si verifica se il progetto regge davvero. +Serve a evitare output convincenti ma fragili o poco affidabili. + +## Output visibile al pubblico + +Un dataset piu affidabile, con controlli espliciti e anomalie residue tracciate. + +## Obiettivo + +Chiudere il gate tecnico di qualita con contract tests verdi, validazioni dataset e smoke opzionale documentato. + +## Checklist + +- [ ] Eseguire `py -m pytest tests/test_contract.py` +- [ ] Verificare che la CI `contract` sia verde +- [ ] Verificare che la CI `smoke` sia documentata e attivabile con `RUN_SMOKE=1` +- [ ] Rieseguire `toolkit validate all --config dataset.yml` +- [ ] Controllare outlier, rowcount sanity, duplicates e coerenza dei KPI +- [ ] Aprire issue residue per anomalie non bloccanti + +## Output atteso + +Gate QA superato, con standard minimo del Lab rispettato e stato di qualita esplicito. + +## Supporto operativo + +- notebook consigliato: `notebooks/04_quality_checks.ipynb` +- comando di stato: `toolkit status --dataset --year --latest --config dataset.yml` + +## File da toccare + +- `tests/test_contract.py` +- `.github/workflows/ci.yml` +- `README.md` +- `docs/decisions.md` + +## Acceptance criteria + +- i contract tests passano +- il job `contract` in CI e sempre eseguibile senza dipendere da toolkit su PyPI +- il job `smoke` resta opzionale e documentato +- le anomalie residue sono documentate o trasformate in issue diff --git a/.github/seed-issues/07_dashboard.md b/.github/seed-issues/07_dashboard.md new file mode 100644 index 0000000..f0cd351 --- /dev/null +++ b/.github/seed-issues/07_dashboard.md @@ -0,0 +1,46 @@ +--- +title: "[Release] Dashboard o output pubblico collegato ai MART" +labels: ["VIZ", "OPTIONAL"] +assignees: [] +--- +## Perche questa fase conta + +E la fase in cui il lavoro diventa leggibile anche fuori dal team tecnico. +Serve a trasformare tabelle finali in un output che aiuti davvero chi legge. + +## Output visibile al pubblico + +Una dashboard, un report o una pagina che spiega cosa emerge dai dati. + +## Obiettivo + +Preparare un output pubblico che consumi i mart prodotti dal progetto. + +## Checklist + +- [ ] Identificare il mart sorgente e i KPI che alimentano l'output +- [ ] Documentare limiti, assunzioni e ultimo aggiornamento in `dashboard/README.md` +- [ ] Verificare coerenza fra dashboard e `docs/data_dictionary.md` +- [ ] Esplicitare cosa emerge e cosa non emerge dall'output +- [ ] Collegare l'output nel `README.md` + +## Output atteso + +Dashboard, report o pagina pubblica leggibile e coerente con i mart del progetto. + +## Supporto operativo + +- notebook consigliato: `notebooks/05_dashboard_export.ipynb` + +## File da toccare + +- `dashboard/README.md` +- `README.md` +- `docs/data_dictionary.md` + +## Acceptance criteria + +- l'output usa solo mart documentati +- KPI e limiti sono spiegati in modo leggibile +- il collegamento con il dataset rilasciato e tracciabile +- l'issue non blocca la release del dataset se l'output pubblico non e previsto diff --git a/.github/seed-issues/07_viz_dashboard.md b/.github/seed-issues/07_viz_dashboard.md deleted file mode 100644 index 8cd5cad..0000000 --- a/.github/seed-issues/07_viz_dashboard.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -title: "M1-07 — Visualizzazione (dashboard MVP + scelte di comunicazione)" -labels: ["VIZ"] -assignees: [] ---- -## 🎯 Obiettivo -Trasformare il MART in una dashboard MVP utile e condivisibile. - -## ✅ Task -- [ ] Definire pubblico target + 1 frase “cosa ci portiamo a casa” -- [ ] Wireframe: 1 pagina (KPI, trend, mappa/tabella, filtri) -- [ ] Scelte viz: colori, scale, note interpretative, unità misura -- [ ] Implementazione MVP (Looker/PowerBI/altro) con filtri minimi -- [ ] Sezione “Come leggere” (3 bullet) + limiti dati - -## 📦 Output atteso -Link dashboard + screenshot + note di lettura. \ No newline at end of file diff --git a/.github/seed-issues/08_docs_release.md b/.github/seed-issues/08_docs_release.md deleted file mode 100644 index 43b7f6e..0000000 --- a/.github/seed-issues/08_docs_release.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -title: "M1-08 — Documentazione & Release (README, riproducibilità, share)" -labels: ["DOCS"] -assignees: [] ---- -## 🎯 Obiettivo -Rendere il progetto replicabile e “pubblicabile” in 10 minuti. - -## ✅ Task -- [ ] README: obiettivo, dataset, output, come eseguire -- [ ] Struttura “metodo”: assunzioni, limiti, scelte, non-coperto -- [ ] Link a notebook/script in ordine di esecuzione -- [ ] Dataset catalog (input/output paths) + versione -- [ ] Tag/release v0.1 + changelog breve (cosa c’è / cosa manca) - -## 📦 Output atteso -Repo presentabile + release v0.1 pronta per essere condivisa. \ No newline at end of file diff --git a/.github/seed-issues/08_release.md b/.github/seed-issues/08_release.md new file mode 100644 index 0000000..2fcf1af --- /dev/null +++ b/.github/seed-issues/08_release.md @@ -0,0 +1,62 @@ +--- +title: "[Release] Preparare release del dataset, README e artifacts minimi" +labels: ["DOCS", "LEAD"] +assignees: [] +--- +## Perche questa fase conta + +Questa fase rende il progetto condivisibile anche con chi arriva da fuori. +Una buona release rende chiaro cosa esiste, cosa si puo usare e con quali limiti. + +## Output visibile al pubblico + +Una homepage chiara, una overview leggibile e una release spiegata bene. + +## Obiettivo + +Portare il progetto a una release riproducibile, spiegabile e pronta per handoff. + +## Checklist + +- [ ] Aggiornare `README.md` con scopo, metodo, output e limiti +- [ ] Verificare `docs/lab_links.md` per hub DataCivicLab, policy comuni e riferimenti al toolkit +- [ ] Confermare che `output.artifacts` resti su `minimal` o motivare eccezioni +- [ ] Collegare eventuale dashboard o report ai mart corretti +- [ ] Verificare che documentazione e artifact minimi siano coerenti +- [ ] Preparare PR o release notes interne + +## Public-facing readiness + +- [ ] README pubblico-first ok +- [ ] `docs/overview.md` presente +- [ ] almeno 3 domande o insight presenti nel README +- [ ] `docs/data_dictionary.md` aggiornato almeno al minimo + +## Civic clarity check + +- [ ] Le domande guida sono ancora rilevanti? +- [ ] Le metriche rispondono davvero alle domande? +- [ ] Ci sono insight scritti in linguaggio semplice? + +## Output atteso + +Release interna o pubblica con documentazione finale coerente con i dati e con i mart prodotti. + +## Supporto operativo + +- notebook consigliato: `notebooks/00_quickstart.ipynb` +- comandi minimi: `py -m pytest tests/test_contract.py` e `toolkit validate all --config dataset.yml` + +## File da toccare + +- `README.md` +- `docs/overview.md` +- `docs/lab_links.md` +- `docs/data_dictionary.md` + +## Acceptance criteria + +- README e docs descrivono chiaramente cosa e stato rilasciato +- il progetto e riprendibile da terzi senza conoscenza implicita +- gli artifact minimi attesi sono coerenti con la policy del template +- i gate precedenti sono chiusi o le eccezioni sono documentate diff --git a/.github/seed-issues/09_docs_decisions_dictionary.md b/.github/seed-issues/09_docs_decisions_dictionary.md new file mode 100644 index 0000000..ed4e8b5 --- /dev/null +++ b/.github/seed-issues/09_docs_decisions_dictionary.md @@ -0,0 +1,48 @@ +--- +title: "[Docs] Aggiornare decision log e data dictionary del progetto" +labels: ["DOCS", "METODO"] +assignees: [] +--- +## Perche questa fase conta + +Un progetto dati e utile solo se altre persone riescono a capirlo e riprenderlo. +Questa fase rende visibili scelte, significato dei campi e limiti. + +## Output visibile al pubblico + +Documentazione leggibile che spiega cosa significano i dati e come interpretarli. + +## Obiettivo + +Tenere allineata la documentazione strutturata del dataset durante tutto il lifecycle del progetto. + +## Checklist + +- [ ] Aggiornare `docs/decisions.md` con decisioni, eccezioni e trade-off +- [ ] Aggiornare `docs/data_dictionary.md` per RAW, CLEAN e MART +- [ ] Verificare coerenza con `docs/sources.md` +- [ ] Verificare coerenza con `docs/lab_links.md` +- [ ] Allineare esempi e naming in README e workflow dataset + +## Output atteso + +Decision log e data dictionary completi, utili per review, handoff e manutenzione futura. + +## Supporto operativo + +- notebook consigliato: `notebooks/02_inspect_clean.ipynb` e `notebooks/03_explore_mart.ipynb` + +## File da toccare + +- `docs/decisions.md` +- `docs/data_dictionary.md` +- `docs/sources.md` +- `docs/lab_links.md` +- `README.md` + +## Acceptance criteria + +- il decision log spiega le scelte non ovvie +- il data dictionary descrive i campi essenziali di CLEAN e MART +- la documentazione e coerente con `dataset.yml` e con la SQL canonica +- il progetto puo passare review metodologica senza knowledge transfer verbale diff --git a/.github/seed-issues/10_maintenance.md b/.github/seed-issues/10_maintenance.md new file mode 100644 index 0000000..5fffb28 --- /dev/null +++ b/.github/seed-issues/10_maintenance.md @@ -0,0 +1,51 @@ +--- +title: "[Maintenance] Gestire nuove annualita, cambi schema e regressioni" +labels: ["DATA", "MAINTENANCE"] +assignees: [] +--- +## Perche questa fase conta + +I dataset non restano fermi: cambiano fonti, anni, regole e definizioni. +Questa fase serve a mantenere il progetto utile anche dopo la prima release. + +## Output visibile al pubblico + +Un progetto che resta aggiornabile e non si rompe al primo cambiamento di fonte. + +## Obiettivo + +Definire il lavoro necessario per mantenere il dataset nel tempo quando cambiano fonte, schema o regole. + +## Checklist + +- [ ] Aggiornare `dataset.yml` per nuove annualita o sorgenti +- [ ] Verificare impatto su `sql/clean.sql` e `sql/mart/*.sql` +- [ ] Rieseguire contract tests +- [ ] Rieseguire smoke opzionale in caso di cambio sostanziale +- [ ] Aggiornare `docs/sources.md`, `docs/data_dictionary.md` e `docs/decisions.md` +- [ ] Documentare regressioni o incompatibilita + +## Output atteso + +Piano di manutenzione chiaro e procedimento ripetibile per evolvere il dataset senza rompere il contratto del template. + +## Supporto operativo + +- notebook consigliato: `notebooks/01_inspect_raw.ipynb`, `notebooks/02_inspect_clean.ipynb`, `notebooks/03_explore_mart.ipynb` +- comandi minimi: `py -m pytest tests/test_contract.py`, `toolkit run all --config dataset.yml`, `toolkit validate all --config dataset.yml` + +## File da toccare + +- `dataset.yml` +- `sql/clean.sql` +- `sql/mart/
.sql` +- `docs/sources.md` +- `docs/data_dictionary.md` +- `docs/decisions.md` + +## Acceptance criteria + +- i cambi sono tracciati nei documenti corretti +- i contract tests restano verdi +- la manutenzione non introduce path assoluti o output committati in `data/` +- il progetto puo essere rieseguito per un nuovo anno o schema senza lavoro manuale implicito diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7933694 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,86 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + workflow_dispatch: + inputs: + run_smoke: + description: "Run optional smoke e2e job" + required: false + type: boolean + default: false + +env: + PYTHON_VERSION: "3.11" + TOOLKIT_PIP_PACKAGE: "" + TOOLKIT_BIN: "toolkit" + +jobs: + contract: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Install contract test dependencies + run: | + python -m pip install --upgrade pip + python -m pip install pytest pyyaml + + - name: Verify required files + run: | + test -f dataset.yml + test -f sql/clean.sql + test -f scripts/smoke.sh + + - name: Run contract tests + run: pytest tests/test_contract.py + + smoke: + if: ${{ vars.RUN_SMOKE == '1' || (github.event_name == 'workflow_dispatch' && inputs.run_smoke) }} + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Install smoke dependencies + run: | + python -m pip install --upgrade pip + python -m pip install pytest pyyaml + if [ -n "${TOOLKIT_PIP_PACKAGE}" ]; then + python -m pip install "${TOOLKIT_PIP_PACKAGE}" + else + git clone --depth 1 https://github.com/dataciviclab/toolkit.git .toolkit-src + python -m pip install -e ./.toolkit-src + fi + + - name: Export DCL_ROOT + run: echo "DCL_ROOT=${GITHUB_WORKSPACE}" >> "${GITHUB_ENV}" + + - name: Smoke run all validate all status + run: sh scripts/smoke.sh + + - name: Upload minimal artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: toolkit-artifacts + path: | + _smoke_out/data/_runs/**/*.json + _smoke_out/data/raw/**/*.json + _smoke_out/data/clean/**/*.json + _smoke_out/data/mart/**/*.json + if-no-files-found: warn diff --git a/.gitignore b/.gitignore index 860a1f2..32a1477 100644 --- a/.gitignore +++ b/.gitignore @@ -1,21 +1,28 @@ # ========================= -# 🔒 DATI (NON VERSIONARE) +# DATI (NON VERSIONARE) # ========================= -data/raw/** -data/clean/** -data/mart/** +data/** +!data/**/ !data/**/README.md +!data/_examples/ +!data/_examples/** -# eventuali export temporanei -*.csv -*.tsv -*.xlsx -*.xls -*.parquet -*.json +# output di run e artifact locali +_runs/ +_runs/** +!_runs/.gitkeep +_smoke_out/ +_smoke_out/** +_test_out/ +_test_out/** + +# eventuali export temporanei generati dal toolkit +_runs/**/*.csv +_runs/**/*.tsv +_runs/**/*.parquet # ========================= -# 🧠 NOTEBOOK / PYTHON +# NOTEBOOK / PYTHON # ========================= __pycache__/ *.py[cod] @@ -26,17 +33,19 @@ venv/ .envrc # ========================= -# 🧪 TOOL / TEMP +# TOOL / TEMP # ========================= .tmp/ .tmp/** +_tmp/ +_tmp/** .cache/ .cache/** logs/ *.log # ========================= -# 💻 IDE / EDITOR +# IDE / EDITOR # ========================= .vscode/* !.vscode/settings.json @@ -46,14 +55,14 @@ logs/ *.code-workspace # ========================= -# 🖥️ OS +# OS # ========================= .DS_Store Thumbs.db desktop.ini # ========================= -# 🔑 CREDENTIALS (MAI!) +# CREDENTIALS (MAI) # ========================= credentials.json service_account.json @@ -61,13 +70,13 @@ service_account.json *.pem # ========================= -# 📦 ARCHIVI +# ARCHIVI # ========================= *.zip *.tar *.gz # ========================= -# 🔍 ALTRO +# ALTRO # ========================= nohup.out diff --git a/README.md b/README.md index 3dd4bed..9298498 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,200 @@ -# 📊 Titolo del progetto +# [Nome dataset] - DataCivicLab -## Domanda civica -(Una sola frase, chiara, misurabile) +Questo progetto analizza **[fenomeno pubblico]** per rispondere a una domanda semplice: +**cosa sta succedendo, dove e con quali differenze nel tempo?** -## Perché questo progetto -(2–3 righe: perché questa domanda è rilevante ora) +E pensato per chi vuole orientarsi in fretta: +capire cosa mostrano i dati, dove sono solidi, quali limiti hanno e quali domande aiutano ad approfondire. -## Ruoli -- Project Lead: -- Data: -- Metodo: -- Viz: -- QA: -- Docs: +- **Stato:** [alpha | beta | stable] +- **Copertura:** [anni], [territorio] +- **Unita di analisi:** [Comune / ASL / Provincia / ...] -## Dataset utilizzati -- Nome dataset — Fonte ufficiale — Periodo — Livello -- Nome dataset — Fonte ufficiale — Periodo — Livello +## 🎯 La domanda civica -## Output pubblico -- Tipo: (dashboard / report / pagina) -- Link: (quando disponibile) +**[Scrivi qui la domanda chiave in una frase chiara.]** -## Stato progetto -🟢 Attivo | 🟡 In revisione | 🔴 Bloccato | ✅ Chiuso +Esempi: -## Come si contribuisce +- Come varia [fenomeno] tra territori? +- Dove si osservano miglioramenti o peggioramenti? +- Il mio territorio e sopra o sotto la media? -1. **Discussion** per idee / contesto -2. **Issue** per task concreti -3. **Branch** per lavorare -4. **Pull Request** per revisione e merge +Questa repo dovrebbe avere **una domanda civica principale**. -Dettagli in `WORKFLOW.md`. +Dallo stesso dataset possono nascere anche altre domande utili, ma vanno tenute distinte: -## Link utili -- Spreadsheet progetto ([Template](https://docs.google.com/spreadsheets/d/17EmTUVLzimppd70kckX2r2UxPQIwGDMU_Fzsqec4idg/edit?gid=1775469119#gid=1775469119)) -- [Metodo DataCivicLab](https://github.com/dataciviclab/dataciviclab/blob/main/METHOD.md) \ No newline at end of file +- la domanda principale orienta README, notebook e output pubblici +- le domande secondarie o complementari possono emergere in Discussions e trasformarsi in issue operative + +In questo modo il repository resta leggibile e non diventa un contenitore indistinto di analisi. + +## 🔎 Cosa puoi capire con questi dati + +- come cambia il fenomeno nel tempo +- quali territori mostrano differenze significative +- se il tuo territorio e sopra o sotto la media +- se emergono anomalie o salti improvvisi +- quali aree meritano un approfondimento mirato + +Non e solo un dataset: e una base per confronto e monitoraggio. + +## 📦 Output disponibili + +Le tabelle finali sono pronte per dashboard, grafici e analisi. + +- `mart.[tabella_1]` - confronti territoriali o temporali +- `mart.[tabella_2]` - indicatori sintetici, ranking o riepiloghi + +Definizioni dettagliate di colonne e metriche: +`docs/data_dictionary.md` + +## ✅ Perche fidarsi + +La fiducia si costruisce su trasparenza e metodo. + +- fonti ufficiali o verificabili (`docs/sources.md`) +- trasformazioni documentate (`docs/decisions.md`) +- controlli automatici prima della pubblicazione +- standard condivisi del DataCivicLab + +Ogni scelta che cambia il significato dei dati viene esplicitata. + +## 💬 Partecipa + +Questo repository distingue chiaramente: + +- **Discussions** -> domande civiche, interpretazioni, proposte di metriche +- **Issues** -> bug, problemi tecnici, miglioramenti della pipeline + +Flusso consigliato: + +`domanda civica -> Discussion -> Issue -> analisi / notebook / output` + +Se non sei tecnico, parti da una **Discussion** in questa repo: +spiega il contesto, il territorio o l'anno che ti interessa e cosa vuoi capire. + +Se la domanda richiede lavoro concreto, va trasformata in una **Issue** nella repo giusta: + +- issue dataset-specifiche in questa repo +- issue di runtime o pipeline nel `toolkit` +- issue di governance o processo nelle repo di ecosistema + +## 📚 Documentazione del dataset + +- `docs/overview.md` - contesto, copertura, limiti +- `docs/sources.md` - fonti ufficiali +- `docs/data_dictionary.md` - colonne e metriche +- `docs/decisions.md` - scelte progettuali +- `docs/contributing.md` - come contribuire + +## 🧩 Cos'e questa repo + +Questo repository e il template operativo da cui nascono i repo dataset DataCivicLab. + +Qui trovi il minimo necessario per far partire un progetto concreto: + +- `dataset.yml` come contratto del dataset +- `sql/` per CLEAN e MART +- `docs/` per documentazione locale del dataset +- `tests/` per i contract tests minimi +- `notebooks/` per leggere gli output reali della pipeline + +Un repo nato da questo template non serve solo a "ospitare dati". +Serve a rispondere in modo verificabile a una domanda civica centrale, lasciando spazio anche a domande complementari ben tracciate. + +## 🛠️ Confine con il toolkit + +Il motore della pipeline vive nel repository `toolkit`. +Questa repo non replica la logica di esecuzione del motore: definisce input, regole e output attesi per questo dataset. + +In pratica: + +- bug o feature di CLI, runner, validazioni runtime e run metadata -> repo `toolkit` +- bug o modifiche a fonti, mapping, SQL, mart, docs e notebook di dataset -> questa repo + +## 🔁 Da dove partire + +Se stai clonando il template per un nuovo progetto: + +1. aggiorna la domanda civica e gli esempi di insight +2. sostituisci fonti, copertura e unita di analisi +3. definisci metriche e tabelle finali +4. documenta le decisioni specifiche del dataset +5. esegui `py -m pytest tests/test_contract.py` + +La struttura resta invariata. Non serve capire tutto subito: qui trovi la base pratica da cui partire. + +## 🧪 Esecuzione tecnica + +```bash +pip install dataciviclab-toolkit +toolkit run all --config dataset.yml +toolkit validate all --config dataset.yml +toolkit status --dataset --year --latest --config dataset.yml +``` + +I notebook del template usano anche: + +```bash +toolkit inspect paths --config dataset.yml --year --json +``` + +Nota di contratto: + +- i path relativi in `dataset.yml` sono risolti rispetto alla directory del file `dataset.yml`, non rispetto al `cwd` +- i notebook non devono ricostruire a mano `root/data/raw|clean|mart|_runs` +- `metadata.json` e il payload ricco del layer +- `manifest.json` e il summary stabile del layer +- `data/_runs/.../.json` e il run record letto da `status` + +Per dettagli piu profondi su CLI, contratti stabili, workflow advanced e feature stability, il posto giusto e `toolkit`. + +## 🧭 Dove andare per il resto + +Questa repo resta focalizzata sul progetto dataset. + +Per il resto: + +- contesto del Lab, mappa delle repo e catalogo dataset: `dataciviclab` +- policy comuni, onboarding GitHub, issue/PR template e community health: `.github` +- motore tecnico della pipeline e documentazione del runtime: `toolkit` + +I riferimenti rapidi sono raccolti in `docs/lab_links.md`. + +## 🌍 Archivio pubblico + +Se il progetto pubblica artifact in un archivio pubblico DataCivicLab su Drive, il flusso consigliato e: + +1. eseguire e validare la pipeline in locale +2. verificare gli output sotto `root/data/...` +3. pubblicare solo gli artifact pubblici con uno script separato + +Questo passaggio e `maintainer-only`. + +Esempio: + +```bash +py scripts/publish_to_drive.py --config dataset.yml --drive-root "G:\\DataCivicLab" --dry-run +py scripts/publish_to_drive.py --config dataset.yml --drive-root "G:\\DataCivicLab" --year 2022 +``` + +La destinazione su Drive mantiene lo stesso path relativo degli output del toolkit sotto `root`. + +## Ritmo operativo consigliato + +Quando un repo dataset entra in ritmo, conviene mantenere una sequenza semplice: + +1. una domanda civica principale sempre visibile nel README +2. domande complementari che emergono e si chiariscono in Discussions +3. issue piccole per trasformare le domande mature in lavoro concreto +4. output condivisibili pubblicati con continuita + +L'output non deve essere sempre una dashboard completa. +Puo essere anche: + +- una risposta breve con una tabella +- un notebook che chiude una domanda precisa +- un aggiornamento intermedio su limiti, dati mancanti o primi pattern + +Questo aiuta a mantenere il repository vivo senza trasformarlo in un backlog confuso. diff --git a/WORKFLOW.md b/WORKFLOW.md index 4ca385e..77127ab 100644 --- a/WORKFLOW.md +++ b/WORKFLOW.md @@ -1,116 +1,46 @@ -# 🔁 WORKFLOW – come si lavora +# Workflow -Questo documento descrive il workflow standard per lavorare su un progetto DataCivicLab. +Come contribuire in modo semplice a un progetto dataset DataCivicLab. -Obiettivo: **collaborazione semplice, asincrona, scalabile**. +## Percorsi ---- +- feedback o idee: usa le Discussions della repo se vuoi lasciare una traccia ragionata +- avanzamento operativo: usa issue, project board o milestone della repo +- insight o visual: parti da `sql/` o `dashboard/` se il progetto li prevede -## 🧭 Principio base +## Dove andare -```text -Tutto parte da una Discussion. -Tutto finisce in una Pull Request. -``` +- setup e contributo rapido: [docs/contributing.md](docs/contributing.md) +- contesto DataCivicLab, policy comuni e motore tecnico: [docs/lab_links.md](docs/lab_links.md) +- indice docs locali: [docs/README.md](docs/README.md) ---- +## Confine tecnico -## 1) Discussion → idee, domande, contesto +- questa repo contiene config dataset, SQL, docs, test di contratto e notebook +- il motore di esecuzione della pipeline sta nel repo toolkit +- se il problema riguarda run, CLI o comportamento interno del motore, aprilo nel toolkit +- se il problema riguarda fonti, mapping, mart o documentazione del dataset, aprilo qui -Le **Discussions** servono per: -- proporre una domanda civica -- discutere dataset e fonti -- fare scelte metodologiche (KPI, perimetro, definizioni) -- allineare rapidamente il team +## Flusso minimo -👉 Nessun lavoro “pesante” parte senza almeno **una discussion iniziale**. +1. apri una domanda, un feedback o una correzione +2. scegli una issue o aprine una nuova +3. lavora su branch dedicato +4. apri una PR piccola e leggibile ---- +GitHub resta il posto dove deve restare la traccia utile. -## 2) Issue → task concreti +## Flusso tecnico minimo -Le **Issue** rappresentano lavoro reale. +1. valida la config con `py -m pytest tests/test_contract.py` +2. esegui `toolkit run all --config dataset.yml` +3. esegui `toolkit validate all --config dataset.yml` +4. esegui `toolkit status --dataset --year --latest --config dataset.yml` +5. usa `toolkit inspect paths --config dataset.yml --year --json` +6. usa i notebook per ispezionare RAW, CLEAN, MART e QA -Una issue dovrebbe: -- avere un obiettivo chiaro -- essere limitata (no mega-task) -- avere criteri di chiusura (Definition of Done) +## Maintainers -Esempi: -- ingestione dataset X (raw) -- pulizia e normalizzazione (clean) -- costruzione mart + KPI base -- prima dashboard (MVP) - -👉 Una issue = una cosa fatta. - ---- - -## 3) Branch → lavorare senza rompere `main` - -Ogni issue si lavora su un **branch dedicato**. - -Naming consigliato: -```text -issue-12-clean-dataset-rifiuti -``` - -Regole: -- non lavorare direttamente su `main` -- PR piccole e frequenti > PR gigante - ---- - -## 4) Pull Request → revisione e qualità - -La **Pull Request (PR)** serve a: -- mostrare cosa è stato fatto -- permettere review e miglioramenti -- lasciare traccia delle decisioni - -Una PR è pronta quando: -- il task è completo -- README / docs coinvolte sono aggiornate -- non sono stati caricati dati sul repo - ---- - -## 5) Review → Merge - -La review verifica: -- coerenza metodologica -- qualità dei notebook / query -- chiarezza della documentazione - -Chi può approvare: -- **Project Lead** o **Maintainer** (vedi `GOVERNANCE.md`) - -Dopo approvazione → merge su `main`. - ---- - -## 📦 Dati e Drive (importante) - -```text -GitHub = codice + metodo + documentazione -Drive = dati -``` - -Nelle PR: -- **non** caricare CSV/XLS/Parquet “pesanti” -- inserire **link Drive** e schema/README aggiornati -- spiegare cosa è cambiato e perché - ---- - -## ✅ Definition of Done (DoD) - -Per chiudere un task, in generale: -- output prodotto (notebook/query/dashboard) -- documentazione aggiornata (almeno README rilevanti) -- link Drive inseriti correttamente -- controlli qualità minimi eseguiti - -(Se vuoi una DoD più dettagliata: `docs/definition-of-done.md`.) - ---- +1. revisiona PR e stato del dataset +2. verifica `status` e output finali +3. se il progetto ha un archivio pubblico, pubblica gli artifact con `py scripts/publish_to_drive.py` diff --git a/dashboard/README.md b/dashboard/README.md index 093711b..1f64be5 100644 --- a/dashboard/README.md +++ b/dashboard/README.md @@ -1,41 +1,18 @@ -# 📊 /dashboards – Output pubblico +# /dashboard - Output pubblico opzionale -Questa cartella raccoglie le informazioni sugli **output pubblici** del progetto (dashboard, report, mappe). +Questa cartella raccoglie materiali per output pubblici del progetto, come dashboard, report o mappe. +Qui vanno link, note di lettura, screenshot e limiti dell'output, non i dati. -Qui non ci sono “file dati”: qui ci sono **link, screenshot e spiegazioni**. +## Cosa inserire qui ---- - -## ✅ Cosa inserire qui - -- **Link pubblico** alla dashboard (Looker Studio / Superset / altro) +- link pubblico alla dashboard - breve descrizione dei KPI principali - note su come leggere i grafici -- limiti e assunzioni (cosa NON si può dedurre) -- data ultimo aggiornamento - ---- - -## 🧾 Template consigliato - -```text -Tipo: Dashboard (Looker Studio) -Link: https://... -KPI principali: -- ... -Come leggere: -- ... -Limiti: -- ... -Ultimo aggiornamento: YYYY-MM-DD -``` - ---- - -## 🔁 Coerenza con i mart - -Ogni dashboard dovrebbe essere legata a uno o più dataset in `/data/mart` -(con schema documentato e link Drive aggiornato). +- limiti e assunzioni +- data di ultimo aggiornamento ---- +## Coerenza con i mart +Ogni dashboard dovrebbe essere collegata ai mart documentati e aggiornati del progetto. +Se il progetto usa un archivio pubblico su Drive, documenta qui quali file pubblicati alimentano la dashboard. +La pubblicazione su Drive resta una operazione `maintainer-only`. diff --git a/dataset.yml b/dataset.yml new file mode 100644 index 0000000..07b4636 --- /dev/null +++ b/dataset.yml @@ -0,0 +1,70 @@ +schema_version: 1 +root: "./_smoke_out" + +dataset: + name: "bdap_http_csv" + years: [2022] + +raw: + output_policy: "versioned" + sources: + - name: "bdap_csv" + type: "http_file" + args: + url: "https://bdap-opendata.rgs.mef.gov.it/export/csv/Rendiconto-Pubblicato---Serie-storica---Saldi.csv" + filename: "bdap_rendiconto_saldi_{year}.csv" + primary: true + +clean: + sql: "sql/clean.sql" + read_mode: "fallback" + read: + source: "config_only" + mode: "explicit" + include: + - "bdap_rendiconto_saldi_*.csv" + delim: ";" + decimal: "." + encoding: "utf-8" + header: true + columns: null + required_columns: + - "anno" + - "saldo_netto" + - "indebitamento_netto" + - "avanzo_primario" + - "entrate_finali" + - "spese_finali" + validate: + primary_key: + - "anno" + not_null: + - "anno" + min_rows: 1 + +mart: + tables: + - name: "mart_ok" + sql: "sql/mart/mart_ok.sql" + required_tables: + - "mart_ok" + validate: + table_rules: + mart_ok: + required_columns: + - "anno" + - "saldo_netto" + - "entrate_finali" + - "spese_finali" + primary_key: + - "anno" + not_null: + - "anno" + min_rows: 1 + +validation: + fail_on_error: true + +output: + artifacts: "minimal" + legacy_aliases: true diff --git a/docs/METHOD.md b/docs/METHOD.md deleted file mode 100644 index 4ea39b5..0000000 --- a/docs/METHOD.md +++ /dev/null @@ -1,23 +0,0 @@ -# 🧠 Metodo del progetto - -## Obiettivo -(Perché questo progetto esiste) - -## Assunzioni -- Assunzione 1 -- Assunzione 2 - -## Limiti dei dati -- Limite 1 -- Limite 2 - -## Scelte metodologiche -- Perché abbiamo fatto X invece di Y - -## Cosa NON copre questo progetto -(Esplicito) - -## Come replicare -- Dataset -- Query / notebook -- Passaggi principali diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..af43896 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,22 @@ +# Docs + +Questa cartella contiene i documenti locali, specifici di questo dataset. +Per standard del Lab e riferimenti organizzativi vedi [lab_links.md](lab_links.md). + +Il motore della pipeline non vive qui: questa documentazione descrive il dataset e il suo contratto verso il toolkit. +Le decisioni operative sul run reale devono restare coerenti con la CLI e con lo schema config del toolkit. + +Se ti serve contesto generale su DataCivicLab, parti da `dataciviclab`. +Se ti servono policy comuni o istruzioni GitHub valide per tutta l'organizzazione, parti da `.github`. + +## Essenziali + +- [overview.md](overview.md) +- [sources.md](sources.md) +- [data_dictionary.md](data_dictionary.md) +- [decisions.md](decisions.md) +- [contributing.md](contributing.md) + +## Archive + +- [Archived Lab-wide docs](_archive/INDEX.md) diff --git a/docs/_archive/INDEX.md b/docs/_archive/INDEX.md new file mode 100644 index 0000000..249a970 --- /dev/null +++ b/docs/_archive/INDEX.md @@ -0,0 +1,4 @@ +# Archive + +Questa cartella raccoglie documenti storici o di riferimento che non fanno parte del set minimo del template. +Usala solo come archivio leggero: i documenti locali correnti restano indicizzati in [../README.md](../README.md). diff --git a/docs/contributing.md b/docs/contributing.md new file mode 100644 index 0000000..80c85a8 --- /dev/null +++ b/docs/contributing.md @@ -0,0 +1,145 @@ +# Contributing + +Guida rapida per contribuire a un repo dataset senza dover capire tutto l'ecosistema in un colpo solo. + +Le policy comuni dell'organizzazione non vengono duplicate qui: per quelle, il posto giusto e `.github`. +Questo documento resta pratico e locale al repo dataset. + +## Setup minimo + +Prerequisiti: + +- avere accesso al repo +- avere Python e il toolkit disponibili nel proprio ambiente, oppure un checkout locale del toolkit +- lavorare sempre dalla root del progetto + +Questa repo contiene configurazione dataset, SQL, documentazione e test di contratto. +Il motore della pipeline sta nel repo `toolkit`. + +## Contract tests + +Esegui sempre prima: + +```sh +py -m pytest tests/test_contract.py +``` + +Questi test non verificano il motore del toolkit. +Verificano che questa repo esponga un contratto coerente per il dataset: file dichiarati, path, tabelle mart e struttura minima della config. + +## Smoke locale + +Per uno smoke test end-to-end: + +```sh +sh scripts/smoke.sh +``` + +Se il toolkit non e nel `PATH`, usa il fallback documentato nello script. +Se lo smoke fallisce per un problema del motore, apri il bug nel repo `toolkit`. +Se fallisce per config, SQL o assunzioni sul dato, correggi questa repo. + +Su Windows, se `sh` non e disponibile nel `PATH`, usa una shell POSIX come Git Bash oppure esegui i comandi toolkit equivalenti: + +```powershell +toolkit run all --config dataset.yml +toolkit validate all --config dataset.yml +toolkit status --dataset --year --latest --config dataset.yml +``` + +## Dove scrivere cosa + +- Discussions della repo: domande, interpretazioni, proposte e contesto +- Issues della repo: bug, task e blocchi operativi +- Project board o milestone della repo, se presenti: avanzamento e priorita +- Discord o altri canali veloci del team: utili per scambio rapido, non come fonte canonica + +## Publish su Drive + +Se il progetto usa un archivio pubblico su Drive, la pubblicazione va fatta dopo `run all` e `validate all`, non durante il run. +Questo passaggio e `maintainer-only`: non e richiesto ai contributor per lavorare su SQL, docs, test o notebook. + +Dry-run: + +```powershell +py scripts/publish_to_drive.py --config dataset.yml --drive-root "G:\DataCivicLab" --dry-run +``` + +Publish di un anno: + +```powershell +py scripts/publish_to_drive.py --config dataset.yml --drive-root "G:\DataCivicLab" --year 2022 +``` + +Lo script pubblica per default payload RAW, metadata, manifest e validation di `raw`, `clean`, `mart`, i parquet CLEAN/MART e l'ultimo run record. +La destinazione su Drive mantiene gli stessi path relativi sotto `root`, quindi pubblica sotto `/data/...`. + +## Comandi canonici toolkit + +```sh +toolkit run all --config dataset.yml +toolkit validate all --config dataset.yml +toolkit status --dataset --year --latest --config dataset.yml +toolkit inspect paths --config dataset.yml --year --json +``` + +Per workflow avanzati come `run raw|clean|mart`, `resume` o `profile raw`, vedi la documentazione advanced del toolkit. +Per il contratto stabile dei notebook e la matrice di stabilita delle feature, vedi anche: + +- `docs/notebook-contract.md` +- `docs/feature-stability.md` + +Quando lavori per layer invece che con `run all`, usa questa regola semplice: + +- `toolkit run raw|clean|mart ...` produce gli artifact +- `toolkit inspect paths --config dataset.yml --year --json` ti dice dove leggerli +- notebook e controlli manuali devono leggere i path restituiti, non ricostruirli a mano + +## Fasi operative + +- kickoff e contratto: `dataset.yml`, `README.md`, `tests/test_contract.py` +- sources e raw: `dataset.yml`, `docs/sources.md`, `docs/decisions.md`, `notebooks/01_inspect_raw.ipynb` +- clean: `sql/clean.sql`, `dataset.yml`, `notebooks/02_inspect_clean.ipynb` +- mart: `sql/mart/*.sql`, `dataset.yml`, `notebooks/03_explore_mart.ipynb` +- qa: `tests/test_contract.py`, `.github/workflows/ci.yml`, `notebooks/04_quality_checks.ipynb` +- dashboard/export: `dashboard/`, `README.md`, `notebooks/05_dashboard_export.ipynb` + +## Checklist lifecycle + +| Fase | File principali | Comando minimo | Notebook | +|---|---|---|---| +| Kickoff | `dataset.yml`, `README.md` | `py -m pytest tests/test_contract.py` | `00_quickstart.ipynb` | +| Sources/RAW | `dataset.yml`, `docs/sources.md`, `docs/decisions.md` | `toolkit run raw --config dataset.yml`, poi `toolkit inspect paths --config dataset.yml --year --json` | `01_inspect_raw.ipynb` | +| CLEAN | `sql/clean.sql`, `dataset.yml`, `docs/data_dictionary.md` | `toolkit run clean --config dataset.yml`, poi `toolkit inspect paths --config dataset.yml --year --json` | `02_inspect_clean.ipynb` | +| MART | `sql/mart/*.sql`, `dataset.yml` | `toolkit run mart --config dataset.yml`, poi `toolkit inspect paths --config dataset.yml --year --json` | `03_explore_mart.ipynb` | +| QA | `tests/test_contract.py`, `.github/workflows/ci.yml` | `toolkit validate all --config dataset.yml` | `04_quality_checks.ipynb` | +| Output pubblico | `dashboard/`, `README.md`, `scripts/publish_to_drive.py` | `maintainer-only: py scripts/publish_to_drive.py --config dataset.yml --drive-root "" --dry-run` | `05_dashboard_export.ipynb` | +| Release | `README.md`, `docs/overview.md`, `docs/data_dictionary.md` | `toolkit status --dataset --year --latest --config dataset.yml` | `00_quickstart.ipynb` | + +I notebook usano `toolkit inspect paths --config dataset.yml --year --json` come contratto stabile per localizzare gli output. + +Ruoli minimi da tenere distinti nei notebook: + +- `metadata.json` = payload ricco del layer +- `manifest.json` = summary stabile del layer con puntatori a metadata e validation +- `data/_runs/.../.json` = stato del run usato da `status` e `resume` + +## Regole veloci + +- non committare output sotto `data/`, salvo sample piccoli in `data/_examples` +- aggiorna `docs/decisions.md` quando cambi scelte o trade-off +- aggiorna `docs/data_dictionary.md` quando cambia il significato dei campi +- usa path root-relative e POSIX nella documentazione tecnica + +## Come aiutare in 15 minuti + +- controlla che `docs/sources.md` e `docs/overview.md` siano coerenti +- migliora una descrizione in `docs/data_dictionary.md` +- esegui `py -m pytest tests/test_contract.py` e segnala eventuali problemi +- apri i notebook per ispezionare gli output generati dal toolkit, senza aggiungere logica di pipeline qui + +## Poi dove vado? + +- workflow umano: [../WORKFLOW.md](../WORKFLOW.md) +- docs locali: [README.md](README.md) +- contesto DataCivicLab, policy comuni e motore: [lab_links.md](lab_links.md) diff --git a/docs/data_dictionary.md b/docs/data_dictionary.md new file mode 100644 index 0000000..dcdb30b --- /dev/null +++ b/docs/data_dictionary.md @@ -0,0 +1,30 @@ +# Data Dictionary + +Audience: Maintainers + +Schema sintetico dei dataset gestiti dal progetto. + +## Raw + +| Column | Type | Description | Nullable | Notes | +| --- | --- | --- | --- | --- | +| year | int | Reference year | no | Example placeholder | +| entity_id | string | Stable entity identifier | no | Replace with project key | +| metric_value | float | Raw metric value | yes | Replace with actual meaning | + +## Clean + +| Column | Type | Description | Nullable | Validation | +| --- | --- | --- | --- | --- | +| year | int | Reference year | no | not_null | +| entity_id | string | Stable entity identifier | no | not_null, unique_key | +| metric_value | float | Normalized metric value | yes | range TBD | + +## Mart + +| Column | Type | Description | Nullable | Consumer | +| --- | --- | --- | --- | --- | +| year | int | Reference year | no | dashboard/report | +| entity_id | string | Stable entity identifier | no | dashboard/report | +| metric_value | float | Final metric | yes | dashboard/report | +| rows_in_year | int | Record count by year | no | QA/dashboard | diff --git a/docs/decisions.md b/docs/decisions.md new file mode 100644 index 0000000..16928b6 --- /dev/null +++ b/docs/decisions.md @@ -0,0 +1,22 @@ +# Decision log - [Nome dataset] + +Qui registriamo scelte tecniche che cambiano il significato dei dati, come mapping, filtri o definizioni delle metriche. + +## D-001 - Standardizzazione chiavi territoriali + +- decisione: [...] +- motivo: [...] +- impatto: [...] +- alternative valutate: [...] + +## D-002 - Policy valori mancanti + +- decisione: [...] +- motivo: [...] +- impatto: [...] + +## D-003 - Definizione metrica [X] + +- decisione: [...] +- formula: [...] +- motivo: [...] diff --git a/docs/definition-of-done.md b/docs/definition-of-done.md deleted file mode 100644 index 2a49b5b..0000000 --- a/docs/definition-of-done.md +++ /dev/null @@ -1,37 +0,0 @@ -# Definition of Done (DoD) - -Un progetto è “Done” quando: - -## Domanda civica -- La domanda è una sola, chiara e misurabile -- L’output risponde davvero alla domanda (anche con limiti espliciti) - -## Dati -- Raw immutato -- Clean/Mart riproducibili -- Tipi coerenti e naming standard (snake_case) -- Nessuna logica “magica” nascosta - -## Metodo -- Assunzioni dichiarate -- Limiti dichiarati -- Confronti omogenei (perimetro, periodo, definizioni) - -## Viz -- Dashboard leggibile da non tecnico -- Titoli “parlanti” -- Unità di misura sempre visibili -- Filtri essenziali (no overload) - -## QA -- Sanity check superati (totali, outlier, ordini di grandezza) -- Cross-check con fonte o riferimenti dove possibile -- Issue aperte per anomalie residue + severità - -## Doc -- README aggiornato con: scopo, dataset, metodo, output, limiti -- Link a dashboard + dataset + query/notebook principali -- “Cosa emerge” e “cosa non emerge” scritto chiaramente - - -> Nota: una sintesi è in `WORKFLOW.md`. \ No newline at end of file diff --git a/docs/lab_links.md b/docs/lab_links.md new file mode 100644 index 0000000..ed48ca9 --- /dev/null +++ b/docs/lab_links.md @@ -0,0 +1,21 @@ +# Lab Links + +Gli standard del Lab sono centralizzati e non vengono duplicati in questo template. +Usa questa pagina come ponte verso i repository organizzativi corretti. + +## Hub del Lab + +- [dataciviclab](https://github.com/dataciviclab/dataciviclab): hub pubblico del Lab, mappa delle repo, catalogo dataset, governance alta e canali community + +## Policy organizzative + +- [.github](https://github.com/dataciviclab/.github): policy condivise, issue template, PR template e community health files + +## Motore tecnico + +- [toolkit](https://github.com/dataciviclab/toolkit): workflow tecnico canonico, CLI, contratti stabili e documentazione del motore + +## Canali pubblici + +- usa i canali della singola repo dataset per lasciare traccia utile del lavoro +- se ti serve contesto org-wide, parti da `dataciviclab` e da `.github` diff --git a/docs/overview.md b/docs/overview.md new file mode 100644 index 0000000..3d8b0ce --- /dev/null +++ b/docs/overview.md @@ -0,0 +1,36 @@ +# Overview - [Nome dataset] + +## Cos'è il dataset + +Questo dataset organizza informazioni pubbliche per aiutare a leggere un fenomeno civico in modo chiaro, confrontabile e verificabile. Serve a trasformare dati sparsi o poco leggibili in una base utile per capire meglio cosa succede e come cambia nel tempo. + +## Unità di analisi e chiavi + +- unità di analisi: [Comune / ASL / Provincia / Regione / altro] +- granularità temporale: [anno / mese / trimestre / altro] +- chiavi principali: [es. codice_istat, anno] + +## Copertura + +- anni coperti: [...] +- territorio coperto: [...] +- note sulla copertura: [...] + +## Domande civiche che puoi esplorare + +- come varia questo fenomeno tra territori diversi +- quali aree mostrano i cambiamenti più forti nel tempo +- dove emergono possibili squilibri o criticità +- quali territori risultano più esposti o più resilienti +- dove servono approfondimenti o verifiche ulteriori + +## Limiti e caveat + +- il dataset dipende dalla qualità e dalla tempestività delle fonti ufficiali +- alcune definizioni possono cambiare nel tempo +- copertura e granularità non sempre coincidono per tutti gli anni +- i dati descrivono il fenomeno solo entro i limiti delle fonti disponibili + +## Metodo + +Il progetto organizza i dati in tre passaggi semplici: raccolta del dato di partenza, pulizia e normalizzazione, produzione di tabelle finali per analisi e output pubblici. Per dettagli tecnici, vedi il repository Toolkit DataCivicLab. diff --git a/docs/roles.md b/docs/roles.md deleted file mode 100644 index e3bcf02..0000000 --- a/docs/roles.md +++ /dev/null @@ -1,21 +0,0 @@ -# Ruoli del progetto - -Questi ruoli servono a rendere il lavoro replicabile, verificabile e chiudibile. - -## Project Lead -Facilita, coordina, mantiene la direzione, decide quando chiudere. - -## Data -Pulizia dati, query, metriche, output clean/mart. - -## Metodo -Assunzioni, limiti, coerenza definizioni, perimetri e confronti. - -## Viz -Dashboard/visual: struttura, leggibilità, filtri, UX. - -## QA -Controllo qualità: sanity check, cross-check, anomalie, regressioni. - -## Doc -Spiegazioni pubbliche: README, note metodologiche, cosa emerge / non emerge. diff --git a/docs/sources.md b/docs/sources.md new file mode 100644 index 0000000..d3d9aa6 --- /dev/null +++ b/docs/sources.md @@ -0,0 +1,23 @@ +# Fonti ufficiali - [Nome dataset] + +## Fonte primaria + +- ente: [...] +- pagina ufficiale: [...] +- download: [...] +- licenza o note d'uso: [...] + +## Mirror + +- [mirror 1] +- [mirror 2] + +## Versioning + +- logica versioni: [per anno / per release / per aggiornamenti] +- campi che cambiano nel tempo: [se noti] + +## Note su qualità e pubblicazione + +- [encoding, delimitatore, header, se rilevante] +- [note su valori mancanti] diff --git a/notebooks/00_quickstart.ipynb b/notebooks/00_quickstart.ipynb new file mode 100644 index 0000000..20d11ab --- /dev/null +++ b/notebooks/00_quickstart.ipynb @@ -0,0 +1,103 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 00 Quickstart\n", + "\n", + "- legge `../dataset.yml`\n", + "- mostra i path reali attesi dal toolkit\n", + "- esegue il run solo se abiliti esplicitamente `RUN_TOOLKIT = True`" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from pathlib import Path\n", + "import json\n", + "import shutil\n", + "import subprocess\n", + "import yaml\n", + "\n", + "ROOT = Path('.').resolve()\n", + "DATASET_YML = (ROOT / 'dataset.yml').resolve() if (ROOT / 'dataset.yml').exists() else (ROOT / '..' / 'dataset.yml').resolve()\n", + "CFG = yaml.safe_load(DATASET_YML.read_text(encoding='utf-8'))\n", + "BASE_DIR = DATASET_YML.parent\n", + "DATASET = CFG['dataset']['name']\n", + "YEARS = CFG['dataset']['years']\n", + "YEAR_INDEX = 0\n", + "YEAR = YEARS[YEAR_INDEX] if YEARS and 0 <= YEAR_INDEX < len(YEARS) else YEARS[0]\n", + "MART_TABLES = [table['name'] for table in CFG.get('mart', {}).get('tables', [])]\n", + "RUN_TOOLKIT = False\n", + "CLI_PREFIX = ['toolkit'] if shutil.which('toolkit') else ['py', '-m', 'toolkit.cli.app']\n", + "RUN_CMD = CLI_PREFIX + ['run', 'all', '--config', str(DATASET_YML)]\n", + "VALIDATE_CMD = CLI_PREFIX + ['validate', 'all', '--config', str(DATASET_YML)]\n", + "INSPECT_CMD = CLI_PREFIX + ['inspect', 'paths', '--config', str(DATASET_YML), '--year', str(YEAR), '--json']\n", + "INSPECT_RESULT = subprocess.run(INSPECT_CMD, capture_output=True, text=True)\n", + "if INSPECT_RESULT.returncode != 0:\n", + " raise RuntimeError(INSPECT_RESULT.stderr.strip() or INSPECT_RESULT.stdout.strip() or 'toolkit inspect paths failed')\n", + "INSPECT = json.loads(INSPECT_RESULT.stdout)\n", + "\n", + "{\n", + " 'DATASET_YML': str(DATASET_YML),\n", + " 'ROOT': INSPECT.get('root'),\n", + " 'DATASET': DATASET,\n", + " 'YEARS': YEARS,\n", + " 'YEAR_INDEX': YEAR_INDEX,\n", + " 'YEAR': YEAR,\n", + " 'MART_TABLES': MART_TABLES,\n", + " 'CLI_PREFIX': CLI_PREFIX,\n", + " 'INSPECT_CMD': INSPECT_CMD,\n", + "}" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "{\n", + " 'raw_dir': INSPECT['paths']['raw']['dir'],\n", + " 'clean_dir': INSPECT['paths']['clean']['dir'],\n", + " 'mart_dir': INSPECT['paths']['mart']['dir'],\n", + " 'run_dir': INSPECT['paths']['run_dir'],\n", + " 'latest_run': INSPECT.get('latest_run'),\n", + "}" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "print('Run command:', ' '.join(RUN_CMD))\n", + "print('Validate command:', ' '.join(VALIDATE_CMD))\n", + "\n", + "if RUN_TOOLKIT:\n", + " subprocess.run(RUN_CMD, check=True)\n", + " subprocess.run(VALIDATE_CMD, check=True)\n", + "else:\n", + " print('Toolkit run disabled. Set RUN_TOOLKIT = True to execute the pipeline.')" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/notebooks/01_inspect_raw.ipynb b/notebooks/01_inspect_raw.ipynb new file mode 100644 index 0000000..a3577fd --- /dev/null +++ b/notebooks/01_inspect_raw.ipynb @@ -0,0 +1,123 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 01 Inspect RAW\n", + "\n", + "- apre il layer RAW reale del toolkit\n", + "- legge `manifest.json`, `metadata.json` e `raw_validation.json`\n", + "- prova a mostrare un sample del file primario dichiarato nel manifest" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from pathlib import Path\n", + "import json\n", + "import shutil\n", + "import subprocess\n", + "import duckdb\n", + "import yaml\n", + "\n", + "ROOT = Path('.').resolve()\n", + "DATASET_YML = (ROOT / 'dataset.yml').resolve() if (ROOT / 'dataset.yml').exists() else (ROOT / '..' / 'dataset.yml').resolve()\n", + "CFG = yaml.safe_load(DATASET_YML.read_text(encoding='utf-8'))\n", + "DATASET = CFG['dataset']['name']\n", + "YEARS = CFG['dataset']['years']\n", + "YEAR_INDEX = 0\n", + "YEAR = YEARS[YEAR_INDEX] if YEARS and 0 <= YEAR_INDEX < len(YEARS) else YEARS[0]\n", + "CLI_PREFIX = ['toolkit'] if shutil.which('toolkit') else ['py', '-m', 'toolkit.cli.app']\n", + "INSPECT_CMD = CLI_PREFIX + ['inspect', 'paths', '--config', str(DATASET_YML), '--year', str(YEAR), '--json']\n", + "INSPECT = json.loads(subprocess.run(INSPECT_CMD, capture_output=True, text=True, check=True).stdout)\n", + "RAW_DIR = Path(INSPECT['paths']['raw']['dir'])\n", + "MANIFEST_PATH = Path(INSPECT['paths']['raw']['manifest'])\n", + "METADATA_PATH = Path(INSPECT['paths']['raw']['metadata'])\n", + "VALIDATION_PATH = Path(INSPECT['paths']['raw']['validation'])\n", + "PROFILE_DIR = RAW_DIR / '_profile'\n", + "\n", + "{\n", + " 'YEARS': YEARS,\n", + " 'YEAR_INDEX': YEAR_INDEX,\n", + " 'RAW_DIR': str(RAW_DIR),\n", + " 'INSPECT_CMD': INSPECT_CMD,\n", + " 'MANIFEST_EXISTS': MANIFEST_PATH.exists(),\n", + " 'METADATA_EXISTS': METADATA_PATH.exists(),\n", + " 'VALIDATION_EXISTS': VALIDATION_PATH.exists(),\n", + "}" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "manifest = json.loads(MANIFEST_PATH.read_text(encoding='utf-8')) if MANIFEST_PATH.exists() else {}\n", + "metadata = json.loads(METADATA_PATH.read_text(encoding='utf-8')) if METADATA_PATH.exists() else {}\n", + "validation = json.loads(VALIDATION_PATH.read_text(encoding='utf-8')) if VALIDATION_PATH.exists() else {}\n", + "\n", + "display(manifest)\n", + "display(metadata)\n", + "display(validation)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "primary_rel = manifest.get('primary_output_file') if manifest else None\n", + "primary_path = (RAW_DIR / primary_rel).resolve() if primary_rel else None\n", + "primary_path" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "if primary_path and primary_path.exists():\n", + " suffix = primary_path.suffix.lower()\n", + " if suffix == '.csv':\n", + " con = duckdb.connect()\n", + " preview = con.execute(\n", + " f\"SELECT * FROM read_csv_auto('{primary_path.as_posix()}', SAMPLE_SIZE=-1) LIMIT 20\"\n", + " ).df()\n", + " display(preview)\n", + " elif suffix == '.parquet':\n", + " con = duckdb.connect()\n", + " preview = con.execute(f\"SELECT * FROM read_parquet('{primary_path.as_posix()}') LIMIT 20\").df()\n", + " display(preview)\n", + " else:\n", + " print({'primary_output_file': str(primary_path), 'bytes': primary_path.stat().st_size, 'suffix': suffix})\n", + "else:\n", + " print('Primary output file not found. Inspect manifest manually.')\n", + "\n", + "if PROFILE_DIR.exists():\n", + " display(sorted(path.name for path in PROFILE_DIR.iterdir()))\n", + "else:\n", + " print('No _profile directory found for this RAW run.')" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/notebooks/02_inspect_clean.ipynb b/notebooks/02_inspect_clean.ipynb new file mode 100644 index 0000000..029fef3 --- /dev/null +++ b/notebooks/02_inspect_clean.ipynb @@ -0,0 +1,107 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 02 Inspect CLEAN\n", + "\n", + "- apre il parquet CLEAN prodotto dal toolkit\n", + "- mostra schema, preview e sanity checks minimi\n", + "- aiuta a verificare `clean.required_columns` e `clean.validate`" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from pathlib import Path\n", + "import json\n", + "import shutil\n", + "import subprocess\n", + "import duckdb\n", + "import yaml\n", + "\n", + "ROOT = Path('.').resolve()\n", + "DATASET_YML = (ROOT / 'dataset.yml').resolve() if (ROOT / 'dataset.yml').exists() else (ROOT / '..' / 'dataset.yml').resolve()\n", + "CFG = yaml.safe_load(DATASET_YML.read_text(encoding='utf-8'))\n", + "DATASET = CFG['dataset']['name']\n", + "YEARS = CFG['dataset']['years']\n", + "YEAR_INDEX = 0\n", + "YEAR = YEARS[YEAR_INDEX] if YEARS and 0 <= YEAR_INDEX < len(YEARS) else YEARS[0]\n", + "CLI_PREFIX = ['toolkit'] if shutil.which('toolkit') else ['py', '-m', 'toolkit.cli.app']\n", + "INSPECT_CMD = CLI_PREFIX + ['inspect', 'paths', '--config', str(DATASET_YML), '--year', str(YEAR), '--json']\n", + "INSPECT = json.loads(subprocess.run(INSPECT_CMD, capture_output=True, text=True, check=True).stdout)\n", + "CLEAN_DIR = Path(INSPECT['paths']['clean']['dir'])\n", + "CLEAN_PATH = Path(INSPECT['paths']['clean']['output'])\n", + "REQUIRED_COLUMNS = CFG.get('clean', {}).get('required_columns', [])\n", + "PRIMARY_KEY = CFG.get('clean', {}).get('validate', {}).get('primary_key', [])\n", + "\n", + "{\n", + " 'YEARS': YEARS,\n", + " 'YEAR_INDEX': YEAR_INDEX,\n", + " 'CLEAN_PATH': str(CLEAN_PATH),\n", + " 'INSPECT_CMD': INSPECT_CMD,\n", + " 'REQUIRED_COLUMNS': REQUIRED_COLUMNS,\n", + " 'PRIMARY_KEY': PRIMARY_KEY,\n", + "}" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "con = duckdb.connect()\n", + "\n", + "if CLEAN_PATH.exists():\n", + " schema_df = con.execute(f\"DESCRIBE SELECT * FROM read_parquet('{CLEAN_PATH.as_posix()}')\").df()\n", + " preview_df = con.execute(f\"SELECT * FROM read_parquet('{CLEAN_PATH.as_posix()}') LIMIT 20\").df()\n", + " display(schema_df)\n", + " display(preview_df)\n", + "else:\n", + " print('CLEAN parquet not found. Run toolkit run clean --config dataset.yml first.')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "if CLEAN_PATH.exists():\n", + " row_count = con.execute(f\"SELECT COUNT(*) AS row_count FROM read_parquet('{CLEAN_PATH.as_posix()}')\").df()\n", + " display(row_count)\n", + "\n", + " if REQUIRED_COLUMNS:\n", + " available = {row[0] for row in con.execute(f\"DESCRIBE SELECT * FROM read_parquet('{CLEAN_PATH.as_posix()}')\").fetchall()}\n", + " print({'missing_required_columns': [col for col in REQUIRED_COLUMNS if col not in available]})\n", + "\n", + " if PRIMARY_KEY:\n", + " keys = ', '.join(PRIMARY_KEY)\n", + " dup_df = con.execute(\n", + " f\"SELECT {keys}, COUNT(*) AS dup_count FROM read_parquet('{CLEAN_PATH.as_posix()}') GROUP BY {keys} HAVING COUNT(*) > 1 LIMIT 20\"\n", + " ).df()\n", + " display(dup_df)\n", + "else:\n", + " print('No CLEAN output available.')" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/notebooks/03_explore_mart.ipynb b/notebooks/03_explore_mart.ipynb new file mode 100644 index 0000000..0b698d1 --- /dev/null +++ b/notebooks/03_explore_mart.ipynb @@ -0,0 +1,105 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 03 Explore MART\n", + "\n", + "- apre il primo mart dichiarato in `dataset.yml`\n", + "- mostra schema, preview e aggregazioni esplorative\n", + "- aiuta a collegare i mart alle domande civiche" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from pathlib import Path\n", + "import json\n", + "import shutil\n", + "import subprocess\n", + "import duckdb\n", + "import yaml\n", + "\n", + "ROOT = Path('.').resolve()\n", + "DATASET_YML = (ROOT / 'dataset.yml').resolve() if (ROOT / 'dataset.yml').exists() else (ROOT / '..' / 'dataset.yml').resolve()\n", + "CFG = yaml.safe_load(DATASET_YML.read_text(encoding='utf-8'))\n", + "DATASET = CFG['dataset']['name']\n", + "YEARS = CFG['dataset']['years']\n", + "YEAR_INDEX = 0\n", + "YEAR = YEARS[YEAR_INDEX] if YEARS and 0 <= YEAR_INDEX < len(YEARS) else YEARS[0]\n", + "TABLES = CFG.get('mart', {}).get('tables', [])\n", + "TABLE_INDEX = 0\n", + "SELECTED_TABLE = TABLES[TABLE_INDEX] if TABLES and 0 <= TABLE_INDEX < len(TABLES) else (TABLES[0] if TABLES else {'name': 'mart_ok'})\n", + "TABLE_NAME = SELECTED_TABLE['name']\n", + "CLI_PREFIX = ['toolkit'] if shutil.which('toolkit') else ['py', '-m', 'toolkit.cli.app']\n", + "INSPECT_CMD = CLI_PREFIX + ['inspect', 'paths', '--config', str(DATASET_YML), '--year', str(YEAR), '--json']\n", + "INSPECT = json.loads(subprocess.run(INSPECT_CMD, capture_output=True, text=True, check=True).stdout)\n", + "MART_OUTPUTS = INSPECT['paths']['mart']['outputs']\n", + "MART_PATH = Path(MART_OUTPUTS[TABLE_INDEX]) if 0 <= TABLE_INDEX < len(MART_OUTPUTS) else None\n", + "{'YEARS': YEARS, 'YEAR_INDEX': YEAR_INDEX, 'TABLES': [table['name'] for table in TABLES], 'TABLE_INDEX': TABLE_INDEX, 'TABLE_NAME': TABLE_NAME, 'MART_PATH': str(MART_PATH), 'INSPECT_CMD': INSPECT_CMD}" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "con = duckdb.connect()\n", + "YEAR_COL = None\n", + "METRIC_COL = None\n", + "\n", + "def choose_columns(schema_rows):\n", + " year_col = next((row[0] for row in schema_rows if str(row[0]).lower() == 'year' or 'anno' in str(row[0]).lower()), None)\n", + " numeric_rows = [row for row in schema_rows if any(token in str(row[1]).upper() for token in ['INT', 'DECIMAL', 'DOUBLE', 'FLOAT', 'REAL', 'BIGINT'])]\n", + " metric_col = next((row[0] for row in numeric_rows if any(token in str(row[0]).lower() for token in ['value', 'tot', 'importo', 'ammontare', 'saldo', 'spese', 'entrate', 'pct', 'percent'])), None)\n", + " if metric_col is None and numeric_rows:\n", + " metric_col = numeric_rows[0][0]\n", + " return year_col, metric_col\n", + "\n", + "if MART_PATH and MART_PATH.exists():\n", + " schema_rows = con.execute(f\"DESCRIBE SELECT * FROM read_parquet('{MART_PATH.as_posix()}')\").fetchall()\n", + " schema_df = con.execute(f\"DESCRIBE SELECT * FROM read_parquet('{MART_PATH.as_posix()}')\").df()\n", + " preview_df = con.execute(f\"SELECT * FROM read_parquet('{MART_PATH.as_posix()}') LIMIT 20\").df()\n", + " YEAR_COL, METRIC_COL = choose_columns(schema_rows)\n", + " display(schema_df)\n", + " display(preview_df)\n", + " print({'YEAR_COL': YEAR_COL, 'METRIC_COL': METRIC_COL})\n", + "else:\n", + " print('MART parquet not found. Run toolkit run mart --config dataset.yml first.')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "if MART_PATH and MART_PATH.exists() and YEAR_COL and METRIC_COL:\n", + " by_year = con.execute(\n", + " f\"SELECT {YEAR_COL} AS year_like, COUNT(*) AS rows, SUM({METRIC_COL}) AS metric_total FROM read_parquet('{MART_PATH.as_posix()}') GROUP BY 1 ORDER BY 1\"\n", + " ).df()\n", + " display(by_year)\n", + "else:\n", + " print('No year-like column or metric column detected.')" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/notebooks/04_quality_checks.ipynb b/notebooks/04_quality_checks.ipynb new file mode 100644 index 0000000..6cb3c89 --- /dev/null +++ b/notebooks/04_quality_checks.ipynb @@ -0,0 +1,111 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 04 Quality Checks\n", + "\n", + "- esegue controlli ripetibili sul primo mart dichiarato in config\n", + "- usa le chiavi di validazione del mart quando disponibili\n", + "- aiuta a investigare anomalie residue dopo `toolkit validate all`" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from pathlib import Path\n", + "import json\n", + "import shutil\n", + "import subprocess\n", + "import duckdb\n", + "import yaml\n", + "\n", + "ROOT = Path('.').resolve()\n", + "DATASET_YML = (ROOT / 'dataset.yml').resolve() if (ROOT / 'dataset.yml').exists() else (ROOT / '..' / 'dataset.yml').resolve()\n", + "CFG = yaml.safe_load(DATASET_YML.read_text(encoding='utf-8'))\n", + "DATASET = CFG['dataset']['name']\n", + "YEARS = CFG['dataset']['years']\n", + "YEAR_INDEX = 0\n", + "YEAR = YEARS[YEAR_INDEX] if YEARS and 0 <= YEAR_INDEX < len(YEARS) else YEARS[0]\n", + "TABLES = CFG.get('mart', {}).get('tables', [])\n", + "TABLE_INDEX = 0\n", + "SELECTED_TABLE = TABLES[TABLE_INDEX] if TABLES and 0 <= TABLE_INDEX < len(TABLES) else (TABLES[0] if TABLES else {'name': 'mart_ok'})\n", + "TABLE_NAME = SELECTED_TABLE['name']\n", + "TABLE_RULES = CFG.get('mart', {}).get('validate', {}).get('table_rules', {}).get(TABLE_NAME, {})\n", + "KEY_COLUMNS = TABLE_RULES.get('primary_key', [])\n", + "CLI_PREFIX = ['toolkit'] if shutil.which('toolkit') else ['py', '-m', 'toolkit.cli.app']\n", + "INSPECT_CMD = CLI_PREFIX + ['inspect', 'paths', '--config', str(DATASET_YML), '--year', str(YEAR), '--json']\n", + "INSPECT = json.loads(subprocess.run(INSPECT_CMD, capture_output=True, text=True, check=True).stdout)\n", + "MART_OUTPUTS = INSPECT['paths']['mart']['outputs']\n", + "MART_PATH = Path(MART_OUTPUTS[TABLE_INDEX]) if 0 <= TABLE_INDEX < len(MART_OUTPUTS) else None\n", + "{'YEARS': YEARS, 'YEAR_INDEX': YEAR_INDEX, 'TABLES': [table['name'] for table in TABLES], 'TABLE_INDEX': TABLE_INDEX, 'TABLE_NAME': TABLE_NAME, 'MART_PATH': str(MART_PATH), 'INSPECT_CMD': INSPECT_CMD}" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "con = duckdb.connect()\n", + "NUMERIC_COLUMNS = []\n", + "\n", + "def detect_numeric_columns(path):\n", + " rows = con.execute(f\"DESCRIBE SELECT * FROM read_parquet('{path.as_posix()}')\").fetchall()\n", + " return [row[0] for row in rows if any(token in str(row[1]).upper() for token in ['INT', 'DECIMAL', 'DOUBLE', 'FLOAT', 'REAL', 'BIGINT'])]\n", + "\n", + "if MART_PATH and MART_PATH.exists():\n", + " NUMERIC_COLUMNS = detect_numeric_columns(MART_PATH)[:3]\n", + " print({'KEY_COLUMNS': KEY_COLUMNS, 'NUMERIC_COLUMNS': NUMERIC_COLUMNS})\n", + "else:\n", + " print('MART parquet not found.')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "if MART_PATH and MART_PATH.exists():\n", + " if KEY_COLUMNS:\n", + " keys = ', '.join(KEY_COLUMNS)\n", + " dup_df = con.execute(\n", + " f\"SELECT {keys}, COUNT(*) AS dup_count FROM read_parquet('{MART_PATH.as_posix()}') GROUP BY {keys} HAVING COUNT(*) > 1 ORDER BY dup_count DESC LIMIT 20\"\n", + " ).df()\n", + " display(dup_df)\n", + " else:\n", + " print('Duplicate-key check skipped: no primary key declared for this mart.')\n", + "\n", + " columns = [row[0] for row in con.execute(f\"DESCRIBE SELECT * FROM read_parquet('{MART_PATH.as_posix()}')\").fetchall()]\n", + " null_expr = ', '.join([f\"AVG(CASE WHEN {col} IS NULL THEN 1 ELSE 0 END) AS {col}_null_rate\" for col in columns])\n", + " null_df = con.execute(f\"SELECT {null_expr} FROM read_parquet('{MART_PATH.as_posix()}')\").df().T.reset_index()\n", + " display(null_df)\n", + "\n", + " if NUMERIC_COLUMNS:\n", + " range_expr = ', '.join([f\"MIN({col}) AS {col}_min, MAX({col}) AS {col}_max\" for col in NUMERIC_COLUMNS])\n", + " range_df = con.execute(f\"SELECT {range_expr} FROM read_parquet('{MART_PATH.as_posix()}')\").df().T.reset_index()\n", + " display(range_df)\n", + "else:\n", + " print('No MART output available.')" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/notebooks/05_dashboard_export.ipynb b/notebooks/05_dashboard_export.ipynb new file mode 100644 index 0000000..c29b947 --- /dev/null +++ b/notebooks/05_dashboard_export.ipynb @@ -0,0 +1,128 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 05 Dashboard Export\n", + "\n", + "- prepara un export leggero partendo dal primo mart dichiarato in config\n", + "- non scrive file finche `EXPORT = False`\n", + "- usa `_tmp/` per evitare output committati nel repo" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from pathlib import Path\n", + "import json\n", + "import shutil\n", + "import subprocess\n", + "import duckdb\n", + "import yaml\n", + "\n", + "ROOT = Path('.').resolve()\n", + "DATASET_YML = (ROOT / 'dataset.yml').resolve() if (ROOT / 'dataset.yml').exists() else (ROOT / '..' / 'dataset.yml').resolve()\n", + "CFG = yaml.safe_load(DATASET_YML.read_text(encoding='utf-8'))\n", + "BASE_DIR = DATASET_YML.parent\n", + "DATASET = CFG['dataset']['name']\n", + "YEARS = CFG['dataset']['years']\n", + "YEAR_INDEX = 0\n", + "YEAR = YEARS[YEAR_INDEX] if YEARS and 0 <= YEAR_INDEX < len(YEARS) else YEARS[0]\n", + "TABLES = CFG.get('mart', {}).get('tables', [])\n", + "TABLE_INDEX = 0\n", + "SELECTED_TABLE = TABLES[TABLE_INDEX] if TABLES and 0 <= TABLE_INDEX < len(TABLES) else (TABLES[0] if TABLES else {'name': 'mart_ok'})\n", + "TABLE_NAME = SELECTED_TABLE['name']\n", + "CLI_PREFIX = ['toolkit'] if shutil.which('toolkit') else ['py', '-m', 'toolkit.cli.app']\n", + "INSPECT_CMD = CLI_PREFIX + ['inspect', 'paths', '--config', str(DATASET_YML), '--year', str(YEAR), '--json']\n", + "INSPECT = json.loads(subprocess.run(INSPECT_CMD, capture_output=True, text=True, check=True).stdout)\n", + "MART_OUTPUTS = INSPECT['paths']['mart']['outputs']\n", + "MART_PATH = Path(MART_OUTPUTS[TABLE_INDEX]) if 0 <= TABLE_INDEX < len(MART_OUTPUTS) else None\n", + "OUT_DIR = (BASE_DIR / '_tmp').resolve()\n", + "EXPORT = False\n", + "{'YEARS': YEARS, 'YEAR_INDEX': YEAR_INDEX, 'TABLES': [table['name'] for table in TABLES], 'TABLE_INDEX': TABLE_INDEX, 'TABLE_NAME': TABLE_NAME, 'MART_PATH': str(MART_PATH), 'INSPECT_CMD': INSPECT_CMD}" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "con = duckdb.connect()\n", + "YEAR_COL = None\n", + "METRIC_COL = None\n", + "export_df = None\n", + "\n", + "def choose_columns(path):\n", + " rows = con.execute(f\"DESCRIBE SELECT * FROM read_parquet('{path.as_posix()}')\").fetchall()\n", + " year_col = next((row[0] for row in rows if str(row[0]).lower() == 'year' or 'anno' in str(row[0]).lower()), None)\n", + " numeric_rows = [row[0] for row in rows if any(token in str(row[1]).upper() for token in ['INT', 'DECIMAL', 'DOUBLE', 'FLOAT', 'REAL', 'BIGINT'])]\n", + " metric_col = next((col for col in numeric_rows if any(token in col.lower() for token in ['value', 'tot', 'importo', 'ammontare', 'saldo', 'spese', 'entrate', 'pct', 'percent'])), None)\n", + " if metric_col is None and numeric_rows:\n", + " metric_col = numeric_rows[0]\n", + " return year_col, metric_col\n", + "\n", + "if MART_PATH and MART_PATH.exists():\n", + " YEAR_COL, METRIC_COL = choose_columns(MART_PATH)\n", + " print({'YEAR_COL': YEAR_COL, 'METRIC_COL': METRIC_COL})\n", + "else:\n", + " print('MART parquet not found.')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "if MART_PATH and MART_PATH.exists() and YEAR_COL and METRIC_COL:\n", + " export_df = con.execute(\n", + " f\"SELECT {YEAR_COL} AS year_like, {METRIC_COL} AS metric_value FROM read_parquet('{MART_PATH.as_posix()}') ORDER BY 1\"\n", + " ).df()\n", + "elif MART_PATH and MART_PATH.exists():\n", + " export_df = con.execute(f\"SELECT * FROM read_parquet('{MART_PATH.as_posix()}') LIMIT 1000\").df()\n", + "else:\n", + " export_df = None\n", + "\n", + "if export_df is not None:\n", + " display(export_df.head())" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "if EXPORT and export_df is not None:\n", + " OUT_DIR.mkdir(parents=True, exist_ok=True)\n", + " csv_path = OUT_DIR / f'{TABLE_NAME}_dashboard.csv'\n", + " parquet_path = OUT_DIR / f'{TABLE_NAME}_dashboard.parquet'\n", + " export_df.to_csv(csv_path, index=False)\n", + " con.register('export_df_view', export_df)\n", + " con.execute(f\"COPY (SELECT * FROM export_df_view) TO '{parquet_path.as_posix()}' (FORMAT PARQUET)\")\n", + " print(csv_path)\n", + " print(parquet_path)\n", + "else:\n", + " print('Export disabled. Set EXPORT = True to write files into ../_tmp/.')" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/notebooks/README.md b/notebooks/README.md index 8d0842d..0223d3e 100644 --- a/notebooks/README.md +++ b/notebooks/README.md @@ -1,32 +1,32 @@ -# 📓 /notebooks – Pipeline e analisi +# /notebooks - notebook standard per il dataset -Questa cartella contiene notebook (Colab / Jupyter) per: -- ingestione dati (raw) -- pulizia/normalizzazione (clean) -- aggregazioni e KPI (mart) -- analisi esplorative (quando servono) +Questa cartella contiene notebook leggeri e clonabili per tutto il lifecycle operativo del dataset. +Usano Python standard, `duckdb` e il contratto stabile `toolkit inspect paths --json` per scoprire gli output reali del progetto. ---- +I notebook non reimplementano il motore della pipeline. +Usano `toolkit inspect paths --json` come fonte primaria per localizzare RAW, CLEAN, MART e run record, e servono a ispezionare gli output dal punto di vista del dataset. +Il comando puo essere disponibile come `toolkit ...` oppure come fallback `py -m toolkit.cli.app ...`. +Per i dettagli stabili lato toolkit, vedi `docs/notebook-contract.md` e `docs/feature-stability.md` nel repo toolkit. -## ✅ Regole minime +Contratto minimo degli output: -- notebook numerati: `01_...`, `02_...`, `03_...` -- eseguibili dall’inizio alla fine (no “celle magiche”) -- commenti brevi: **cosa** fai e **perché** -- niente path locali: usare riferimenti chiari al Drive / cartelle di progetto +- `metadata.json` = payload ricco del layer +- `manifest.json` = summary stabile del layer con puntatori a metadata e validation +- `data/_runs/.../.json` = stato del run letto da `status` e `resume` ---- +## Notebook inclusi -## 🔁 Collegamento con `/data` +- `00_quickstart.ipynb` - setup, command preview, run opzionale e localizzazione output reali del toolkit +- `01_inspect_raw.ipynb` - ispezione del layer RAW tramite `manifest.json`, file primario e sample di output +- `02_inspect_clean.ipynb` - ispezione del parquet CLEAN con schema e sanity checks minimi +- `03_explore_mart.ipynb` - esplorazione del mart selezionato nella config +- `04_quality_checks.ipynb` - controlli ripetibili su chiavi, missingness e range del mart selezionato +- `05_dashboard_export.ipynb` - export opzionali derivati dal mart selezionato nella config -Ogni notebook dovrebbe aggiornare (o citare) i README di: -- `/data/raw` -- `/data/clean` -- `/data/mart` +## Regole -Così chi arriva dopo capisce: -- da dove arrivano i dati -- cosa è stato fatto -- dove trovare i file su Drive - ---- +- non salvare output pesanti nel repo +- se serve esportare file, usa `../_tmp/` +- mantieni i notebook generici: preferisci leggere `dataset.yml` e usa i parametri iniziali per scegliere anno/tabella +- non ricostruire a mano i path degli output del toolkit: usa sempre i path restituiti da `inspect paths --json` +- per dettagli tecnici della pipeline, vedi il repository Toolkit DataCivicLab diff --git a/queries/README.md b/queries/README.md deleted file mode 100644 index ae558a0..0000000 --- a/queries/README.md +++ /dev/null @@ -1,11 +0,0 @@ -# 🧮 Query - -Questa cartella contiene le query utilizzate per: -- pulizia -- aggregazione -- calcolo metriche - -Ogni query deve: -- avere un nome descrittivo -- essere commentata -- indicare input e output diff --git a/scripts/publish_to_drive.py b/scripts/publish_to_drive.py new file mode 100644 index 0000000..8da2614 --- /dev/null +++ b/scripts/publish_to_drive.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +import argparse +import shutil +import sys +from dataclasses import dataclass +from pathlib import Path + +import yaml + + +@dataclass(frozen=True) +class PublishItem: + source: Path + destination: Path + + +def load_config(config_path: Path) -> dict: + return yaml.safe_load(config_path.read_text(encoding="utf-8")) + + +def resolve_output_root(config_path: Path, cfg: dict) -> Path: + root_value = cfg.get("root", ".") + return (config_path.parent / root_value).resolve() + + +def year_values(cfg: dict, selected_year: int | None) -> list[int]: + years = cfg["dataset"]["years"] + if selected_year is None: + return years + if selected_year not in years: + raise ValueError(f"Year {selected_year} not found in dataset.yml years: {years}") + return [selected_year] + + +def latest_run_record(run_dir: Path) -> Path | None: + if not run_dir.exists(): + return None + candidates = sorted(run_dir.glob("*.json")) + return candidates[-1] if candidates else None + + +def build_publish_items( + *, + output_root: Path, + dataset: str, + years: list[int], + drive_root: Path, +) -> list[PublishItem]: + items: list[PublishItem] = [] + + for year in years: + year_text = str(year) + mart_dir = output_root / "data" / "mart" / dataset / year_text + clean_dir = output_root / "data" / "clean" / dataset / year_text + raw_dir = output_root / "data" / "raw" / dataset / year_text + runs_dir = output_root / "data" / "_runs" / dataset / year_text + + for path in sorted(raw_dir.glob("*")): + if path.is_file(): + items.append(PublishItem(path, drive_root / path.relative_to(output_root))) + + for path in sorted(mart_dir.glob("*.parquet")): + items.append(PublishItem(path, drive_root / path.relative_to(output_root))) + + clean_parquet = clean_dir / f"{dataset}_{year_text}_clean.parquet" + if clean_parquet.exists(): + items.append(PublishItem(clean_parquet, drive_root / clean_parquet.relative_to(output_root))) + + for layer_dir, validation_rel in ( + (raw_dir, "raw_validation.json"), + (clean_dir, "_validate/clean_validation.json"), + (mart_dir, "_validate/mart_validation.json"), + ): + for name in ("metadata.json", "manifest.json"): + path = layer_dir / name + if path.exists(): + items.append(PublishItem(path, drive_root / path.relative_to(output_root))) + + validation_path = layer_dir / validation_rel + if validation_path.exists(): + items.append(PublishItem(validation_path, drive_root / validation_path.relative_to(output_root))) + + latest_run = latest_run_record(runs_dir) + if latest_run is not None: + items.append(PublishItem(latest_run, drive_root / latest_run.relative_to(output_root))) + + return items + + +def copy_items(items: list[PublishItem], *, dry_run: bool) -> tuple[int, int]: + copied = 0 + missing = 0 + + for item in items: + if not item.source.exists(): + print(f"MISSING {item.source}") + missing += 1 + continue + + print(f"{'DRY-RUN' if dry_run else 'COPY'} {item.source} -> {item.destination}") + if not dry_run: + item.destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(item.source, item.destination) + copied += 1 + + return copied, missing + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Publish dataset artifacts to Drive preserving toolkit output paths under root." + ) + parser.add_argument("--config", default="dataset.yml", help="Path to dataset.yml") + parser.add_argument("--drive-root", required=True, help="Destination root in Drive") + parser.add_argument("--year", type=int, help="Publish a single year") + parser.add_argument("--dry-run", action="store_true", help="Show what would be copied without writing") + return parser + + +def main() -> int: + args = build_parser().parse_args() + config_path = Path(args.config).resolve() + cfg = load_config(config_path) + + dataset = cfg["dataset"]["name"] + years = year_values(cfg, args.year) + output_root = resolve_output_root(config_path, cfg) + drive_root = Path(args.drive_root).resolve() + + items = build_publish_items( + output_root=output_root, + dataset=dataset, + years=years, + drive_root=drive_root, + ) + + print( + { + "dataset": dataset, + "years": years, + "output_root": str(output_root), + "drive_root": str(drive_root), + "dry_run": args.dry_run, + "items": len(items), + } + ) + + copied, missing = copy_items(items, dry_run=args.dry_run) + print({"copied": copied, "missing": missing}) + return 0 if missing == 0 else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/smoke.sh b/scripts/smoke.sh new file mode 100644 index 0000000..f984a6c --- /dev/null +++ b/scripts/smoke.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env sh + +set -eu + +DATASET_FILE="${DATASET_FILE:-dataset.yml}" +TOOLKIT_BIN="${TOOLKIT_BIN:-toolkit}" +DCL_ROOT="${DCL_ROOT:-$(pwd)}" + +export DCL_ROOT + +detect_python() { + if command -v python >/dev/null 2>&1; then + echo python + return 0 + fi + if command -v python3 >/dev/null 2>&1; then + echo python3 + return 0 + fi + return 1 +} + +detect_toolkit_module() { + if [ -z "${PYTHON_BIN:-}" ]; then + return 1 + fi + if "${PYTHON_BIN}" -c "import importlib.util, sys; sys.exit(0 if importlib.util.find_spec('toolkit.cli.app') else 1)" >/dev/null 2>&1; then + echo toolkit.cli.app + return 0 + fi + return 1 +} + +detect_year() { + if [ -n "${YEAR:-}" ]; then + echo "${YEAR}" + return 0 + fi + if [ -n "${1:-}" ]; then + echo "${1}" + return 0 + fi + if [ -f "${DATASET_FILE}" ]; then + parsed_year="$(sed -n 's/^[[:space:]]*years:[[:space:]]*\[\([0-9][0-9][0-9][0-9]\).*/\1/p' "${DATASET_FILE}" | head -n 1)" + if [ -n "${parsed_year}" ]; then + echo "${parsed_year}" + return 0 + fi + fi + echo 2023 +} + +detect_dataset() { + if [ -n "${DATASET_NAME:-}" ]; then + echo "${DATASET_NAME}" + return 0 + fi + if [ -f "${DATASET_FILE}" ]; then + parsed_dataset="$(sed -n 's/^[[:space:]]*name:[[:space:]]*"\{0,1\}\([^"]*\)"\{0,1\}[[:space:]]*$/\1/p' "${DATASET_FILE}" | head -n 1)" + if [ -n "${parsed_dataset}" ]; then + echo "${parsed_dataset}" + return 0 + fi + fi + echo "dataset_unknown" +} + +run_toolkit() { + if [ -n "${TOOLKIT_COMMAND:-}" ]; then + "${TOOLKIT_COMMAND}" "$@" + return 0 + fi + if [ -n "${TOOLKIT_MODULE:-}" ]; then + "${PYTHON_BIN}" -m "${TOOLKIT_MODULE}" "$@" + return 0 + fi + echo "Toolkit non disponibile: imposta TOOLKIT_BIN oppure installa il modulo Python del toolkit." >&2 + exit 2 +} + +PYTHON_BIN="$(detect_python || true)" +TOOLKIT_COMMAND="" +TOOLKIT_MODULE="" + +if command -v "${TOOLKIT_BIN}" >/dev/null 2>&1; then + TOOLKIT_COMMAND="${TOOLKIT_BIN}" +else + TOOLKIT_MODULE="$(detect_toolkit_module || true)" +fi + +if [ -z "${TOOLKIT_COMMAND}" ] && [ -z "${TOOLKIT_MODULE}" ]; then + echo "Toolkit non trovato. Provati: comando '${TOOLKIT_BIN}', modulo 'toolkit.cli.app'." >&2 + exit 2 +fi + +YEAR="$(detect_year "${1:-}")" +DATASET_NAME="$(detect_dataset)" + +echo "DCL_ROOT=${DCL_ROOT}" +echo "DATASET_FILE=${DATASET_FILE}" +echo "TOOLKIT_BIN=${TOOLKIT_BIN}" +echo "TOOLKIT_COMMAND=${TOOLKIT_COMMAND:-}" +echo "TOOLKIT_MODULE=${TOOLKIT_MODULE:-}" +echo "DATASET_NAME=${DATASET_NAME}" +echo "YEAR=${YEAR}" + +run_toolkit run all --config "${DATASET_FILE}" +run_toolkit validate all --config "${DATASET_FILE}" +run_toolkit status --dataset "${DATASET_NAME}" --year "${YEAR}" --latest --config "${DATASET_FILE}" +run_toolkit inspect paths --config "${DATASET_FILE}" --year "${YEAR}" --json diff --git a/sql/README.md b/sql/README.md new file mode 100644 index 0000000..a97570e --- /dev/null +++ b/sql/README.md @@ -0,0 +1,15 @@ +# Query + +Questa cartella contiene query ad hoc, esplorative o materiale legacy. + +## Regole + +- usare nomi descrittivi +- commentare input e output +- evitare dipendenze implicite da path assoluti + +## Nota + +La pipeline toolkit-first usa come riferimento canonico la cartella `sql/`. + +Mantieni `queries/` solo per analisi non canoniche o work-in-progress. diff --git a/sql/clean.sql b/sql/clean.sql new file mode 100644 index 0000000..22585a2 --- /dev/null +++ b/sql/clean.sql @@ -0,0 +1,33 @@ +WITH base AS ( + SELECT + TRY_CAST(TRIM(CAST("ANNO" AS VARCHAR)) AS INTEGER) AS anno, + + TRY_CAST(TRIM(CAST("RISPARMIO_PUBBLICO" AS VARCHAR)) AS DOUBLE) AS risparmio_pubblico, + TRY_CAST(TRIM(CAST("SALDO_NETTO" AS VARCHAR)) AS DOUBLE) AS saldo_netto, + TRY_CAST(TRIM(CAST("INDEBITAMENTO_NETTO" AS VARCHAR)) AS DOUBLE) AS indebitamento_netto, + TRY_CAST(TRIM(CAST("RICORSO_MERCATO" AS VARCHAR)) AS DOUBLE) AS ricorso_mercato, + TRY_CAST(TRIM(CAST("AVANZO_PRIMARIO" AS VARCHAR)) AS DOUBLE) AS avanzo_primario, + + TRY_CAST(TRIM(CAST("SPESE_CORRENTI" AS VARCHAR)) AS DOUBLE) AS spese_correnti, + TRY_CAST(TRIM(CAST("SPESE_INTERESSI" AS VARCHAR)) AS DOUBLE) AS spese_interessi, + TRY_CAST(TRIM(CAST("SPESE_CONTO_CAPITALE" AS VARCHAR)) AS DOUBLE) AS spese_conto_capitale, + TRY_CAST(TRIM(CAST("SPESE_ACQ_ATT_FINE" AS VARCHAR)) AS DOUBLE) AS spese_acq_att_fin, + TRY_CAST(TRIM(CAST("SPESE_RIMBORSO_PRESTITI" AS VARCHAR)) AS DOUBLE) AS spese_rimborso_prestiti, + TRY_CAST(TRIM(CAST("SPESE_COMPLESSIVE" AS VARCHAR)) AS DOUBLE) AS spese_complessive, + TRY_CAST(TRIM(CAST("SPESE_FINALI" AS VARCHAR)) AS DOUBLE) AS spese_finali, + TRY_CAST(TRIM(CAST("SPESE_FIN_NETTO_ATT_FIN" AS VARCHAR)) AS DOUBLE) AS spese_fin_netto_att_fin, + + TRY_CAST(TRIM(CAST("ENTRATE_TRIBUTARIE" AS VARCHAR)) AS DOUBLE) AS entrate_tributarie, + TRY_CAST(TRIM(CAST("ENTRATE_EXTRA_TRIBUTARIE" AS VARCHAR)) AS DOUBLE) AS entrate_extra_tributarie, + TRY_CAST(TRIM(CAST("ENTR_ALIEN_PATR_RISCOS" AS VARCHAR)) AS DOUBLE) AS entr_alien_patr_riscos, + TRY_CAST(TRIM(CAST("RISCOSSIONE_CREDITI" AS VARCHAR)) AS DOUBLE) AS riscossione_crediti, + TRY_CAST(TRIM(CAST("ENTR_ACCENSIONE_PRESTITI" AS VARCHAR)) AS DOUBLE) AS entr_accensione_prestiti, + TRY_CAST(TRIM(CAST("ENTRATE_FINALI" AS VARCHAR)) AS DOUBLE) AS entrate_finali, + TRY_CAST(TRIM(CAST("ENTR_FIN_NETTO_RISCO_CRED" AS VARCHAR)) AS DOUBLE) AS entr_fin_netto_risco_cred, + TRY_CAST(TRIM(CAST("ENTRATE_CORRENTI" AS VARCHAR)) AS DOUBLE) AS entrate_correnti + FROM raw_input +) + +SELECT * +FROM base +WHERE anno IS NOT NULL; diff --git a/sql/mart/mart_ok.sql b/sql/mart/mart_ok.sql new file mode 100644 index 0000000..8893846 --- /dev/null +++ b/sql/mart/mart_ok.sql @@ -0,0 +1,15 @@ +SELECT + anno, + saldo_netto, + indebitamento_netto, + avanzo_primario, + entrate_finali, + spese_finali, + entrate_finali - spese_finali AS differenza_entrate_spese, + CASE + WHEN spese_finali IS NULL OR spese_finali = 0 THEN NULL + ELSE entrate_finali / spese_finali + END AS rapporto_entrate_spese +FROM clean_input +WHERE anno IS NOT NULL +ORDER BY anno diff --git a/tests/test_contract.py b/tests/test_contract.py new file mode 100644 index 0000000..d4e2a68 --- /dev/null +++ b/tests/test_contract.py @@ -0,0 +1,183 @@ +from __future__ import annotations + +import json +import re +from pathlib import Path + +import yaml + + +REPO_ROOT = Path(__file__).resolve().parents[1] +DATASET_FILE = REPO_ROOT / "dataset.yml" +DATA_DIR = REPO_ROOT / "data" +NOTEBOOKS_DIR = REPO_ROOT / "notebooks" +BLOCKED_DATA_EXTENSIONS = {".parquet", ".csv", ".jsonl", ".zip", ".xlsx", ".tsv"} +REQUIRED_FILES = [ + REPO_ROOT / "dataset.yml", + REPO_ROOT / "sql" / "clean.sql", + REPO_ROOT / "docs" / "sources.md", + REPO_ROOT / "docs" / "decisions.md", + REPO_ROOT / "docs" / "data_dictionary.md", + REPO_ROOT / "scripts" / "smoke.sh", + REPO_ROOT / ".github" / "workflows" / "ci.yml", +] + + +def _load_dataset() -> dict: + return yaml.safe_load(DATASET_FILE.read_text(encoding="utf-8")) + + +def _iter_path_values(node: object): + if isinstance(node, dict): + for key, value in node.items(): + if isinstance(value, str) and key in {"root", "source", "sql", "target", "dir", "path", "filename"}: + yield key, value + yield from _iter_path_values(value) + elif isinstance(node, list): + for item in node: + yield from _iter_path_values(item) + + +def test_required_files_exist() -> None: + missing = [str(path.relative_to(REPO_ROOT)) for path in REQUIRED_FILES if not path.exists()] + assert not missing, f"Missing required template files: {missing}" + + +def test_dataset_declares_minimum_contract() -> None: + dataset = _load_dataset() + + assert dataset.get("schema_version") == 1 + assert "root" in dataset + assert "dataset" in dataset + assert "name" in dataset["dataset"] + assert "years" in dataset["dataset"] + assert isinstance(dataset["dataset"]["years"], list) + assert dataset["dataset"]["years"] + assert "raw" in dataset + assert "sources" in dataset["raw"] + assert isinstance(dataset["raw"]["sources"], list) + assert dataset["raw"]["sources"] + assert dataset["raw"]["sources"][0]["primary"] is True + assert "clean" in dataset + assert dataset["clean"]["sql"] + assert dataset["clean"]["read_mode"] in {"strict", "fallback", "robust"} + assert "read" in dataset["clean"] + assert isinstance(dataset["clean"]["read"], dict) + assert dataset["clean"]["read"]["source"] in {"auto", "config_only"} + assert dataset["clean"]["read"]["mode"] in {"explicit", "latest", "largest", "all"} + assert "header" in dataset["clean"]["read"] + assert "columns" in dataset["clean"]["read"] + assert dataset["clean"]["required_columns"] + assert dataset["clean"]["validate"]["primary_key"] + assert dataset["clean"]["validate"]["not_null"] + assert dataset["clean"]["validate"]["min_rows"] == 1 + assert "mart" in dataset + assert "tables" in dataset["mart"] + assert isinstance(dataset["mart"]["tables"], list) + assert dataset["mart"]["tables"] + assert dataset["mart"]["required_tables"] + assert "table_rules" in dataset["mart"]["validate"] + assert dataset["validation"]["fail_on_error"] is True + assert dataset["output"]["artifacts"] in {"minimal", "standard", "debug"} + + +def test_dataset_avoids_legacy_clean_read_shape() -> None: + dataset = _load_dataset() + + assert "csv" not in dataset["clean"]["read"] + + +def test_dataset_paths_are_relative_and_posix() -> None: + dataset = _load_dataset() + + for key, value in _iter_path_values(dataset): + if value.startswith("http://") or value.startswith("https://"): + continue + assert value, f"Empty path value for key '{key}'" + assert "\\" not in value, f"Path for key '{key}' must use POSIX separators: {value}" + assert not value.startswith("/"), f"Absolute POSIX path found for key '{key}': {value}" + assert not value.startswith("~"), f"Home-relative path found for key '{key}': {value}" + assert not re.match(r"^[A-Za-z]:[\\/]", value), f"Absolute Windows path found for key '{key}': {value}" + + +def test_declared_sql_files_exist() -> None: + dataset = _load_dataset() + + clean_sql = REPO_ROOT / dataset["clean"]["sql"] + assert clean_sql.exists(), f"Missing clean SQL file declared in dataset.yml: {dataset['clean']['sql']}" + + mart_tables = dataset["mart"]["tables"] + for table in mart_tables: + assert "name" in table and table["name"], "Each mart table must declare a non-empty name" + assert "sql" in table and table["sql"], f"Mart table '{table['name']}' must declare an SQL path" + sql_path = REPO_ROOT / table["sql"] + assert sql_path.exists(), f"Missing mart SQL file declared in dataset.yml: {table['sql']}" + + +def test_mart_table_names_are_unique() -> None: + dataset = _load_dataset() + names = [table["name"] for table in dataset["mart"]["tables"]] + assert len(names) == len(set(names)), f"Duplicate mart table names found: {names}" + + +def test_required_tables_and_rules_match_declared_marts() -> None: + dataset = _load_dataset() + + names = {table["name"] for table in dataset["mart"]["tables"]} + required_tables = set(dataset["mart"]["required_tables"]) + table_rules = set(dataset["mart"]["validate"]["table_rules"].keys()) + + assert required_tables <= names, "mart.required_tables must reference declared mart.tables" + assert table_rules <= names, "mart.validate.table_rules must reference declared mart.tables" + + +def test_data_directory_does_not_contain_committed_outputs() -> None: + offenders: list[str] = [] + + if not DATA_DIR.exists(): + return + + for path in DATA_DIR.rglob("*"): + if not path.is_file(): + continue + if "_examples" in path.parts: + continue + if path.name == "README.md": + continue + if path.suffix.lower() not in BLOCKED_DATA_EXTENSIONS: + continue + offenders.append(str(path.relative_to(REPO_ROOT)).replace("\\", "/")) + + assert not offenders, ( + "Non committare output in data/: usa data/_examples per sample piccoli. " + f"Found: {offenders}" + ) + + +def test_notebooks_do_not_rebuild_runtime_output_paths() -> None: + forbidden_patterns = [ + "OUT_ROOT =", + "/ 'data' / 'raw' /", + "/ 'data' / 'clean' /", + "/ 'data' / 'mart' /", + "/ 'data' / '_runs' /", + "Path(INSPECT['paths']['mart']['dir']) /", + ] + + offenders: list[str] = [] + + for path in sorted(NOTEBOOKS_DIR.glob("*.ipynb")): + notebook = json.loads(path.read_text(encoding="utf-8")) + for cell in notebook.get("cells", []): + if cell.get("cell_type") != "code": + continue + source = "".join(cell.get("source", [])) + for pattern in forbidden_patterns: + if pattern in source: + offenders.append(f"{path.relative_to(REPO_ROOT)} -> {pattern}") + + assert not offenders, ( + "I notebook devono usare `toolkit inspect paths --json` come fonte di verita` " + "e non ricostruire a mano i path del runtime. " + f"Found: {offenders}" + )