From 1ff7d5cdba8dd6a72403e366fa087e55cacaa8af Mon Sep 17 00:00:00 2001 From: Zio Gabber <78922322+Gabrymi93@users.noreply.github.com> Date: Sun, 1 Mar 2026 15:44:01 +0000 Subject: [PATCH 01/11] repo aggiornata per toolkit --- .github/seed-issues/00_kickoff.md | 43 +++++ .github/seed-issues/01_civic_questions.md | 42 +++++ .github/seed-issues/01_setup.md | 17 -- .github/seed-issues/02_dataset_scoping.md | 17 -- .github/seed-issues/02_sources.md | 43 +++++ .github/seed-issues/03_raw.md | 42 +++++ .github/seed-issues/03_raw_ingestion.md | 17 -- .github/seed-issues/04_clean.md | 46 +++++ .github/seed-issues/04_raw_to_clean.md | 17 -- .github/seed-issues/05_clean_to_mart.md | 17 -- .github/seed-issues/05_mart.md | 44 +++++ .github/seed-issues/06_qa_checks.md | 17 -- .github/seed-issues/06_validation_qa.md | 44 +++++ .github/seed-issues/07_dashboard.md | 42 +++++ .github/seed-issues/07_viz_dashboard.md | 17 -- .github/seed-issues/08_docs_release.md | 17 -- .github/seed-issues/08_release.md | 57 ++++++ .../09_docs_decisions_dictionary.md | 44 +++++ .github/seed-issues/10_maintenance.md | 46 +++++ .github/workflows/ci.yml | 89 +++++++++ .gitignore | 41 +++-- README.md | 128 ++++++++++--- WORKFLOW.md | 124 ++----------- dashboard/README.md | 43 +---- dataset.yml | 70 +++++++ docs/METHOD.md | 23 --- docs/README.md | 16 ++ docs/_archive/INDEX.md | 4 + docs/contributing.md | 48 +++++ docs/data_dictionary.md | 30 +++ docs/decisions.md | 22 +++ docs/definition-of-done.md | 37 ---- docs/lab_links.md | 15 ++ docs/overview.md | 36 ++++ docs/roles.md | 21 --- docs/sources.md | 23 +++ notebooks/00_quickstart.ipynb | 172 ++++++++++++++++++ notebooks/01_explore_mart.ipynb | 140 ++++++++++++++ notebooks/02_quality_checks.ipynb | 134 ++++++++++++++ notebooks/03_dashboard_export.ipynb | 131 +++++++++++++ notebooks/README.md | 40 ++-- queries/README.md | 11 -- scripts/smoke.sh | 97 ++++++++++ sql/README.md | 15 ++ sql/clean.sql | 23 +++ sql/mart/project_summary.sql | 25 +++ tests/test_contract.py | 121 ++++++++++++ 47 files changed, 1863 insertions(+), 445 deletions(-) create mode 100644 .github/seed-issues/00_kickoff.md create mode 100644 .github/seed-issues/01_civic_questions.md delete mode 100644 .github/seed-issues/01_setup.md delete mode 100644 .github/seed-issues/02_dataset_scoping.md create mode 100644 .github/seed-issues/02_sources.md create mode 100644 .github/seed-issues/03_raw.md delete mode 100644 .github/seed-issues/03_raw_ingestion.md create mode 100644 .github/seed-issues/04_clean.md delete mode 100644 .github/seed-issues/04_raw_to_clean.md delete mode 100644 .github/seed-issues/05_clean_to_mart.md create mode 100644 .github/seed-issues/05_mart.md delete mode 100644 .github/seed-issues/06_qa_checks.md create mode 100644 .github/seed-issues/06_validation_qa.md create mode 100644 .github/seed-issues/07_dashboard.md delete mode 100644 .github/seed-issues/07_viz_dashboard.md delete mode 100644 .github/seed-issues/08_docs_release.md create mode 100644 .github/seed-issues/08_release.md create mode 100644 .github/seed-issues/09_docs_decisions_dictionary.md create mode 100644 .github/seed-issues/10_maintenance.md create mode 100644 .github/workflows/ci.yml create mode 100644 dataset.yml delete mode 100644 docs/METHOD.md create mode 100644 docs/README.md create mode 100644 docs/_archive/INDEX.md create mode 100644 docs/contributing.md create mode 100644 docs/data_dictionary.md create mode 100644 docs/decisions.md delete mode 100644 docs/definition-of-done.md create mode 100644 docs/lab_links.md create mode 100644 docs/overview.md delete mode 100644 docs/roles.md create mode 100644 docs/sources.md create mode 100644 notebooks/00_quickstart.ipynb create mode 100644 notebooks/01_explore_mart.ipynb create mode 100644 notebooks/02_quality_checks.ipynb create mode 100644 notebooks/03_dashboard_export.ipynb delete mode 100644 queries/README.md create mode 100644 scripts/smoke.sh create mode 100644 sql/README.md create mode 100644 sql/clean.sql create mode 100644 sql/mart/project_summary.sql create mode 100644 tests/test_contract.py diff --git a/.github/seed-issues/00_kickoff.md b/.github/seed-issues/00_kickoff.md new file mode 100644 index 0000000..282ac78 --- /dev/null +++ b/.github/seed-issues/00_kickoff.md @@ -0,0 +1,43 @@ +--- +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. + +## 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..9487f1f --- /dev/null +++ b/.github/seed-issues/01_civic_questions.md @@ -0,0 +1,42 @@ +--- +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. + +## 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..2373e62 --- /dev/null +++ b/.github/seed-issues/02_sources.md @@ -0,0 +1,43 @@ +--- +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.source.type` e `raw.source.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. + +## File da toccare + +- `dataset.yml` +- `docs/sources.md` +- `docs/decisions.md` + +## Acceptance criteria + +- la fonte e verificabile e documentata +- `raw.source` 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..fd27aa8 --- /dev/null +++ b/.github/seed-issues/03_raw.md @@ -0,0 +1,42 @@ +--- +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.source` e eventuale extractor in `dataset.yml` +- [ ] Eseguire `toolkit run raw --config dataset.yml --year ` +- [ ] Eseguire `toolkit validate --config dataset.yml --year ` oppure documentare il blocco +- [ ] 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. + +## 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 `_runs/` +- 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..d6a3ba4 --- /dev/null +++ b/.github/seed-issues/04_clean.md @@ -0,0 +1,46 @@ +--- +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 --year ` +- [ ] Eseguire `toolkit validate --config dataset.yml --year ` +- [ ] 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. + +## 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..9f8574e --- /dev/null +++ b/.github/seed-issues/05_mart.md @@ -0,0 +1,44 @@ +--- +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`, `mart.required_tables` e `mart.validate` in `dataset.yml` +- [ ] Eseguire `toolkit run mart --config dataset.yml --year ` +- [ ] Eseguire `toolkit validate --config dataset.yml --year ` +- [ ] 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. + +## File da toccare + +- `sql/mart/project_summary.sql` +- `dataset.yml` +- `docs/data_dictionary.md` +- `docs/decisions.md` + +## Acceptance criteria + +- ogni tabella dichiarata in `mart.tables[]` ha un file SQL dedicato +- `mart.required_tables` e `mart.validate.table_rules` 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..564aca9 --- /dev/null +++ b/.github/seed-issues/06_validation_qa.md @@ -0,0 +1,44 @@ +--- +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 `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 --config dataset.yml --year ` se disponibile +- [ ] 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. + +## 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..c7b6e4e --- /dev/null +++ b/.github/seed-issues/07_dashboard.md @@ -0,0 +1,42 @@ +--- +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. + +## 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..be37dd2 --- /dev/null +++ b/.github/seed-issues/08_release.md @@ -0,0 +1,57 @@ +--- +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 release policy, DoD e riferimenti Lab-wide +- [ ] 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. + +## 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..1a56619 --- /dev/null +++ b/.github/seed-issues/09_docs_decisions_dictionary.md @@ -0,0 +1,44 @@ +--- +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. + +## 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..c1df356 --- /dev/null +++ b/.github/seed-issues/10_maintenance.md @@ -0,0 +1,46 @@ +--- +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. + +## File da toccare + +- `dataset.yml` +- `sql/clean.sql` +- `sql/mart/project_summary.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..a78e7ee --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,89 @@ +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 sql/mart/project_summary.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}" + elif [ -d "./toolkit" ]; then + python -m pip install -e "./toolkit" + elif [ -d "../toolkit" ]; then + python -m pip install -e "../toolkit" + else + echo "Unable to install toolkit: set TOOLKIT_PIP_PACKAGE or provide ./toolkit or ../toolkit" >&2 + exit 1 + fi + + - name: Export DCL_ROOT + run: echo "DCL_ROOT=${GITHUB_WORKSPACE}" >> "${GITHUB_ENV}" + + - name: Smoke run raw clean mart validate + run: sh scripts/smoke.sh + + - name: Upload minimal artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: toolkit-artifacts + path: | + _runs/**/*.json + _runs/**/logs/** + if-no-files-found: warn diff --git a/.gitignore b/.gitignore index 860a1f2..2d1ba3f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,21 +1,24 @@ # ========================= -# 🔒 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 + +# eventuali export temporanei generati dal toolkit +_runs/**/*.csv +_runs/**/*.tsv +_runs/**/*.parquet # ========================= -# 🧠 NOTEBOOK / PYTHON +# NOTEBOOK / PYTHON # ========================= __pycache__/ *.py[cod] @@ -26,17 +29,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 +51,14 @@ logs/ *.code-workspace # ========================= -# 🖥️ OS +# OS # ========================= .DS_Store Thumbs.db desktop.ini # ========================= -# 🔑 CREDENTIALS (MAI!) +# CREDENTIALS (MAI) # ========================= credentials.json service_account.json @@ -61,13 +66,13 @@ service_account.json *.pem # ========================= -# 📦 ARCHIVI +# ARCHIVI # ========================= *.zip *.tar *.gz # ========================= -# 🔍 ALTRO +# ALTRO # ========================= nohup.out diff --git a/README.md b/README.md index 3dd4bed..f5e87c7 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,109 @@ -# 📊 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) +È 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] +* **Unità 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 +Esempio: -## Come si contribuisce +* Come varia [fenomeno] tra territori? +* Dove si osservano miglioramenti o peggioramenti? +* Il mio territorio è 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 +## 🔎 Cosa puoi capire con questi dati -Dettagli in `WORKFLOW.md`. +* come cambia il fenomeno nel tempo +* quali territori mostrano differenze significative +* se il tuo territorio è sopra o sotto la media +* se emergono anomalie o salti improvvisi +* quali aree meritano un approfondimento mirato -## 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 +Non è solo un dataset: è 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` + + +## ✅ Perché 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 + +Se non sei tecnico, parti da una **Discussion**: +spiega il contesto, il territorio o l’anno che ti interessa e cosa vuoi capire. + + +## 📚 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 + + +## 🧭 Roadmap + +La roadmap è gestita con **issue + milestone**. + + +## 🔁 Clonabilità + +Questo repository è un modello per progetti dataset DataCivicLab. + +Per adattarlo a un nuovo dataset: + +1. aggiorna la domanda civica e gli esempi di insight +2. sostituisci fonti, copertura e unità di analisi +3. definisci metriche e tabelle finali +4. documenta le decisioni specifiche del dataset + +La struttura resta invariata. + + +## 🧪 Esecuzione tecnica (per contributor) + +```bash +pip install dataciviclab-toolkit +toolkit run --dataset dataset.yml +``` + +Per dettagli tecnici (CLI, configurazione, validazioni, run metadata) +vedi il repository **Toolkit DataCivicLab**. + + +## 🌍 DataCivicLab + +Parte del progetto DataCivicLab. +Costruiamo infrastruttura open per analisi pubbliche riproducibili. diff --git a/WORKFLOW.md b/WORKFLOW.md index 4ca385e..ac62d54 100644 --- a/WORKFLOW.md +++ b/WORKFLOW.md @@ -1,116 +1,22 @@ -# 🔁 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: apri una Discussion o una Issue +- avanzamento: usa gli issues e la Board +- 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) +- standard Lab, DoD e release policy: [docs/lab_links.md](docs/lab_links.md) +- indice docs locali: [docs/README.md](docs/README.md) ---- +## Flusso minimo -## 1) Discussion → idee, domande, contesto - -Le **Discussions** servono per: -- proporre una domanda civica -- discutere dataset e fonti -- fare scelte metodologiche (KPI, perimetro, definizioni) -- allineare rapidamente il team - -👉 Nessun lavoro “pesante” parte senza almeno **una discussion iniziale**. - ---- - -## 2) Issue → task concreti - -Le **Issue** rappresentano lavoro reale. - -Una issue dovrebbe: -- avere un obiettivo chiaro -- essere limitata (no mega-task) -- avere criteri di chiusura (Definition of Done) - -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. 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 diff --git a/dashboard/README.md b/dashboard/README.md index 093711b..9512581 100644 --- a/dashboard/README.md +++ b/dashboard/README.md @@ -1,41 +1,16 @@ -# 📊 /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. diff --git a/dataset.yml b/dataset.yml new file mode 100644 index 0000000..ecd4266 --- /dev/null +++ b/dataset.yml @@ -0,0 +1,70 @@ +# Toolkit-first dataset contract aligned with the current smoke suite. +# Path policy: +# - use POSIX separators +# - keep every path root-relative +# - no absolute paths +# Root resolution: +# 1. dataset.yml root +# 2. DCL_ROOT +# 3. base_dir + +root: "./_runs" + +dataset: + name: "project_template" + years: [2024] + +validation: + fail_on_error: true + +raw: + source: + type: "http_file" + args: + url: "https://example.org/datasets/project_template_2024.csv" + filename: "project_template_{year}.csv" + +clean: + sql: "sql/clean.sql" + required_columns: + - year + - entity_id + - metric_value + read: + source: config_only + mode: latest + delim: "," + header: true + encoding: "utf-8" + trim_whitespace: true + columns: null + validate: + primary_key: + - entity_id + - year + not_null: + - year + - entity_id + min_rows: 1 + +mart: + required_tables: + - project_summary + tables: + - name: "project_summary" + sql: "sql/mart/project_summary.sql" + validate: + table_rules: + project_summary: + required_columns: + - year + - rows_in_year + - total_metric_value + not_null: + - year + primary_key: + - year + min_rows: 1 + +output: + artifacts: minimal 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..91e5184 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,16 @@ +# Docs + +Questa cartella contiene i documenti locali, specifici di questo dataset. +Per standard del Lab vedi [lab_links.md](lab_links.md). + +## 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..1f9094a --- /dev/null +++ b/docs/contributing.md @@ -0,0 +1,48 @@ +# Contributing + +Guida rapida per contribuire ai dati senza dover leggere tutta la documentazione tecnica del progetto. + +## 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 + +## Contract tests + +Esegui sempre prima: + +```sh +pytest tests/test_contract.py +``` + +## Smoke locale + +Per uno smoke test end-to-end: + +```sh +sh scripts/smoke.sh 2024 +``` + +Se il toolkit non è nel `PATH`, usa il fallback documentato nello script. + +## 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 `pytest tests/test_contract.py` e segnala eventuali problemi + +## Poi dove vado? + +- workflow umano: [../WORKFLOW.md](../WORKFLOW.md) +- docs locali: [README.md](README.md) +- standard Lab: [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..5c75687 --- /dev/null +++ b/docs/lab_links.md @@ -0,0 +1,15 @@ +# Lab Links + +Gli standard del Lab sono centralizzati e non vengono duplicati in questo template. +Usa questa pagina come ponte verso handbook e repository org-wide. + +## Handbook + +- [Handbook: Method](TODO: link repo dataciviclab/handbook) +- [Handbook: Definition of Done](TODO: link repo dataciviclab/handbook) +- [Handbook: Release policy](TODO: link repo dataciviclab/handbook) +- [Handbook: Roles](TODO: link repo dataciviclab/handbook) + +## Org-wide + +- [dataciviclab/.github: Issue and PR templates](TODO: link repo dataciviclab/.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..261f93f --- /dev/null +++ b/notebooks/00_quickstart.ipynb @@ -0,0 +1,172 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 00 Quickstart\n", + "\n", + "## Cosa fa / Cosa NON fa\n", + "\n", + "- esegue la pipeline solo se abiliti esplicitamente `RUN_TOOLKIT = True`\n", + "- cerca un mart leggibile e mostra schema, anteprima e controlli minimi\n", + "- non scrive file e non assume output già presenti in una cartella specifica" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from pathlib import Path\n", + "import subprocess\n", + "import duckdb\n", + "\n", + "ROOT = Path('.').resolve()\n", + "DATASET_YML = (ROOT / '..' / 'dataset.yml').resolve()\n", + "MART_DIRS = [\n", + " (ROOT / '..' / 'data' / 'mart').resolve(),\n", + " (ROOT / '..' / '_runs').resolve(),\n", + "]\n", + "TABLE_NAME = 'project_summary'\n", + "RUN_TOOLKIT = False\n", + "ROOT, DATASET_YML" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "cmd = ['toolkit', 'run', '--dataset', str(DATASET_YML)]\n", + "if RUN_TOOLKIT:\n", + " print('Running:', ' '.join(cmd))\n", + " subprocess.run(cmd, check=True)\n", + "else:\n", + " print('Toolkit run disabled.')\n", + " print('Set RUN_TOOLKIT = True to execute the pipeline from this notebook.')\n", + " print('Expected command:', ' '.join(cmd))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "def find_mart_candidates(table_name):\n", + " patterns = [\n", + " f'**/{table_name}.parquet',\n", + " f'**/*{table_name}*.parquet',\n", + " ]\n", + " found = []\n", + " for base in MART_DIRS:\n", + " if not base.exists():\n", + " continue\n", + " for pattern in patterns:\n", + " found.extend(sorted(base.glob(pattern)))\n", + " unique = []\n", + " seen = set()\n", + " for path in found:\n", + " key = str(path)\n", + " if key not in seen:\n", + " seen.add(key)\n", + " unique.append(path)\n", + " return unique\n", + "\n", + "mart_files = find_mart_candidates(TABLE_NAME)\n", + "mart_files[:5]" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "con = duckdb.connect()\n", + "mart_path = str(mart_files[0]) if mart_files else None\n", + "if mart_path:\n", + " schema_df = con.execute(f\"DESCRIBE SELECT * FROM read_parquet('{mart_path}')\").df()\n", + " head_df = con.execute(f\"SELECT * FROM read_parquet('{mart_path}') LIMIT 10\").df()\n", + " display(schema_df)\n", + " display(head_df)\n", + "else:\n", + " print('No mart parquet found. Run the pipeline first or update TABLE_NAME.')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "def read_schema(path):\n", + " schema = con.execute(f\"DESCRIBE SELECT * FROM read_parquet('{path}')\").df()\n", + " schema.columns = [str(col).lower() for col in schema.columns]\n", + " return schema\n", + "\n", + "def choose_columns(schema):\n", + " name_col = 'column_name' if 'column_name' in schema.columns else schema.columns[0]\n", + " type_col = 'column_type' if 'column_type' in schema.columns else schema.columns[1]\n", + " rows = [\n", + " {'name': str(row[name_col]), 'type': str(row[type_col]).upper()}\n", + " for _, row in schema.iterrows()\n", + " ]\n", + " year_col = next((r['name'] for r in rows if r['name'].lower() == 'year' or 'anno' in r['name'].lower()), None)\n", + " numeric_rows = [r for r in rows if any(token in r['type'] for token in ['INT', 'DECIMAL', 'DOUBLE', 'FLOAT', 'REAL', 'BIGINT'])]\n", + " metric_col = next((r['name'] for r in numeric_rows if any(token in r['name'].lower() for token in ['value', 'tot', 'importo', 'ammontare', 'pct', 'percent'])), None)\n", + " if metric_col is None and numeric_rows:\n", + " metric_col = numeric_rows[0]['name']\n", + " return year_col, metric_col\n", + "\n", + "if mart_path:\n", + " schema_df = read_schema(mart_path)\n", + " YEAR_COL, METRIC_COL = choose_columns(schema_df)\n", + " print({'YEAR_COL': YEAR_COL, 'METRIC_COL': METRIC_COL})" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "if mart_path:\n", + " row_count = con.execute(f\"SELECT COUNT(*) AS row_count FROM read_parquet('{mart_path}')\").df()\n", + " display(row_count)\n", + "\n", + " if YEAR_COL:\n", + " distinct_count = con.execute(\n", + " f\"SELECT COUNT(DISTINCT {YEAR_COL}) AS distinct_key_count FROM read_parquet('{mart_path}')\"\n", + " ).df()\n", + " display(distinct_count)\n", + " else:\n", + " print('No year-like key detected. Update the helper or inspect schema_df manually.')\n", + "\n", + " check_columns = [col for col in [YEAR_COL, METRIC_COL] if col]\n", + " if check_columns:\n", + " null_expr = ', '.join([f\"AVG(CASE WHEN {col} IS NULL THEN 1 ELSE 0 END) AS {col}_null_rate\" for col in check_columns])\n", + " null_rates = con.execute(f\"SELECT {null_expr} FROM read_parquet('{mart_path}')\").df()\n", + " display(null_rates)\n", + " else:\n", + " print('No suitable columns found for null-rate sanity checks.')" + ] + } + ], + "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_explore_mart.ipynb b/notebooks/01_explore_mart.ipynb new file mode 100644 index 0000000..7ad159b --- /dev/null +++ b/notebooks/01_explore_mart.ipynb @@ -0,0 +1,140 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 01 Explore MART\n", + "\n", + "## Cosa fa / Cosa NON fa\n", + "\n", + "- apre una tabella mart e ne mostra una lettura iniziale\n", + "- prova a scegliere automaticamente una colonna anno e una metrica numerica\n", + "- se non trova colonne adatte, salta le query dipendenti e mostra istruzioni" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from pathlib import Path\n", + "import duckdb\n", + "\n", + "ROOT = Path('.').resolve()\n", + "TABLE_NAME = 'project_summary'\n", + "MART_GLOBS = [\n", + " (ROOT / '..' / 'data' / 'mart').resolve(),\n", + " (ROOT / '..' / '_runs').resolve(),\n", + "]\n", + "\n", + "def first_match(table_name):\n", + " for base in MART_GLOBS:\n", + " if not base.exists():\n", + " continue\n", + " for path in sorted(base.glob(f'**/*{table_name}*.parquet')):\n", + " return path\n", + " return None\n", + "\n", + "mart_path = first_match(TABLE_NAME)\n", + "mart_path" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Descrizione breve:\n", + "\n", + "- questo notebook serve a capire cosa c'è nella tabella finale\n", + "- aggiorna `TABLE_NAME` se il dataset usa un altro mart principale" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "con = duckdb.connect()\n", + "\n", + "def read_schema(path):\n", + " schema = con.execute(f\"DESCRIBE SELECT * FROM read_parquet('{path}')\").df()\n", + " schema.columns = [str(col).lower() for col in schema.columns]\n", + " return schema\n", + "\n", + "def choose_columns(schema):\n", + " name_col = 'column_name' if 'column_name' in schema.columns else schema.columns[0]\n", + " type_col = 'column_type' if 'column_type' in schema.columns else schema.columns[1]\n", + " rows = [\n", + " {'name': str(row[name_col]), 'type': str(row[type_col]).upper()}\n", + " for _, row in schema.iterrows()\n", + " ]\n", + " year_col = next((r['name'] for r in rows if r['name'].lower() == 'year' or 'anno' in r['name'].lower()), None)\n", + " numeric_rows = [r for r in rows if any(token in r['type'] for token in ['INT', 'DECIMAL', 'DOUBLE', 'FLOAT', 'REAL', 'BIGINT'])]\n", + " metric_col = next((r['name'] for r in numeric_rows if any(token in r['name'].lower() for token in ['value', 'tot', 'importo', 'ammontare', 'pct', 'percent'])), None)\n", + " if metric_col is None and numeric_rows:\n", + " metric_col = numeric_rows[0]['name']\n", + " return year_col, metric_col\n", + "\n", + "if mart_path:\n", + " schema_df = read_schema(str(mart_path))\n", + " YEAR_COL, METRIC_COL = choose_columns(schema_df)\n", + " preview = con.execute(f\"SELECT * FROM read_parquet('{mart_path}') LIMIT 20\").df()\n", + " display(schema_df)\n", + " display(preview)\n", + " print({'YEAR_COL': YEAR_COL, 'METRIC_COL': METRIC_COL})\n", + "else:\n", + " print('No mart parquet found. Run the pipeline first or update TABLE_NAME.')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "if mart_path 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}') GROUP BY 1 ORDER BY 1\"\n", + " ).df()\n", + " display(by_year)\n", + "else:\n", + " print('No year-like column or metric column detected. Update TABLE_NAME or inspect schema_df manually.')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "if mart_path and METRIC_COL:\n", + " top_rows = con.execute(\n", + " f\"SELECT * FROM read_parquet('{mart_path}') ORDER BY {METRIC_COL} DESC NULLS LAST LIMIT 10\"\n", + " ).df()\n", + " bottom_rows = con.execute(\n", + " f\"SELECT * FROM read_parquet('{mart_path}') ORDER BY {METRIC_COL} ASC NULLS LAST LIMIT 10\"\n", + " ).df()\n", + " display(top_rows)\n", + " display(bottom_rows)\n", + "else:\n", + " print('No metric column detected. Update the helper or inspect schema_df manually.')" + ] + } + ], + "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_quality_checks.ipynb b/notebooks/02_quality_checks.ipynb new file mode 100644 index 0000000..ab512df --- /dev/null +++ b/notebooks/02_quality_checks.ipynb @@ -0,0 +1,134 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 02 Quality checks\n", + "\n", + "## Cosa fa / Cosa NON fa\n", + "\n", + "- esegue controlli riutilizzabili su duplicati, missingness e range\n", + "- prova a rilevare colonne numeriche in modo automatico\n", + "- se mancano chiavi o colonne adatte, stampa istruzioni invece di fallire" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from pathlib import Path\n", + "import duckdb\n", + "\n", + "ROOT = Path('.').resolve()\n", + "TABLE_NAME = 'project_summary'\n", + "KEY_COLUMNS = []\n", + "NUMERIC_COLUMNS = []\n", + "\n", + "def find_mart(table_name):\n", + " for base in [(ROOT / '..' / 'data' / 'mart').resolve(), (ROOT / '..' / '_runs').resolve()]:\n", + " if not base.exists():\n", + " continue\n", + " matches = sorted(base.glob(f'**/*{table_name}*.parquet'))\n", + " if matches:\n", + " return matches[0]\n", + " return None\n", + "\n", + "mart_path = find_mart(TABLE_NAME)\n", + "mart_path" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "con = duckdb.connect()\n", + "\n", + "def read_schema(path):\n", + " schema = con.execute(f\"DESCRIBE SELECT * FROM read_parquet('{path}')\").df()\n", + " schema.columns = [str(col).lower() for col in schema.columns]\n", + " return schema\n", + "\n", + "def detect_numeric_columns(schema):\n", + " name_col = 'column_name' if 'column_name' in schema.columns else schema.columns[0]\n", + " type_col = 'column_type' if 'column_type' in schema.columns else schema.columns[1]\n", + " numeric = []\n", + " for _, row in schema.iterrows():\n", + " dtype = str(row[type_col]).upper()\n", + " if any(token in dtype for token in ['INT', 'DECIMAL', 'DOUBLE', 'FLOAT', 'REAL', 'BIGINT']):\n", + " numeric.append(str(row[name_col]))\n", + " return numeric\n", + "\n", + "def duplicate_key_report(path, key_columns):\n", + " keys = ', '.join(key_columns)\n", + " sql = f\"\"\"\n", + " SELECT {keys}, COUNT(*) AS dup_count\n", + " FROM read_parquet('{path}')\n", + " GROUP BY {keys}\n", + " HAVING COUNT(*) > 1\n", + " ORDER BY dup_count DESC\n", + " LIMIT 20\n", + " \"\"\"\n", + " return con.execute(sql).df()\n", + "\n", + "def missingness_report(path):\n", + " columns = [row[0] for row in con.execute(f\"DESCRIBE SELECT * FROM read_parquet('{path}')\").fetchall()]\n", + " expr = ', '.join([f\"AVG(CASE WHEN {col} IS NULL THEN 1 ELSE 0 END) AS {col}_null_rate\" for col in columns])\n", + " return con.execute(f\"SELECT {expr} FROM read_parquet('{path}')\").df().T.reset_index()\n", + "\n", + "def range_report(path, numeric_columns):\n", + " expr = ', '.join([f\"MIN({col}) AS {col}_min, MAX({col}) AS {col}_max\" for col in numeric_columns])\n", + " return con.execute(f\"SELECT {expr} FROM read_parquet('{path}')\").df().T.reset_index()\n", + "\n", + "if mart_path:\n", + " schema_df = read_schema(str(mart_path))\n", + " detected_numeric = detect_numeric_columns(schema_df)\n", + " numeric_for_range = [col for col in NUMERIC_COLUMNS if col in detected_numeric]\n", + " if not numeric_for_range:\n", + " numeric_for_range = detected_numeric[:3]\n", + " display(schema_df)\n", + " print({'KEY_COLUMNS': KEY_COLUMNS, 'NUMERIC_COLUMNS': numeric_for_range})" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "if mart_path:\n", + " if KEY_COLUMNS:\n", + " dup_df = duplicate_key_report(str(mart_path), KEY_COLUMNS)\n", + " display(dup_df)\n", + " else:\n", + " print('Duplicate-key check skipped: imposta KEY_COLUMNS.')\n", + "\n", + " missing_df = missingness_report(str(mart_path))\n", + " display(missing_df)\n", + "\n", + " if numeric_for_range:\n", + " range_df = range_report(str(mart_path), numeric_for_range)\n", + " display(range_df)\n", + " else:\n", + " print('Range check skipped: no numeric columns 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/03_dashboard_export.ipynb b/notebooks/03_dashboard_export.ipynb new file mode 100644 index 0000000..0b052b9 --- /dev/null +++ b/notebooks/03_dashboard_export.ipynb @@ -0,0 +1,131 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# 03 Dashboard export\n", + "\n", + "## Cosa fa / Cosa NON fa\n", + "\n", + "- prepara un export di esempio partendo da un mart disponibile\n", + "- non scrive file finché `EXPORT = False`\n", + "- se non trova colonne adatte, esporta un campione generico come fallback" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from pathlib import Path\n", + "import duckdb\n", + "\n", + "ROOT = Path('.').resolve()\n", + "EXPORT = False\n", + "OUT_DIR = (ROOT / '..' / '_tmp').resolve()\n", + "TABLE_NAME = 'project_summary'\n", + "\n", + "def find_mart(table_name):\n", + " for base in [(ROOT / '..' / 'data' / 'mart').resolve(), (ROOT / '..' / '_runs').resolve()]:\n", + " if not base.exists():\n", + " continue\n", + " matches = sorted(base.glob(f'**/*{table_name}*.parquet'))\n", + " if matches:\n", + " return matches[0]\n", + " return None\n", + "\n", + "mart_path = find_mart(TABLE_NAME)\n", + "OUT_DIR" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "con = duckdb.connect()\n", + "\n", + "def read_schema(path):\n", + " schema = con.execute(f\"DESCRIBE SELECT * FROM read_parquet('{path}')\").df()\n", + " schema.columns = [str(col).lower() for col in schema.columns]\n", + " return schema\n", + "\n", + "def choose_columns(schema):\n", + " name_col = 'column_name' if 'column_name' in schema.columns else schema.columns[0]\n", + " type_col = 'column_type' if 'column_type' in schema.columns else schema.columns[1]\n", + " rows = [\n", + " {'name': str(row[name_col]), 'type': str(row[type_col]).upper()}\n", + " for _, row in schema.iterrows()\n", + " ]\n", + " year_col = next((r['name'] for r in rows if r['name'].lower() == 'year' or 'anno' in r['name'].lower()), None)\n", + " numeric_rows = [r for r in rows if any(token in r['type'] for token in ['INT', 'DECIMAL', 'DOUBLE', 'FLOAT', 'REAL', 'BIGINT'])]\n", + " metric_col = next((r['name'] for r in numeric_rows if any(token in r['name'].lower() for token in ['value', 'tot', 'importo', 'ammontare', 'pct', 'percent'])), None)\n", + " if metric_col is None and numeric_rows:\n", + " metric_col = numeric_rows[0]['name']\n", + " return year_col, metric_col\n", + "\n", + "if mart_path:\n", + " schema_df = read_schema(str(mart_path))\n", + " YEAR_COL, METRIC_COL = choose_columns(schema_df)\n", + " print({'YEAR_COL': YEAR_COL, 'METRIC_COL': METRIC_COL})\n", + "else:\n", + " print('No mart parquet found. Run the pipeline first or update TABLE_NAME.')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "if mart_path 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}') ORDER BY 1\"\n", + " ).df()\n", + "elif mart_path:\n", + " print('Using generic fallback export: no year-like or metric column detected.')\n", + " export_df = con.execute(f\"SELECT * FROM read_parquet('{mart_path}') 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}' (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..a5d1986 100644 --- a/notebooks/README.md +++ b/notebooks/README.md @@ -1,32 +1,18 @@ -# 📓 /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 avvio rapido, esplorazione dei mart e controlli di qualita. +Usano solo Python standard, `duckdb`, path relativi e il file `../dataset.yml` come riferimento di progetto. ---- +## Notebook inclusi -## ✅ Regole minime +- `00_quickstart.ipynb` - esegue la pipeline e controlla che esistano tabelle mart leggibili +- `01_explore_mart.ipynb` - esplorazione public-first dei dati finali +- `02_quality_checks.ipynb` - controlli ripetibili su duplicati, missingness e range +- `03_dashboard_export.ipynb` - export opzionali in `../_tmp/`, disattivati di default -- 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 +## Regole ---- - -## 🔁 Collegamento con `/data` - -Ogni notebook dovrebbe aggiornare (o citare) i README di: -- `/data/raw` -- `/data/clean` -- `/data/mart` - -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: aggiorna nomi tabella e chiavi senza introdurre logica dataset-specifica +- 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/smoke.sh b/scripts/smoke.sh new file mode 100644 index 0000000..4b056e6 --- /dev/null +++ b/scripts/smoke.sh @@ -0,0 +1,97 @@ +#!/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') else 1)" >/dev/null 2>&1; then + echo toolkit + return 0 + fi + if "${PYTHON_BIN}" -c "import importlib.util, sys; sys.exit(0 if importlib.util.find_spec('dataciviclab_toolkit') else 1)" >/dev/null 2>&1; then + echo dataciviclab_toolkit + 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 +} + +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 un modulo Python 'toolkit' o 'dataciviclab_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', modulo 'dataciviclab_toolkit'." >&2 + exit 2 +fi + +YEAR="$(detect_year "${1:-}")" + +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 "YEAR=${YEAR}" + +run_toolkit run raw --config "${DATASET_FILE}" --year "${YEAR}" +run_toolkit run clean --config "${DATASET_FILE}" --year "${YEAR}" +run_toolkit run mart --config "${DATASET_FILE}" --year "${YEAR}" +run_toolkit validate --config "${DATASET_FILE}" --year "${YEAR}" 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..51afa63 --- /dev/null +++ b/sql/clean.sql @@ -0,0 +1,23 @@ +-- clean.sql +-- Purpose: placeholder transformation for the CLEAN layer. +-- Contract: read from the source configured in dataset.yml and keep the query portable. + +with source_rows as ( + select + year, + entity_id, + metric_value + from raw_input +), +normalized as ( + select + cast(year as integer) as year, + cast(entity_id as varchar) as entity_id, + cast(metric_value as double) as metric_value + from source_rows +) +select + year, + entity_id, + metric_value +from normalized; diff --git a/sql/mart/project_summary.sql b/sql/mart/project_summary.sql new file mode 100644 index 0000000..eaa4488 --- /dev/null +++ b/sql/mart/project_summary.sql @@ -0,0 +1,25 @@ +-- project_summary.sql +-- Purpose: placeholder MART query aligned with the toolkit `mart.tables[]` contract. +-- Contract: consume `clean_input` and expose a dashboard-ready table. + +with clean_rows as ( + select + year, + entity_id, + metric_value + from clean_input +), +project_summary as ( + select + year, + count(*) as rows_in_year, + sum(metric_value) as total_metric_value + from clean_rows + group by year +) +select + year, + rows_in_year, + total_metric_value +from project_summary +order by year; diff --git a/tests/test_contract.py b/tests/test_contract.py new file mode 100644 index 0000000..06c79bf --- /dev/null +++ b/tests/test_contract.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +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" +BLOCKED_DATA_EXTENSIONS = {".parquet", ".csv", ".jsonl", ".zip", ".xlsx", ".tsv"} +REQUIRED_FILES = [ + REPO_ROOT / "dataset.yml", + REPO_ROOT / "sql" / "clean.sql", + REPO_ROOT / "sql" / "mart" / "project_summary.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 _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_uses_supported_contract_keys() -> None: + dataset = yaml.safe_load(DATASET_FILE.read_text(encoding="utf-8")) + clean_read = dataset["clean"]["read"] + + assert "dataset" in dataset + assert "name" in dataset["dataset"] + assert "years" in dataset["dataset"] + assert dataset["validation"]["fail_on_error"] is True + assert "source" in clean_read + assert "header" in clean_read + assert "columns" in clean_read + assert "csv" not in clean_read + assert dataset["clean"]["required_columns"] + assert dataset["clean"]["validate"]["primary_key"] + assert dataset["clean"]["validate"]["not_null"] + 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 dataset["mart"]["validate"]["table_rules"]["project_summary"]["required_columns"] + + +def test_dataset_matches_smoke_contract_shape() -> None: + dataset = yaml.safe_load(DATASET_FILE.read_text(encoding="utf-8")) + + assert dataset["output"]["artifacts"] == "minimal" + assert "csv" not in dataset["clean"]["read"] + + +def test_dataset_paths_are_relative_and_posix() -> None: + dataset = yaml.safe_load(DATASET_FILE.read_text(encoding="utf-8")) + + 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_yaml_sql_paths_match_template_files() -> None: + dataset = yaml.safe_load(DATASET_FILE.read_text(encoding="utf-8")) + + assert dataset["clean"]["sql"] == "sql/clean.sql" + + mart_tables = dataset["mart"]["tables"] + project_summary = next((table for table in mart_tables if table["name"] == "project_summary"), None) + assert project_summary is not None, "Missing mart table 'project_summary'" + assert project_summary["sql"] == "sql/mart/project_summary.sql" + + +def test_output_artifacts_is_configured() -> None: + dataset = yaml.safe_load(DATASET_FILE.read_text(encoding="utf-8")) + + assert dataset["output"]["artifacts"] == "minimal" + + +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}" + ) From 1639c14650d7de9fe6a1e5f09f4178f8165499f8 Mon Sep 17 00:00:00 2001 From: Zio Gabber <78922322+Gabrymi93@users.noreply.github.com> Date: Mon, 2 Mar 2026 09:19:05 +0000 Subject: [PATCH 02/11] cambi finali integrazione toolkit --- .github/seed-issues/00_kickoff.md | 5 + .github/seed-issues/01_civic_questions.md | 5 + .github/seed-issues/02_sources.md | 9 +- .github/seed-issues/03_raw.md | 13 +- .github/seed-issues/04_clean.md | 9 +- .github/seed-issues/05_mart.md | 11 +- .github/seed-issues/06_validation_qa.md | 9 +- .github/seed-issues/07_dashboard.md | 4 + .github/seed-issues/08_release.md | 5 + .../09_docs_decisions_dictionary.md | 4 + .github/seed-issues/10_maintenance.md | 7 +- .github/workflows/ci.yml | 9 +- .gitignore | 4 + README.md | 59 ++++++- WORKFLOW.md | 15 ++ dashboard/README.md | 1 + dataset.yml | 92 +++++----- docs/README.md | 3 + docs/contributing.md | 74 ++++++++- notebooks/00_quickstart.ipynb | 157 +++++------------- notebooks/01_explore_mart.ipynb | 140 ---------------- notebooks/01_inspect_raw.ipynb | 119 +++++++++++++ notebooks/02_inspect_clean.ipynb | 102 ++++++++++++ notebooks/02_quality_checks.ipynb | 134 --------------- notebooks/03_dashboard_export.ipynb | 131 --------------- notebooks/03_explore_mart.ipynb | 100 +++++++++++ notebooks/04_quality_checks.ipynb | 106 ++++++++++++ notebooks/05_dashboard_export.ipynb | 122 ++++++++++++++ notebooks/README.md | 17 +- scripts/publish_to_drive.py | 155 +++++++++++++++++ scripts/smoke.sh | 36 ++-- sql/clean.sql | 52 +++--- sql/mart/mart_ok.sql | 15 ++ sql/mart/project_summary.sql | 25 --- tests/test_contract.py | 77 ++++++--- 35 files changed, 1149 insertions(+), 677 deletions(-) delete mode 100644 notebooks/01_explore_mart.ipynb create mode 100644 notebooks/01_inspect_raw.ipynb create mode 100644 notebooks/02_inspect_clean.ipynb delete mode 100644 notebooks/02_quality_checks.ipynb delete mode 100644 notebooks/03_dashboard_export.ipynb create mode 100644 notebooks/03_explore_mart.ipynb create mode 100644 notebooks/04_quality_checks.ipynb create mode 100644 notebooks/05_dashboard_export.ipynb create mode 100644 scripts/publish_to_drive.py create mode 100644 sql/mart/mart_ok.sql delete mode 100644 sql/mart/project_summary.sql diff --git a/.github/seed-issues/00_kickoff.md b/.github/seed-issues/00_kickoff.md index 282ac78..5671614 100644 --- a/.github/seed-issues/00_kickoff.md +++ b/.github/seed-issues/00_kickoff.md @@ -29,6 +29,11 @@ Avviare il progetto dataset con perimetro chiaro, domanda civica misurabile e co 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` diff --git a/.github/seed-issues/01_civic_questions.md b/.github/seed-issues/01_civic_questions.md index 9487f1f..3a0aeaf 100644 --- a/.github/seed-issues/01_civic_questions.md +++ b/.github/seed-issues/01_civic_questions.md @@ -27,6 +27,11 @@ Definire le domande civiche che guideranno fonti, metriche, unità di analisi e 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` diff --git a/.github/seed-issues/02_sources.md b/.github/seed-issues/02_sources.md index 2373e62..ce12dc8 100644 --- a/.github/seed-issues/02_sources.md +++ b/.github/seed-issues/02_sources.md @@ -19,7 +19,7 @@ Qualificare la fonte e codificare in modo riproducibile come il toolkit deve leg ## Checklist - [ ] Identificare fonte primaria, URL canonico e frequenza di aggiornamento -- [ ] Aggiornare `raw.source.type` e `raw.source.args` in `dataset.yml` +- [ ] 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 @@ -29,6 +29,11 @@ Qualificare la fonte e codificare in modo riproducibile come il toolkit deve leg 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` + ## File da toccare - `dataset.yml` @@ -38,6 +43,6 @@ Fonte verificata e configurata in `dataset.yml`, con documentazione sufficiente ## Acceptance criteria - la fonte e verificabile e documentata -- `raw.source` e compilato con campi sufficienti all'esecuzione +- `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 index fd27aa8..52adf76 100644 --- a/.github/seed-issues/03_raw.md +++ b/.github/seed-issues/03_raw.md @@ -17,9 +17,9 @@ Ottenere un layer RAW eseguibile e ripetibile, senza committare output in repo. ## Checklist -- [ ] Verificare `raw.source` e eventuale extractor in `dataset.yml` -- [ ] Eseguire `toolkit run raw --config dataset.yml --year ` -- [ ] Eseguire `toolkit validate --config dataset.yml --year ` oppure documentare il blocco +- [ ] Verificare `raw.sources[]`, `primary` ed eventuale extractor in `dataset.yml` +- [ ] Eseguire `toolkit run raw --config dataset.yml` +- [ ] 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 @@ -28,6 +28,11 @@ Ottenere un layer RAW eseguibile e ripetibile, senza committare output in repo. 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///` + ## File da toccare - `dataset.yml` @@ -38,5 +43,5 @@ RAW eseguibile con report minimi di validazione e metadata disponibili negli art - 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 `_runs/` +- 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/04_clean.md b/.github/seed-issues/04_clean.md index d6a3ba4..6fb4487 100644 --- a/.github/seed-issues/04_clean.md +++ b/.github/seed-issues/04_clean.md @@ -21,8 +21,8 @@ Portare il dataset da RAW a CLEAN con SQL esplicita, schema documentato e valida - [ ] 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 --year ` -- [ ] Eseguire `toolkit validate --config dataset.yml --year ` +- [ ] Eseguire `toolkit run clean --config dataset.yml` +- [ ] Eseguire `toolkit validate clean --config dataset.yml` - [ ] Aggiornare `docs/data_dictionary.md` per il layer CLEAN - [ ] Loggare assunzioni e mapping in `docs/decisions.md` @@ -30,6 +30,11 @@ Portare il dataset da RAW a CLEAN con SQL esplicita, schema documentato e valida 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///` + ## File da toccare - `sql/clean.sql` diff --git a/.github/seed-issues/05_mart.md b/.github/seed-issues/05_mart.md index 9f8574e..9bac54a 100644 --- a/.github/seed-issues/05_mart.md +++ b/.github/seed-issues/05_mart.md @@ -19,7 +19,7 @@ Produrre uno o piu mart orientati a KPI e output finali, con tabella/e e validat ## Checklist - [ ] Creare o aggiornare `sql/mart/
.sql` per ogni tabella dichiarata -- [ ] Allineare `mart.tables`, `mart.required_tables` e `mart.validate` in `dataset.yml` +- [ ] 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 ` - [ ] Verificare required columns, chiavi, `not_null`, `min_rows` e KPI sanity @@ -29,9 +29,14 @@ Produrre uno o piu mart orientati a KPI e output finali, con tabella/e e validat 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` + ## File da toccare -- `sql/mart/project_summary.sql` +- `sql/mart/
.sql` - `dataset.yml` - `docs/data_dictionary.md` - `docs/decisions.md` @@ -39,6 +44,6 @@ Mart pronti per dashboard o report, con SQL separata per tabella e regole di val ## Acceptance criteria - ogni tabella dichiarata in `mart.tables[]` ha un file SQL dedicato -- `mart.required_tables` e `mart.validate.table_rules` sono coerenti con le tabelle pubblicate +- 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_validation_qa.md b/.github/seed-issues/06_validation_qa.md index 564aca9..50232e1 100644 --- a/.github/seed-issues/06_validation_qa.md +++ b/.github/seed-issues/06_validation_qa.md @@ -18,10 +18,10 @@ Chiudere il gate tecnico di qualita con contract tests verdi, validazioni datase ## Checklist -- [ ] Eseguire `pytest tests/test_contract.py` +- [ ] 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 --config dataset.yml --year ` se disponibile +- [ ] Rieseguire `toolkit validate all --config dataset.yml` - [ ] Controllare outlier, rowcount sanity, duplicates e coerenza dei KPI - [ ] Aprire issue residue per anomalie non bloccanti @@ -29,6 +29,11 @@ Chiudere il gate tecnico di qualita con contract tests verdi, validazioni datase 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` diff --git a/.github/seed-issues/07_dashboard.md b/.github/seed-issues/07_dashboard.md index c7b6e4e..f0cd351 100644 --- a/.github/seed-issues/07_dashboard.md +++ b/.github/seed-issues/07_dashboard.md @@ -28,6 +28,10 @@ Preparare un output pubblico che consumi i mart prodotti dal progetto. 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` diff --git a/.github/seed-issues/08_release.md b/.github/seed-issues/08_release.md index be37dd2..2b8b517 100644 --- a/.github/seed-issues/08_release.md +++ b/.github/seed-issues/08_release.md @@ -42,6 +42,11 @@ Portare il progetto a una release riproducibile, spiegabile e pronta per handoff 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` diff --git a/.github/seed-issues/09_docs_decisions_dictionary.md b/.github/seed-issues/09_docs_decisions_dictionary.md index 1a56619..ed4e8b5 100644 --- a/.github/seed-issues/09_docs_decisions_dictionary.md +++ b/.github/seed-issues/09_docs_decisions_dictionary.md @@ -28,6 +28,10 @@ Tenere allineata la documentazione strutturata del dataset durante tutto il life 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` diff --git a/.github/seed-issues/10_maintenance.md b/.github/seed-issues/10_maintenance.md index c1df356..5fffb28 100644 --- a/.github/seed-issues/10_maintenance.md +++ b/.github/seed-issues/10_maintenance.md @@ -29,11 +29,16 @@ Definire il lavoro necessario per mantenere il dataset nel tempo quando cambiano 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/project_summary.sql` +- `sql/mart/
.sql` - `docs/sources.md` - `docs/data_dictionary.md` - `docs/decisions.md` diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a78e7ee..36f5faf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,7 +39,6 @@ jobs: run: | test -f dataset.yml test -f sql/clean.sql - test -f sql/mart/project_summary.sql test -f scripts/smoke.sh - name: Run contract tests @@ -75,7 +74,7 @@ jobs: - name: Export DCL_ROOT run: echo "DCL_ROOT=${GITHUB_WORKSPACE}" >> "${GITHUB_ENV}" - - name: Smoke run raw clean mart validate + - name: Smoke run all validate all status run: sh scripts/smoke.sh - name: Upload minimal artifacts @@ -84,6 +83,8 @@ jobs: with: name: toolkit-artifacts path: | - _runs/**/*.json - _runs/**/logs/** + _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 2d1ba3f..32a1477 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,10 @@ data/** _runs/ _runs/** !_runs/.gitkeep +_smoke_out/ +_smoke_out/** +_test_out/ +_test_out/** # eventuali export temporanei generati dal toolkit _runs/**/*.csv diff --git a/README.md b/README.md index f5e87c7..f3edb3b 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,24 @@ spiega il contesto, il territorio o l’anno che ti interessa e cosa vuoi capire * `docs/contributing.md` — come contribuire +## Confine con il toolkit + +Questo repository contiene il contratto del dataset: + +* configurazione in `dataset.yml` +* trasformazioni SQL in `sql/` +* test di contratto e documentazione locale +* notebook leggeri per ispezione degli output + +Il motore della pipeline vive nel repository **Toolkit DataCivicLab**. +Questa repo non replica la logica di esecuzione del toolkit: definisce input, regole e output attesi per questo dataset. + +In pratica: + +* bug o feature della CLI, runner, validazioni runtime e metadata di run → repo `toolkit` +* bug o modifiche a fonti, mapping, SQL, mart, docs e notebook di dataset → questa repo + + ## 🧭 Roadmap La roadmap è gestita con **issue + milestone**. @@ -82,6 +100,9 @@ La roadmap è gestita con **issue + milestone**. Questo repository è un modello per progetti dataset DataCivicLab. +`dataset.yml` in root è un esempio eseguibile completo, utile per smoke e onboarding. +Chi clona questo template deve adattarlo al dataset reale, non copiarlo come contratto finale immutabile. + Per adattarlo a un nuovo dataset: 1. aggiorna la domanda civica e gli esempi di insight @@ -96,13 +117,47 @@ La struttura resta invariata. ```bash pip install dataciviclab-toolkit -toolkit run --dataset dataset.yml +toolkit run all --config dataset.yml +toolkit validate all --config dataset.yml ``` -Per dettagli tecnici (CLI, configurazione, validazioni, run metadata) +Se lavori con un checkout locale del toolkit, installalo in editable e poi esegui i comandi da questa repo. + +Per dettagli tecnici su CLI, configurazione supportata, validazioni runtime e run metadata, vedi il repository **Toolkit DataCivicLab**. +## 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 + +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 +``` + +Per default lo script pubblica: + +* payload RAW +* metadata, manifest e validation di `raw`, `clean`, `mart` +* parquet CLEAN +* parquet MART +* ultimo run record + +La destinazione su Drive mantiene lo stesso path relativo degli output del toolkit sotto `root`. + +Esempio: + +* locale: `root/data/mart///mart_ok.parquet` +* Drive: `/data/mart///mart_ok.parquet` + + ## 🌍 DataCivicLab Parte del progetto DataCivicLab. diff --git a/WORKFLOW.md b/WORKFLOW.md index ac62d54..2695a4b 100644 --- a/WORKFLOW.md +++ b/WORKFLOW.md @@ -14,9 +14,24 @@ Come contribuire in modo semplice a un progetto dataset DataCivicLab. - standard Lab, DoD e release policy: [docs/lab_links.md](docs/lab_links.md) - indice docs locali: [docs/README.md](docs/README.md) +## Confine tecnico + +- 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 + ## Flusso minimo 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 + +## Flusso tecnico minimo + +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. usa i notebook per ispezionare RAW, CLEAN, MART e QA +5. 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 9512581..b4277ed 100644 --- a/dashboard/README.md +++ b/dashboard/README.md @@ -14,3 +14,4 @@ Qui vanno link, note di lettura, screenshot e limiti dell'output, non i dati. ## 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. diff --git a/dataset.yml b/dataset.yml index ecd4266..07b4636 100644 --- a/dataset.yml +++ b/dataset.yml @@ -1,70 +1,70 @@ -# Toolkit-first dataset contract aligned with the current smoke suite. -# Path policy: -# - use POSIX separators -# - keep every path root-relative -# - no absolute paths -# Root resolution: -# 1. dataset.yml root -# 2. DCL_ROOT -# 3. base_dir - -root: "./_runs" +schema_version: 1 +root: "./_smoke_out" dataset: - name: "project_template" - years: [2024] - -validation: - fail_on_error: true + name: "bdap_http_csv" + years: [2022] raw: - source: - type: "http_file" - args: - url: "https://example.org/datasets/project_template_2024.csv" - filename: "project_template_{year}.csv" + 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" - required_columns: - - year - - entity_id - - metric_value + read_mode: "fallback" read: - source: config_only - mode: latest - delim: "," - header: true + source: "config_only" + mode: "explicit" + include: + - "bdap_rendiconto_saldi_*.csv" + delim: ";" + decimal: "." encoding: "utf-8" - trim_whitespace: true + header: true columns: null + required_columns: + - "anno" + - "saldo_netto" + - "indebitamento_netto" + - "avanzo_primario" + - "entrate_finali" + - "spese_finali" validate: primary_key: - - entity_id - - year + - "anno" not_null: - - year - - entity_id + - "anno" min_rows: 1 mart: - required_tables: - - project_summary tables: - - name: "project_summary" - sql: "sql/mart/project_summary.sql" + - name: "mart_ok" + sql: "sql/mart/mart_ok.sql" + required_tables: + - "mart_ok" validate: table_rules: - project_summary: + mart_ok: required_columns: - - year - - rows_in_year - - total_metric_value - not_null: - - year + - "anno" + - "saldo_netto" + - "entrate_finali" + - "spese_finali" primary_key: - - year + - "anno" + not_null: + - "anno" min_rows: 1 +validation: + fail_on_error: true + output: - artifacts: minimal + artifacts: "minimal" + legacy_aliases: true diff --git a/docs/README.md b/docs/README.md index 91e5184..abcaf67 100644 --- a/docs/README.md +++ b/docs/README.md @@ -3,6 +3,9 @@ Questa cartella contiene i documenti locali, specifici di questo dataset. Per standard del Lab 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. + ## Essenziali - [overview.md](overview.md) diff --git a/docs/contributing.md b/docs/contributing.md index 1f9094a..2d54479 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -10,23 +10,90 @@ Prerequisiti: - 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 -pytest tests/test_contract.py +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 2024 +sh scripts/smoke.sh ``` Se il toolkit non è 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 è 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 +``` + +## 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. + +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 run raw --config dataset.yml +toolkit run clean --config dataset.yml +toolkit run mart --config dataset.yml +toolkit validate all --config dataset.yml +toolkit status --dataset --year --latest --config dataset.yml +``` + +## 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` | `01_inspect_raw.ipynb` | +| CLEAN | `sql/clean.sql`, `dataset.yml`, `docs/data_dictionary.md` | `toolkit run clean --config dataset.yml` | `02_inspect_clean.ipynb` | +| MART | `sql/mart/*.sql`, `dataset.yml` | `toolkit run mart --config dataset.yml` | `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` | `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` | ## Regole veloci @@ -39,7 +106,8 @@ Se il toolkit non è nel `PATH`, usa il fallback documentato nello script. - controlla che `docs/sources.md` e `docs/overview.md` siano coerenti - migliora una descrizione in `docs/data_dictionary.md` -- esegui `pytest tests/test_contract.py` e segnala eventuali problemi +- 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? diff --git a/notebooks/00_quickstart.ipynb b/notebooks/00_quickstart.ipynb index 261f93f..0f1f20f 100644 --- a/notebooks/00_quickstart.ipynb +++ b/notebooks/00_quickstart.ipynb @@ -6,11 +6,9 @@ "source": [ "# 00 Quickstart\n", "\n", - "## Cosa fa / Cosa NON fa\n", - "\n", - "- esegue la pipeline solo se abiliti esplicitamente `RUN_TOOLKIT = True`\n", - "- cerca un mart leggibile e mostra schema, anteprima e controlli minimi\n", - "- non scrive file e non assume output già presenti in una cartella specifica" + "- legge `../dataset.yml`\n", + "- mostra i path reali attesi dal toolkit\n", + "- esegue il run solo se abiliti esplicitamente `RUN_TOOLKIT = True`" ] }, { @@ -20,81 +18,35 @@ "outputs": [], "source": [ "from pathlib import Path\n", + "import shutil\n", "import subprocess\n", - "import duckdb\n", + "import yaml\n", "\n", "ROOT = Path('.').resolve()\n", - "DATASET_YML = (ROOT / '..' / 'dataset.yml').resolve()\n", - "MART_DIRS = [\n", - " (ROOT / '..' / 'data' / 'mart').resolve(),\n", - " (ROOT / '..' / '_runs').resolve(),\n", - "]\n", - "TABLE_NAME = 'project_summary'\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", + "OUT_ROOT = (BASE_DIR / CFG.get('root', '.')).resolve()\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", - "ROOT, DATASET_YML" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "cmd = ['toolkit', 'run', '--dataset', str(DATASET_YML)]\n", - "if RUN_TOOLKIT:\n", - " print('Running:', ' '.join(cmd))\n", - " subprocess.run(cmd, check=True)\n", - "else:\n", - " print('Toolkit run disabled.')\n", - " print('Set RUN_TOOLKIT = True to execute the pipeline from this notebook.')\n", - " print('Expected command:', ' '.join(cmd))" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "def find_mart_candidates(table_name):\n", - " patterns = [\n", - " f'**/{table_name}.parquet',\n", - " f'**/*{table_name}*.parquet',\n", - " ]\n", - " found = []\n", - " for base in MART_DIRS:\n", - " if not base.exists():\n", - " continue\n", - " for pattern in patterns:\n", - " found.extend(sorted(base.glob(pattern)))\n", - " unique = []\n", - " seen = set()\n", - " for path in found:\n", - " key = str(path)\n", - " if key not in seen:\n", - " seen.add(key)\n", - " unique.append(path)\n", - " return unique\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", "\n", - "mart_files = find_mart_candidates(TABLE_NAME)\n", - "mart_files[:5]" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "con = duckdb.connect()\n", - "mart_path = str(mart_files[0]) if mart_files else None\n", - "if mart_path:\n", - " schema_df = con.execute(f\"DESCRIBE SELECT * FROM read_parquet('{mart_path}')\").df()\n", - " head_df = con.execute(f\"SELECT * FROM read_parquet('{mart_path}') LIMIT 10\").df()\n", - " display(schema_df)\n", - " display(head_df)\n", - "else:\n", - " print('No mart parquet found. Run the pipeline first or update TABLE_NAME.')" + "{\n", + " 'DATASET_YML': str(DATASET_YML),\n", + " 'OUT_ROOT': str(OUT_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", + "}" ] }, { @@ -103,29 +55,13 @@ "metadata": {}, "outputs": [], "source": [ - "def read_schema(path):\n", - " schema = con.execute(f\"DESCRIBE SELECT * FROM read_parquet('{path}')\").df()\n", - " schema.columns = [str(col).lower() for col in schema.columns]\n", - " return schema\n", - "\n", - "def choose_columns(schema):\n", - " name_col = 'column_name' if 'column_name' in schema.columns else schema.columns[0]\n", - " type_col = 'column_type' if 'column_type' in schema.columns else schema.columns[1]\n", - " rows = [\n", - " {'name': str(row[name_col]), 'type': str(row[type_col]).upper()}\n", - " for _, row in schema.iterrows()\n", - " ]\n", - " year_col = next((r['name'] for r in rows if r['name'].lower() == 'year' or 'anno' in r['name'].lower()), None)\n", - " numeric_rows = [r for r in rows if any(token in r['type'] for token in ['INT', 'DECIMAL', 'DOUBLE', 'FLOAT', 'REAL', 'BIGINT'])]\n", - " metric_col = next((r['name'] for r in numeric_rows if any(token in r['name'].lower() for token in ['value', 'tot', 'importo', 'ammontare', 'pct', 'percent'])), None)\n", - " if metric_col is None and numeric_rows:\n", - " metric_col = numeric_rows[0]['name']\n", - " return year_col, metric_col\n", - "\n", - "if mart_path:\n", - " schema_df = read_schema(mart_path)\n", - " YEAR_COL, METRIC_COL = choose_columns(schema_df)\n", - " print({'YEAR_COL': YEAR_COL, 'METRIC_COL': METRIC_COL})" + "expected_paths = {\n", + " 'raw_dir': str(OUT_ROOT / 'data' / 'raw' / DATASET / str(YEAR)),\n", + " 'clean_dir': str(OUT_ROOT / 'data' / 'clean' / DATASET / str(YEAR)),\n", + " 'mart_dir': str(OUT_ROOT / 'data' / 'mart' / DATASET / str(YEAR)),\n", + " 'run_dir': str(OUT_ROOT / 'data' / '_runs' / DATASET / str(YEAR)),\n", + "}\n", + "expected_paths" ] }, { @@ -134,25 +70,14 @@ "metadata": {}, "outputs": [], "source": [ - "if mart_path:\n", - " row_count = con.execute(f\"SELECT COUNT(*) AS row_count FROM read_parquet('{mart_path}')\").df()\n", - " display(row_count)\n", - "\n", - " if YEAR_COL:\n", - " distinct_count = con.execute(\n", - " f\"SELECT COUNT(DISTINCT {YEAR_COL}) AS distinct_key_count FROM read_parquet('{mart_path}')\"\n", - " ).df()\n", - " display(distinct_count)\n", - " else:\n", - " print('No year-like key detected. Update the helper or inspect schema_df manually.')\n", + "print('Run command:', ' '.join(RUN_CMD))\n", + "print('Validate command:', ' '.join(VALIDATE_CMD))\n", "\n", - " check_columns = [col for col in [YEAR_COL, METRIC_COL] if col]\n", - " if check_columns:\n", - " null_expr = ', '.join([f\"AVG(CASE WHEN {col} IS NULL THEN 1 ELSE 0 END) AS {col}_null_rate\" for col in check_columns])\n", - " null_rates = con.execute(f\"SELECT {null_expr} FROM read_parquet('{mart_path}')\").df()\n", - " display(null_rates)\n", - " else:\n", - " print('No suitable columns found for null-rate sanity checks.')" + "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.')" ] } ], diff --git a/notebooks/01_explore_mart.ipynb b/notebooks/01_explore_mart.ipynb deleted file mode 100644 index 7ad159b..0000000 --- a/notebooks/01_explore_mart.ipynb +++ /dev/null @@ -1,140 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# 01 Explore MART\n", - "\n", - "## Cosa fa / Cosa NON fa\n", - "\n", - "- apre una tabella mart e ne mostra una lettura iniziale\n", - "- prova a scegliere automaticamente una colonna anno e una metrica numerica\n", - "- se non trova colonne adatte, salta le query dipendenti e mostra istruzioni" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from pathlib import Path\n", - "import duckdb\n", - "\n", - "ROOT = Path('.').resolve()\n", - "TABLE_NAME = 'project_summary'\n", - "MART_GLOBS = [\n", - " (ROOT / '..' / 'data' / 'mart').resolve(),\n", - " (ROOT / '..' / '_runs').resolve(),\n", - "]\n", - "\n", - "def first_match(table_name):\n", - " for base in MART_GLOBS:\n", - " if not base.exists():\n", - " continue\n", - " for path in sorted(base.glob(f'**/*{table_name}*.parquet')):\n", - " return path\n", - " return None\n", - "\n", - "mart_path = first_match(TABLE_NAME)\n", - "mart_path" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Descrizione breve:\n", - "\n", - "- questo notebook serve a capire cosa c'è nella tabella finale\n", - "- aggiorna `TABLE_NAME` se il dataset usa un altro mart principale" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "con = duckdb.connect()\n", - "\n", - "def read_schema(path):\n", - " schema = con.execute(f\"DESCRIBE SELECT * FROM read_parquet('{path}')\").df()\n", - " schema.columns = [str(col).lower() for col in schema.columns]\n", - " return schema\n", - "\n", - "def choose_columns(schema):\n", - " name_col = 'column_name' if 'column_name' in schema.columns else schema.columns[0]\n", - " type_col = 'column_type' if 'column_type' in schema.columns else schema.columns[1]\n", - " rows = [\n", - " {'name': str(row[name_col]), 'type': str(row[type_col]).upper()}\n", - " for _, row in schema.iterrows()\n", - " ]\n", - " year_col = next((r['name'] for r in rows if r['name'].lower() == 'year' or 'anno' in r['name'].lower()), None)\n", - " numeric_rows = [r for r in rows if any(token in r['type'] for token in ['INT', 'DECIMAL', 'DOUBLE', 'FLOAT', 'REAL', 'BIGINT'])]\n", - " metric_col = next((r['name'] for r in numeric_rows if any(token in r['name'].lower() for token in ['value', 'tot', 'importo', 'ammontare', 'pct', 'percent'])), None)\n", - " if metric_col is None and numeric_rows:\n", - " metric_col = numeric_rows[0]['name']\n", - " return year_col, metric_col\n", - "\n", - "if mart_path:\n", - " schema_df = read_schema(str(mart_path))\n", - " YEAR_COL, METRIC_COL = choose_columns(schema_df)\n", - " preview = con.execute(f\"SELECT * FROM read_parquet('{mart_path}') LIMIT 20\").df()\n", - " display(schema_df)\n", - " display(preview)\n", - " print({'YEAR_COL': YEAR_COL, 'METRIC_COL': METRIC_COL})\n", - "else:\n", - " print('No mart parquet found. Run the pipeline first or update TABLE_NAME.')" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "if mart_path 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}') GROUP BY 1 ORDER BY 1\"\n", - " ).df()\n", - " display(by_year)\n", - "else:\n", - " print('No year-like column or metric column detected. Update TABLE_NAME or inspect schema_df manually.')" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "if mart_path and METRIC_COL:\n", - " top_rows = con.execute(\n", - " f\"SELECT * FROM read_parquet('{mart_path}') ORDER BY {METRIC_COL} DESC NULLS LAST LIMIT 10\"\n", - " ).df()\n", - " bottom_rows = con.execute(\n", - " f\"SELECT * FROM read_parquet('{mart_path}') ORDER BY {METRIC_COL} ASC NULLS LAST LIMIT 10\"\n", - " ).df()\n", - " display(top_rows)\n", - " display(bottom_rows)\n", - "else:\n", - " print('No metric column detected. Update the helper or inspect schema_df manually.')" - ] - } - ], - "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..d17e873 --- /dev/null +++ b/notebooks/01_inspect_raw.ipynb @@ -0,0 +1,119 @@ +{ + "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 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", + "OUT_ROOT = (BASE_DIR / CFG.get('root', '.')).resolve()\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", + "RAW_DIR = OUT_ROOT / 'data' / 'raw' / DATASET / str(YEAR)\n", + "MANIFEST_PATH = RAW_DIR / 'manifest.json'\n", + "METADATA_PATH = RAW_DIR / 'metadata.json'\n", + "VALIDATION_PATH = RAW_DIR / 'raw_validation.json'\n", + "PROFILE_DIR = RAW_DIR / '_profile'\n", + "\n", + "{\n", + " 'YEARS': YEARS,\n", + " 'YEAR_INDEX': YEAR_INDEX,\n", + " 'RAW_DIR': str(RAW_DIR),\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..fb6d826 --- /dev/null +++ b/notebooks/02_inspect_clean.ipynb @@ -0,0 +1,102 @@ +{ + "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 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", + "OUT_ROOT = (BASE_DIR / CFG.get('root', '.')).resolve()\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", + "CLEAN_DIR = OUT_ROOT / 'data' / 'clean' / DATASET / str(YEAR)\n", + "CLEAN_PATH = CLEAN_DIR / f'{DATASET}_{YEAR}_clean.parquet'\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", + " '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/02_quality_checks.ipynb b/notebooks/02_quality_checks.ipynb deleted file mode 100644 index ab512df..0000000 --- a/notebooks/02_quality_checks.ipynb +++ /dev/null @@ -1,134 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# 02 Quality checks\n", - "\n", - "## Cosa fa / Cosa NON fa\n", - "\n", - "- esegue controlli riutilizzabili su duplicati, missingness e range\n", - "- prova a rilevare colonne numeriche in modo automatico\n", - "- se mancano chiavi o colonne adatte, stampa istruzioni invece di fallire" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from pathlib import Path\n", - "import duckdb\n", - "\n", - "ROOT = Path('.').resolve()\n", - "TABLE_NAME = 'project_summary'\n", - "KEY_COLUMNS = []\n", - "NUMERIC_COLUMNS = []\n", - "\n", - "def find_mart(table_name):\n", - " for base in [(ROOT / '..' / 'data' / 'mart').resolve(), (ROOT / '..' / '_runs').resolve()]:\n", - " if not base.exists():\n", - " continue\n", - " matches = sorted(base.glob(f'**/*{table_name}*.parquet'))\n", - " if matches:\n", - " return matches[0]\n", - " return None\n", - "\n", - "mart_path = find_mart(TABLE_NAME)\n", - "mart_path" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "con = duckdb.connect()\n", - "\n", - "def read_schema(path):\n", - " schema = con.execute(f\"DESCRIBE SELECT * FROM read_parquet('{path}')\").df()\n", - " schema.columns = [str(col).lower() for col in schema.columns]\n", - " return schema\n", - "\n", - "def detect_numeric_columns(schema):\n", - " name_col = 'column_name' if 'column_name' in schema.columns else schema.columns[0]\n", - " type_col = 'column_type' if 'column_type' in schema.columns else schema.columns[1]\n", - " numeric = []\n", - " for _, row in schema.iterrows():\n", - " dtype = str(row[type_col]).upper()\n", - " if any(token in dtype for token in ['INT', 'DECIMAL', 'DOUBLE', 'FLOAT', 'REAL', 'BIGINT']):\n", - " numeric.append(str(row[name_col]))\n", - " return numeric\n", - "\n", - "def duplicate_key_report(path, key_columns):\n", - " keys = ', '.join(key_columns)\n", - " sql = f\"\"\"\n", - " SELECT {keys}, COUNT(*) AS dup_count\n", - " FROM read_parquet('{path}')\n", - " GROUP BY {keys}\n", - " HAVING COUNT(*) > 1\n", - " ORDER BY dup_count DESC\n", - " LIMIT 20\n", - " \"\"\"\n", - " return con.execute(sql).df()\n", - "\n", - "def missingness_report(path):\n", - " columns = [row[0] for row in con.execute(f\"DESCRIBE SELECT * FROM read_parquet('{path}')\").fetchall()]\n", - " expr = ', '.join([f\"AVG(CASE WHEN {col} IS NULL THEN 1 ELSE 0 END) AS {col}_null_rate\" for col in columns])\n", - " return con.execute(f\"SELECT {expr} FROM read_parquet('{path}')\").df().T.reset_index()\n", - "\n", - "def range_report(path, numeric_columns):\n", - " expr = ', '.join([f\"MIN({col}) AS {col}_min, MAX({col}) AS {col}_max\" for col in numeric_columns])\n", - " return con.execute(f\"SELECT {expr} FROM read_parquet('{path}')\").df().T.reset_index()\n", - "\n", - "if mart_path:\n", - " schema_df = read_schema(str(mart_path))\n", - " detected_numeric = detect_numeric_columns(schema_df)\n", - " numeric_for_range = [col for col in NUMERIC_COLUMNS if col in detected_numeric]\n", - " if not numeric_for_range:\n", - " numeric_for_range = detected_numeric[:3]\n", - " display(schema_df)\n", - " print({'KEY_COLUMNS': KEY_COLUMNS, 'NUMERIC_COLUMNS': numeric_for_range})" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "if mart_path:\n", - " if KEY_COLUMNS:\n", - " dup_df = duplicate_key_report(str(mart_path), KEY_COLUMNS)\n", - " display(dup_df)\n", - " else:\n", - " print('Duplicate-key check skipped: imposta KEY_COLUMNS.')\n", - "\n", - " missing_df = missingness_report(str(mart_path))\n", - " display(missing_df)\n", - "\n", - " if numeric_for_range:\n", - " range_df = range_report(str(mart_path), numeric_for_range)\n", - " display(range_df)\n", - " else:\n", - " print('Range check skipped: no numeric columns 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/03_dashboard_export.ipynb b/notebooks/03_dashboard_export.ipynb deleted file mode 100644 index 0b052b9..0000000 --- a/notebooks/03_dashboard_export.ipynb +++ /dev/null @@ -1,131 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# 03 Dashboard export\n", - "\n", - "## Cosa fa / Cosa NON fa\n", - "\n", - "- prepara un export di esempio partendo da un mart disponibile\n", - "- non scrive file finché `EXPORT = False`\n", - "- se non trova colonne adatte, esporta un campione generico come fallback" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from pathlib import Path\n", - "import duckdb\n", - "\n", - "ROOT = Path('.').resolve()\n", - "EXPORT = False\n", - "OUT_DIR = (ROOT / '..' / '_tmp').resolve()\n", - "TABLE_NAME = 'project_summary'\n", - "\n", - "def find_mart(table_name):\n", - " for base in [(ROOT / '..' / 'data' / 'mart').resolve(), (ROOT / '..' / '_runs').resolve()]:\n", - " if not base.exists():\n", - " continue\n", - " matches = sorted(base.glob(f'**/*{table_name}*.parquet'))\n", - " if matches:\n", - " return matches[0]\n", - " return None\n", - "\n", - "mart_path = find_mart(TABLE_NAME)\n", - "OUT_DIR" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "con = duckdb.connect()\n", - "\n", - "def read_schema(path):\n", - " schema = con.execute(f\"DESCRIBE SELECT * FROM read_parquet('{path}')\").df()\n", - " schema.columns = [str(col).lower() for col in schema.columns]\n", - " return schema\n", - "\n", - "def choose_columns(schema):\n", - " name_col = 'column_name' if 'column_name' in schema.columns else schema.columns[0]\n", - " type_col = 'column_type' if 'column_type' in schema.columns else schema.columns[1]\n", - " rows = [\n", - " {'name': str(row[name_col]), 'type': str(row[type_col]).upper()}\n", - " for _, row in schema.iterrows()\n", - " ]\n", - " year_col = next((r['name'] for r in rows if r['name'].lower() == 'year' or 'anno' in r['name'].lower()), None)\n", - " numeric_rows = [r for r in rows if any(token in r['type'] for token in ['INT', 'DECIMAL', 'DOUBLE', 'FLOAT', 'REAL', 'BIGINT'])]\n", - " metric_col = next((r['name'] for r in numeric_rows if any(token in r['name'].lower() for token in ['value', 'tot', 'importo', 'ammontare', 'pct', 'percent'])), None)\n", - " if metric_col is None and numeric_rows:\n", - " metric_col = numeric_rows[0]['name']\n", - " return year_col, metric_col\n", - "\n", - "if mart_path:\n", - " schema_df = read_schema(str(mart_path))\n", - " YEAR_COL, METRIC_COL = choose_columns(schema_df)\n", - " print({'YEAR_COL': YEAR_COL, 'METRIC_COL': METRIC_COL})\n", - "else:\n", - " print('No mart parquet found. Run the pipeline first or update TABLE_NAME.')" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "if mart_path 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}') ORDER BY 1\"\n", - " ).df()\n", - "elif mart_path:\n", - " print('Using generic fallback export: no year-like or metric column detected.')\n", - " export_df = con.execute(f\"SELECT * FROM read_parquet('{mart_path}') 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}' (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/03_explore_mart.ipynb b/notebooks/03_explore_mart.ipynb new file mode 100644 index 0000000..df2d7e7 --- /dev/null +++ b/notebooks/03_explore_mart.ipynb @@ -0,0 +1,100 @@ +{ + "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 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", + "OUT_ROOT = (BASE_DIR / CFG.get('root', '.')).resolve()\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", + "MART_PATH = OUT_ROOT / 'data' / 'mart' / DATASET / str(YEAR) / f'{TABLE_NAME}.parquet'\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)}" + ] + }, + { + "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.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.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..f4df43e --- /dev/null +++ b/notebooks/04_quality_checks.ipynb @@ -0,0 +1,106 @@ +{ + "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 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", + "OUT_ROOT = (BASE_DIR / CFG.get('root', '.')).resolve()\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", + "MART_PATH = OUT_ROOT / 'data' / 'mart' / DATASET / str(YEAR) / f'{TABLE_NAME}.parquet'\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)}" + ] + }, + { + "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.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.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..9bb1d7d --- /dev/null +++ b/notebooks/05_dashboard_export.ipynb @@ -0,0 +1,122 @@ +{ + "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 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", + "OUT_ROOT = (BASE_DIR / CFG.get('root', '.')).resolve()\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", + "MART_PATH = OUT_ROOT / 'data' / 'mart' / DATASET / str(YEAR) / f'{TABLE_NAME}.parquet'\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)}" + ] + }, + { + "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.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.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.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 a5d1986..6fd0746 100644 --- a/notebooks/README.md +++ b/notebooks/README.md @@ -1,18 +1,23 @@ # /notebooks - notebook standard per il dataset -Questa cartella contiene notebook leggeri e clonabili per avvio rapido, esplorazione dei mart e controlli di qualita. +Questa cartella contiene notebook leggeri e clonabili per tutto il lifecycle operativo del dataset. Usano solo Python standard, `duckdb`, path relativi e il file `../dataset.yml` come riferimento di progetto. +I notebook non reimplementano il motore della pipeline. +Assumono che il toolkit produca output leggibili e servono a ispezionare RAW, CLEAN o MART dal punto di vista del dataset. + ## Notebook inclusi -- `00_quickstart.ipynb` - esegue la pipeline e controlla che esistano tabelle mart leggibili -- `01_explore_mart.ipynb` - esplorazione public-first dei dati finali -- `02_quality_checks.ipynb` - controlli ripetibili su duplicati, missingness e range -- `03_dashboard_export.ipynb` - export opzionali in `../_tmp/`, disattivati di default +- `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 ## Regole - non salvare output pesanti nel repo - se serve esportare file, usa `../_tmp/` -- mantieni i notebook generici: aggiorna nomi tabella e chiavi senza introdurre logica dataset-specifica +- mantieni i notebook generici: preferisci leggere `dataset.yml` e usa i parametri iniziali per scegliere anno/tabella - per dettagli tecnici della pipeline, vedi il repository Toolkit DataCivicLab 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 index 4b056e6..ec02853 100644 --- a/scripts/smoke.sh +++ b/scripts/smoke.sh @@ -24,12 +24,8 @@ 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') else 1)" >/dev/null 2>&1; then - echo toolkit - return 0 - fi - if "${PYTHON_BIN}" -c "import importlib.util, sys; sys.exit(0 if importlib.util.find_spec('dataciviclab_toolkit') else 1)" >/dev/null 2>&1; then - echo dataciviclab_toolkit + 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 @@ -54,6 +50,21 @@ detect_year() { 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}" "$@" @@ -63,7 +74,7 @@ run_toolkit() { "${PYTHON_BIN}" -m "${TOOLKIT_MODULE}" "$@" return 0 fi - echo "Toolkit non disponibile: imposta TOOLKIT_BIN oppure installa un modulo Python 'toolkit' o 'dataciviclab_toolkit'." >&2 + echo "Toolkit non disponibile: imposta TOOLKIT_BIN oppure installa il modulo Python del toolkit." >&2 exit 2 } @@ -78,20 +89,21 @@ else fi if [ -z "${TOOLKIT_COMMAND}" ] && [ -z "${TOOLKIT_MODULE}" ]; then - echo "Toolkit non trovato. Provati: comando '${TOOLKIT_BIN}', modulo 'toolkit', modulo 'dataciviclab_toolkit'." >&2 + 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 raw --config "${DATASET_FILE}" --year "${YEAR}" -run_toolkit run clean --config "${DATASET_FILE}" --year "${YEAR}" -run_toolkit run mart --config "${DATASET_FILE}" --year "${YEAR}" -run_toolkit validate --config "${DATASET_FILE}" --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}" diff --git a/sql/clean.sql b/sql/clean.sql index 51afa63..22585a2 100644 --- a/sql/clean.sql +++ b/sql/clean.sql @@ -1,23 +1,33 @@ --- clean.sql --- Purpose: placeholder transformation for the CLEAN layer. --- Contract: read from the source configured in dataset.yml and keep the query portable. +WITH base AS ( + SELECT + TRY_CAST(TRIM(CAST("ANNO" AS VARCHAR)) AS INTEGER) AS anno, -with source_rows as ( - select - year, - entity_id, - metric_value - from raw_input -), -normalized as ( - select - cast(year as integer) as year, - cast(entity_id as varchar) as entity_id, - cast(metric_value as double) as metric_value - from source_rows + 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 - year, - entity_id, - metric_value -from normalized; + +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/sql/mart/project_summary.sql b/sql/mart/project_summary.sql deleted file mode 100644 index eaa4488..0000000 --- a/sql/mart/project_summary.sql +++ /dev/null @@ -1,25 +0,0 @@ --- project_summary.sql --- Purpose: placeholder MART query aligned with the toolkit `mart.tables[]` contract. --- Contract: consume `clean_input` and expose a dashboard-ready table. - -with clean_rows as ( - select - year, - entity_id, - metric_value - from clean_input -), -project_summary as ( - select - year, - count(*) as rows_in_year, - sum(metric_value) as total_metric_value - from clean_rows - group by year -) -select - year, - rows_in_year, - total_metric_value -from project_summary -order by year; diff --git a/tests/test_contract.py b/tests/test_contract.py index 06c79bf..e58fc1b 100644 --- a/tests/test_contract.py +++ b/tests/test_contract.py @@ -13,7 +13,6 @@ REQUIRED_FILES = [ REPO_ROOT / "dataset.yml", REPO_ROOT / "sql" / "clean.sql", - REPO_ROOT / "sql" / "mart" / "project_summary.sql", REPO_ROOT / "docs" / "sources.md", REPO_ROOT / "docs" / "decisions.md", REPO_ROOT / "docs" / "data_dictionary.md", @@ -22,6 +21,10 @@ ] +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(): @@ -38,38 +41,52 @@ def test_required_files_exist() -> None: assert not missing, f"Missing required template files: {missing}" -def test_dataset_uses_supported_contract_keys() -> None: - dataset = yaml.safe_load(DATASET_FILE.read_text(encoding="utf-8")) - clean_read = dataset["clean"]["read"] +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 dataset["validation"]["fail_on_error"] is True - assert "source" in clean_read - assert "header" in clean_read - assert "columns" in clean_read - assert "csv" not in clean_read + 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 dataset["mart"]["validate"]["table_rules"]["project_summary"]["required_columns"] + 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_matches_smoke_contract_shape() -> None: - dataset = yaml.safe_load(DATASET_FILE.read_text(encoding="utf-8")) +def test_dataset_avoids_legacy_clean_read_shape() -> None: + dataset = _load_dataset() - assert dataset["output"]["artifacts"] == "minimal" assert "csv" not in dataset["clean"]["read"] def test_dataset_paths_are_relative_and_posix() -> None: - dataset = yaml.safe_load(DATASET_FILE.read_text(encoding="utf-8")) + dataset = _load_dataset() for key, value in _iter_path_values(dataset): if value.startswith("http://") or value.startswith("https://"): @@ -81,21 +98,35 @@ def test_dataset_paths_are_relative_and_posix() -> None: assert not re.match(r"^[A-Za-z]:[\\/]", value), f"Absolute Windows path found for key '{key}': {value}" -def test_yaml_sql_paths_match_template_files() -> None: - dataset = yaml.safe_load(DATASET_FILE.read_text(encoding="utf-8")) +def test_declared_sql_files_exist() -> None: + dataset = _load_dataset() - assert dataset["clean"]["sql"] == "sql/clean.sql" + 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"] - project_summary = next((table for table in mart_tables if table["name"] == "project_summary"), None) - assert project_summary is not None, "Missing mart table 'project_summary'" - assert project_summary["sql"] == "sql/mart/project_summary.sql" + 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() -def test_output_artifacts_is_configured() -> None: - dataset = yaml.safe_load(DATASET_FILE.read_text(encoding="utf-8")) + 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 dataset["output"]["artifacts"] == "minimal" + 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: From fcc553c781d7ddd47252a2acc5516bcec252b8e3 Mon Sep 17 00:00:00 2001 From: Zio Gabber <78922322+Gabrymi93@users.noreply.github.com> Date: Mon, 2 Mar 2026 10:04:35 +0000 Subject: [PATCH 03/11] docs: link stable toolkit notebook and feature docs --- README.md | 10 +++++++++ WORKFLOW.md | 7 +++++- dashboard/README.md | 1 + docs/contributing.md | 21 ++++++++++++------ notebooks/00_quickstart.ipynb | 34 +++++++++++++++++++++-------- notebooks/01_inspect_raw.ipynb | 16 +++++++++----- notebooks/02_inspect_clean.ipynb | 13 +++++++---- notebooks/03_explore_mart.ipynb | 13 +++++++---- notebooks/04_quality_checks.ipynb | 13 +++++++---- notebooks/05_dashboard_export.ipynb | 12 +++++++--- notebooks/README.md | 6 +++-- 11 files changed, 106 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index f3edb3b..35e155c 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,14 @@ Se lavori con un checkout locale del toolkit, installalo in editable e poi esegu Per dettagli tecnici su CLI, configurazione supportata, validazioni runtime e run metadata, vedi il repository **Toolkit DataCivicLab**. +I notebook del template usano `toolkit inspect paths --config dataset.yml --year --json` per localizzare gli output reali della pipeline. +Il workflow principale del template resta centrato su `run all`, `validate all`, `status` e notebook locali; i flow avanzati del toolkit restano documentati nel repo toolkit. +Per i contratti stabili del toolkit, vedi in particolare: + +* `docs/notebook-contract.md` +* `docs/feature-stability.md` +* `docs/advanced-workflows.md` + ## Archivio Pubblico @@ -135,6 +143,8 @@ Se il progetto pubblica artifact in un archivio pubblico DataCivicLab su Drive, 2. verificare gli output sotto `root/data/...` 3. pubblicare solo gli artifact pubblici con uno script separato +Il publish su Drive e una operazione `maintainer-only`, da eseguire in fase di release o merge, non nel workflow base dei contributor. + Esempio: ```bash diff --git a/WORKFLOW.md b/WORKFLOW.md index 2695a4b..466261b 100644 --- a/WORKFLOW.md +++ b/WORKFLOW.md @@ -34,4 +34,9 @@ Come contribuire in modo semplice a un progetto dataset DataCivicLab. 2. esegui `toolkit run all --config dataset.yml` 3. esegui `toolkit validate all --config dataset.yml` 4. usa i notebook per ispezionare RAW, CLEAN, MART e QA -5. se il progetto ha un archivio pubblico, pubblica gli artifact con `py scripts/publish_to_drive.py` + +## Maintainers + +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 b4277ed..1f64be5 100644 --- a/dashboard/README.md +++ b/dashboard/README.md @@ -15,3 +15,4 @@ Qui vanno link, note di lettura, screenshot e limiti dell'output, non i dati. 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/docs/contributing.md b/docs/contributing.md index 2d54479..904c937 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -47,6 +47,7 @@ toolkit status --dataset --year --latest --config dataset.yml ## 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: @@ -67,13 +68,17 @@ La destinazione su Drive mantiene gli stessi path relativi sotto `root`, quindi ```sh toolkit run all --config dataset.yml -toolkit run raw --config dataset.yml -toolkit run clean --config dataset.yml -toolkit run mart --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`, `profile raw` o `gen-sql`, 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` + ## Fasi operative - kickoff e contratto: `dataset.yml`, `README.md`, `tests/test_contract.py` @@ -88,13 +93,15 @@ toolkit status --dataset --year --latest --config dataset.yml | 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` | `01_inspect_raw.ipynb` | -| CLEAN | `sql/clean.sql`, `dataset.yml`, `docs/data_dictionary.md` | `toolkit run clean --config dataset.yml` | `02_inspect_clean.ipynb` | -| MART | `sql/mart/*.sql`, `dataset.yml` | `toolkit run mart --config dataset.yml` | `03_explore_mart.ipynb` | +| Sources/RAW | `dataset.yml`, `docs/sources.md`, `docs/decisions.md` | `toolkit inspect paths --config dataset.yml --year --json` | `01_inspect_raw.ipynb` | +| CLEAN | `sql/clean.sql`, `dataset.yml`, `docs/data_dictionary.md` | `toolkit inspect paths --config dataset.yml --year --json` | `02_inspect_clean.ipynb` | +| MART | `sql/mart/*.sql`, `dataset.yml` | `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` | `py scripts/publish_to_drive.py --config dataset.yml --drive-root "" --dry-run` | `05_dashboard_export.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. + ## Regole veloci - non committare output sotto `data/`, salvo sample piccoli in `data/_examples` diff --git a/notebooks/00_quickstart.ipynb b/notebooks/00_quickstart.ipynb index 0f1f20f..1592f33 100644 --- a/notebooks/00_quickstart.ipynb +++ b/notebooks/00_quickstart.ipynb @@ -18,6 +18,7 @@ "outputs": [], "source": [ "from pathlib import Path\n", + "import json\n", "import shutil\n", "import subprocess\n", "import yaml\n", @@ -26,7 +27,6 @@ "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", - "OUT_ROOT = (BASE_DIR / CFG.get('root', '.')).resolve()\n", "DATASET = CFG['dataset']['name']\n", "YEARS = CFG['dataset']['years']\n", "YEAR_INDEX = 0\n", @@ -36,16 +36,32 @@ "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", + "try:\n", + " INSPECT = json.loads(subprocess.run(INSPECT_CMD, capture_output=True, text=True, check=True).stdout)\n", + "except Exception:\n", + " OUT_ROOT = (BASE_DIR / CFG.get('root', '.')).resolve()\n", + " INSPECT = {\n", + " 'root': str(OUT_ROOT),\n", + " 'paths': {\n", + " 'raw': {'dir': str(OUT_ROOT / 'data' / 'raw' / DATASET / str(YEAR))},\n", + " 'clean': {'dir': str(OUT_ROOT / 'data' / 'clean' / DATASET / str(YEAR))},\n", + " 'mart': {'dir': str(OUT_ROOT / 'data' / 'mart' / DATASET / str(YEAR))},\n", + " 'run_dir': str(OUT_ROOT / 'data' / '_runs' / DATASET / str(YEAR)),\n", + " },\n", + " 'latest_run': None,\n", + " }\n", "\n", "{\n", " 'DATASET_YML': str(DATASET_YML),\n", - " 'OUT_ROOT': str(OUT_ROOT),\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", "}" ] }, @@ -55,13 +71,13 @@ "metadata": {}, "outputs": [], "source": [ - "expected_paths = {\n", - " 'raw_dir': str(OUT_ROOT / 'data' / 'raw' / DATASET / str(YEAR)),\n", - " 'clean_dir': str(OUT_ROOT / 'data' / 'clean' / DATASET / str(YEAR)),\n", - " 'mart_dir': str(OUT_ROOT / 'data' / 'mart' / DATASET / str(YEAR)),\n", - " 'run_dir': str(OUT_ROOT / 'data' / '_runs' / DATASET / str(YEAR)),\n", - "}\n", - "expected_paths" + "{\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", + "}" ] }, { diff --git a/notebooks/01_inspect_raw.ipynb b/notebooks/01_inspect_raw.ipynb index d17e873..a3577fd 100644 --- a/notebooks/01_inspect_raw.ipynb +++ b/notebooks/01_inspect_raw.ipynb @@ -19,28 +19,32 @@ "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", - "OUT_ROOT = (BASE_DIR / CFG.get('root', '.')).resolve()\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", - "RAW_DIR = OUT_ROOT / 'data' / 'raw' / DATASET / str(YEAR)\n", - "MANIFEST_PATH = RAW_DIR / 'manifest.json'\n", - "METADATA_PATH = RAW_DIR / 'metadata.json'\n", - "VALIDATION_PATH = RAW_DIR / 'raw_validation.json'\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", diff --git a/notebooks/02_inspect_clean.ipynb b/notebooks/02_inspect_clean.ipynb index fb6d826..029fef3 100644 --- a/notebooks/02_inspect_clean.ipynb +++ b/notebooks/02_inspect_clean.ipynb @@ -18,20 +18,24 @@ "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", - "OUT_ROOT = (BASE_DIR / CFG.get('root', '.')).resolve()\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", - "CLEAN_DIR = OUT_ROOT / 'data' / 'clean' / DATASET / str(YEAR)\n", - "CLEAN_PATH = CLEAN_DIR / f'{DATASET}_{YEAR}_clean.parquet'\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", @@ -39,6 +43,7 @@ " '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", "}" diff --git a/notebooks/03_explore_mart.ipynb b/notebooks/03_explore_mart.ipynb index df2d7e7..38f63f5 100644 --- a/notebooks/03_explore_mart.ipynb +++ b/notebooks/03_explore_mart.ipynb @@ -18,14 +18,15 @@ "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", - "OUT_ROOT = (BASE_DIR / CFG.get('root', '.')).resolve()\n", "DATASET = CFG['dataset']['name']\n", "YEARS = CFG['dataset']['years']\n", "YEAR_INDEX = 0\n", @@ -34,8 +35,12 @@ "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", - "MART_PATH = OUT_ROOT / 'data' / 'mart' / DATASET / str(YEAR) / f'{TABLE_NAME}.parquet'\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)}" + "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 MART_OUTPUTS and 0 <= TABLE_INDEX < len(MART_OUTPUTS) else (Path(MART_OUTPUTS[0]) if MART_OUTPUTS else Path(INSPECT['paths']['mart']['dir']) / f'{TABLE_NAME}.parquet')\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}" ] }, { diff --git a/notebooks/04_quality_checks.ipynb b/notebooks/04_quality_checks.ipynb index f4df43e..f5aca77 100644 --- a/notebooks/04_quality_checks.ipynb +++ b/notebooks/04_quality_checks.ipynb @@ -18,14 +18,15 @@ "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", - "OUT_ROOT = (BASE_DIR / CFG.get('root', '.')).resolve()\n", "DATASET = CFG['dataset']['name']\n", "YEARS = CFG['dataset']['years']\n", "YEAR_INDEX = 0\n", @@ -36,8 +37,12 @@ "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", - "MART_PATH = OUT_ROOT / 'data' / 'mart' / DATASET / str(YEAR) / f'{TABLE_NAME}.parquet'\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)}" + "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 MART_OUTPUTS and 0 <= TABLE_INDEX < len(MART_OUTPUTS) else (Path(MART_OUTPUTS[0]) if MART_OUTPUTS else Path(INSPECT['paths']['mart']['dir']) / f'{TABLE_NAME}.parquet')\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}" ] }, { diff --git a/notebooks/05_dashboard_export.ipynb b/notebooks/05_dashboard_export.ipynb index 9bb1d7d..e14c218 100644 --- a/notebooks/05_dashboard_export.ipynb +++ b/notebooks/05_dashboard_export.ipynb @@ -18,6 +18,9 @@ "outputs": [], "source": [ "from pathlib import Path\n", + "import json\n", + "import shutil\n", + "import subprocess\n", "import duckdb\n", "import yaml\n", "\n", @@ -25,7 +28,6 @@ "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", - "OUT_ROOT = (BASE_DIR / CFG.get('root', '.')).resolve()\n", "DATASET = CFG['dataset']['name']\n", "YEARS = CFG['dataset']['years']\n", "YEAR_INDEX = 0\n", @@ -34,10 +36,14 @@ "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", - "MART_PATH = OUT_ROOT / 'data' / 'mart' / DATASET / str(YEAR) / f'{TABLE_NAME}.parquet'\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 MART_OUTPUTS and 0 <= TABLE_INDEX < len(MART_OUTPUTS) else (Path(MART_OUTPUTS[0]) if MART_OUTPUTS else Path(INSPECT['paths']['mart']['dir']) / f'{TABLE_NAME}.parquet')\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)}" + "{'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}" ] }, { diff --git a/notebooks/README.md b/notebooks/README.md index 6fd0746..f929106 100644 --- a/notebooks/README.md +++ b/notebooks/README.md @@ -1,10 +1,12 @@ # /notebooks - notebook standard per il dataset Questa cartella contiene notebook leggeri e clonabili per tutto il lifecycle operativo del dataset. -Usano solo Python standard, `duckdb`, path relativi e il file `../dataset.yml` come riferimento di progetto. +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. -Assumono che il toolkit produca output leggibili e servono a ispezionare RAW, CLEAN o MART dal punto di vista del dataset. +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. ## Notebook inclusi From c4b0cf81fcb3bdecf799e7970e09d2ee071e1c11 Mon Sep 17 00:00:00 2001 From: Zio Gabber <78922322+Gabrymi93@users.noreply.github.com> Date: Mon, 2 Mar 2026 14:02:16 +0000 Subject: [PATCH 04/11] docs: clarify layer workflow with inspect paths discovery --- .github/seed-issues/02_sources.md | 1 + .github/seed-issues/03_raw.md | 2 ++ .github/seed-issues/04_clean.md | 2 ++ .github/seed-issues/05_mart.md | 2 ++ .github/seed-issues/08_release.md | 2 +- README.md | 3 +++ WORKFLOW.md | 2 +- docs/README.md | 2 +- docs/contributing.md | 12 +++++++++--- docs/lab_links.md | 17 +++++++++-------- 10 files changed, 31 insertions(+), 14 deletions(-) diff --git a/.github/seed-issues/02_sources.md b/.github/seed-issues/02_sources.md index ce12dc8..725c0e0 100644 --- a/.github/seed-issues/02_sources.md +++ b/.github/seed-issues/02_sources.md @@ -33,6 +33,7 @@ Fonte verificata e configurata in `dataset.yml`, con documentazione sufficiente - 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 diff --git a/.github/seed-issues/03_raw.md b/.github/seed-issues/03_raw.md index 52adf76..7f62545 100644 --- a/.github/seed-issues/03_raw.md +++ b/.github/seed-issues/03_raw.md @@ -19,6 +19,7 @@ Ottenere un layer RAW eseguibile e ripetibile, senza committare output in repo. - [ ] 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 @@ -32,6 +33,7 @@ RAW eseguibile con report minimi di validazione e metadata disponibili negli art - 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 diff --git a/.github/seed-issues/04_clean.md b/.github/seed-issues/04_clean.md index 6fb4487..fc40d99 100644 --- a/.github/seed-issues/04_clean.md +++ b/.github/seed-issues/04_clean.md @@ -23,6 +23,7 @@ Portare il dataset da RAW a CLEAN con SQL esplicita, schema documentato e valida - [ ] 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` @@ -34,6 +35,7 @@ Layer CLEAN riproducibile, con schema e regole di validazione sufficienti per al - 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 diff --git a/.github/seed-issues/05_mart.md b/.github/seed-issues/05_mart.md index 9bac54a..ff39aea 100644 --- a/.github/seed-issues/05_mart.md +++ b/.github/seed-issues/05_mart.md @@ -22,6 +22,7 @@ Produrre uno o piu mart orientati a KPI e output finali, con tabella/e e validat - [ ] 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 @@ -33,6 +34,7 @@ Mart pronti per dashboard o report, con SQL separata per tabella e regole di val - 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 diff --git a/.github/seed-issues/08_release.md b/.github/seed-issues/08_release.md index 2b8b517..2fcf1af 100644 --- a/.github/seed-issues/08_release.md +++ b/.github/seed-issues/08_release.md @@ -19,7 +19,7 @@ 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 release policy, DoD e riferimenti Lab-wide +- [ ] 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 diff --git a/README.md b/README.md index 35e155c..b3188bb 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,8 @@ Per i contratti stabili del toolkit, vedi in particolare: * `docs/feature-stability.md` * `docs/advanced-workflows.md` +Per il contesto dell'ecosistema DataCivicLab, la mappa delle repo e le policy condivise, usa invece i riferimenti in `docs/lab_links.md`. + ## Archivio Pubblico @@ -172,3 +174,4 @@ Esempio: Parte del progetto DataCivicLab. Costruiamo infrastruttura open per analisi pubbliche riproducibili. +Per capire come si colloca questa repo nell'organizzazione, parti da `docs/lab_links.md`. diff --git a/WORKFLOW.md b/WORKFLOW.md index 466261b..fcd06b2 100644 --- a/WORKFLOW.md +++ b/WORKFLOW.md @@ -11,7 +11,7 @@ Come contribuire in modo semplice a un progetto dataset DataCivicLab. ## Dove andare - setup e contributo rapido: [docs/contributing.md](docs/contributing.md) -- standard Lab, DoD e release policy: [docs/lab_links.md](docs/lab_links.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 diff --git a/docs/README.md b/docs/README.md index abcaf67..4024356 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,7 +1,7 @@ # Docs Questa cartella contiene i documenti locali, specifici di questo dataset. -Per standard del Lab vedi [lab_links.md](lab_links.md). +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. diff --git a/docs/contributing.md b/docs/contributing.md index 904c937..d524af7 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -79,6 +79,12 @@ Per il contratto stabile dei notebook e la matrice di stabilita delle feature, v - `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` @@ -93,9 +99,9 @@ Per il contratto stabile dei notebook e la matrice di stabilita delle feature, v | 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 inspect paths --config dataset.yml --year --json` | `01_inspect_raw.ipynb` | -| CLEAN | `sql/clean.sql`, `dataset.yml`, `docs/data_dictionary.md` | `toolkit inspect paths --config dataset.yml --year --json` | `02_inspect_clean.ipynb` | -| MART | `sql/mart/*.sql`, `dataset.yml` | `toolkit inspect paths --config dataset.yml --year --json` | `03_explore_mart.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` | diff --git a/docs/lab_links.md b/docs/lab_links.md index 5c75687..6268911 100644 --- a/docs/lab_links.md +++ b/docs/lab_links.md @@ -1,15 +1,16 @@ # Lab Links Gli standard del Lab sono centralizzati e non vengono duplicati in questo template. -Usa questa pagina come ponte verso handbook e repository org-wide. +Usa questa pagina come ponte verso i repository organizzativi corretti. -## Handbook +## Hub del Lab -- [Handbook: Method](TODO: link repo dataciviclab/handbook) -- [Handbook: Definition of Done](TODO: link repo dataciviclab/handbook) -- [Handbook: Release policy](TODO: link repo dataciviclab/handbook) -- [Handbook: Roles](TODO: link repo dataciviclab/handbook) +- [dataciviclab](https://github.com/dataciviclab/dataciviclab): hub pubblico del Lab, mappa delle repo, catalogo dataset, governance alta e canali community -## Org-wide +## Policy organizzative -- [dataciviclab/.github: Issue and PR templates](TODO: link repo dataciviclab/.github) +- [.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 From 336f08ba2682c8e2c03128084e94a353743aaa94 Mon Sep 17 00:00:00 2001 From: Zio Gabber <78922322+Gabrymi93@users.noreply.github.com> Date: Mon, 2 Mar 2026 15:35:51 +0000 Subject: [PATCH 05/11] docs: finalize template framing for cloned dataset repos --- README.md | 163 ++++++++++++++++++------------------------- WORKFLOW.md | 6 +- docs/README.md | 3 + docs/contributing.md | 22 ++++-- docs/lab_links.md | 5 ++ 5 files changed, 97 insertions(+), 102 deletions(-) diff --git a/README.md b/README.md index b3188bb..3788222 100644 --- a/README.md +++ b/README.md @@ -1,143 +1,138 @@ -# 📊 [Nome dataset] — DataCivicLab +# [Nome dataset] - DataCivicLab Questo progetto analizza **[fenomeno pubblico]** per rispondere a una domanda semplice: **cosa sta succedendo, dove e con quali differenze nel tempo?** -È pensato per chi vuole orientarsi in fretta: +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. -* **Stato:** [alpha | beta | stable] -* **Copertura:** [anni], [territorio] -* **Unità di analisi:** [Comune / ASL / Provincia / …] +- **Stato:** [alpha | beta | stable] +- **Copertura:** [anni], [territorio] +- **Unita di analisi:** [Comune / ASL / Provincia / ...] ## 🎯 La domanda civica **[Scrivi qui la domanda chiave in una frase chiara.]** -Esempio: +Esempi: -* Come varia [fenomeno] tra territori? -* Dove si osservano miglioramenti o peggioramenti? -* Il mio territorio è sopra o sotto la media? +- Come varia [fenomeno] tra territori? +- Dove si osservano miglioramenti o peggioramenti? +- Il mio territorio e sopra o sotto la media? ## 🔎 Cosa puoi capire con questi dati -* come cambia il fenomeno nel tempo -* quali territori mostrano differenze significative -* se il tuo territorio è sopra o sotto la media -* se emergono anomalie o salti improvvisi -* quali aree meritano un approfondimento mirato +- 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 è solo un dataset: è una base per confronto e monitoraggio. +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 +- `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` - +`docs/data_dictionary.md` -## ✅ Perché fidarsi +## ✅ 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 +- 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 - -Se non sei tecnico, parti da una **Discussion**: -spiega il contesto, il territorio o l’anno che ti interessa e cosa vuoi capire. +- **Discussions** -> domande civiche, interpretazioni, proposte di metriche +- **Issues** -> bug, problemi tecnici, miglioramenti della pipeline +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. ## 📚 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 - - -## Confine con il toolkit +- `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 -Questo repository contiene il contratto del dataset: +## 🧩 Cos'e questa repo -* configurazione in `dataset.yml` -* trasformazioni SQL in `sql/` -* test di contratto e documentazione locale -* notebook leggeri per ispezione degli output +Questo repository e il template operativo da cui nascono i repo dataset DataCivicLab. -Il motore della pipeline vive nel repository **Toolkit DataCivicLab**. -Questa repo non replica la logica di esecuzione del toolkit: definisce input, regole e output attesi per questo dataset. - -In pratica: - -* bug o feature della CLI, runner, validazioni runtime e metadata di run → repo `toolkit` -* bug o modifiche a fonti, mapping, SQL, mart, docs e notebook di dataset → questa repo +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 -## 🧭 Roadmap +## 🛠️ Confine con il toolkit -La roadmap è gestita con **issue + milestone**. +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: -## 🔁 Clonabilità - -Questo repository è un modello per progetti dataset DataCivicLab. +- 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 -`dataset.yml` in root è un esempio eseguibile completo, utile per smoke e onboarding. -Chi clona questo template deve adattarlo al dataset reale, non copiarlo come contratto finale immutabile. +## 🔁 Da dove partire -Per adattarlo a un nuovo dataset: +Se stai clonando il template per un nuovo progetto: 1. aggiorna la domanda civica e gli esempi di insight -2. sostituisci fonti, copertura e unità di analisi +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. - +La struttura resta invariata. Non serve capire tutto subito: qui trovi la base pratica da cui partire. -## 🧪 Esecuzione tecnica (per contributor) +## 🧪 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 ``` -Se lavori con un checkout locale del toolkit, installalo in editable e poi esegui i comandi da questa repo. +I notebook del template usano anche: -Per dettagli tecnici su CLI, configurazione supportata, validazioni runtime e run metadata, -vedi il repository **Toolkit DataCivicLab**. +```bash +toolkit inspect paths --config dataset.yml --year --json +``` + +Per dettagli piu profondi su CLI, contratti stabili, workflow advanced e feature stability, il posto giusto e `toolkit`. -I notebook del template usano `toolkit inspect paths --config dataset.yml --year --json` per localizzare gli output reali della pipeline. -Il workflow principale del template resta centrato su `run all`, `validate all`, `status` e notebook locali; i flow avanzati del toolkit restano documentati nel repo toolkit. -Per i contratti stabili del toolkit, vedi in particolare: +## 🧭 Dove andare per il resto -* `docs/notebook-contract.md` -* `docs/feature-stability.md` -* `docs/advanced-workflows.md` +Questa repo resta focalizzata sul progetto dataset. -Per il contesto dell'ecosistema DataCivicLab, la mappa delle repo e le policy condivise, usa invece i riferimenti in `docs/lab_links.md`. +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` -## Archivio Pubblico +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: @@ -145,7 +140,7 @@ Se il progetto pubblica artifact in un archivio pubblico DataCivicLab su Drive, 2. verificare gli output sotto `root/data/...` 3. pubblicare solo gli artifact pubblici con uno script separato -Il publish su Drive e una operazione `maintainer-only`, da eseguire in fase di release o merge, non nel workflow base dei contributor. +Questo passaggio e `maintainer-only`. Esempio: @@ -154,24 +149,4 @@ py scripts/publish_to_drive.py --config dataset.yml --drive-root "G:\\DataCivicL py scripts/publish_to_drive.py --config dataset.yml --drive-root "G:\\DataCivicLab" --year 2022 ``` -Per default lo script pubblica: - -* payload RAW -* metadata, manifest e validation di `raw`, `clean`, `mart` -* parquet CLEAN -* parquet MART -* ultimo run record - La destinazione su Drive mantiene lo stesso path relativo degli output del toolkit sotto `root`. - -Esempio: - -* locale: `root/data/mart///mart_ok.parquet` -* Drive: `/data/mart///mart_ok.parquet` - - -## 🌍 DataCivicLab - -Parte del progetto DataCivicLab. -Costruiamo infrastruttura open per analisi pubbliche riproducibili. -Per capire come si colloca questa repo nell'organizzazione, parti da `docs/lab_links.md`. diff --git a/WORKFLOW.md b/WORKFLOW.md index fcd06b2..6091db9 100644 --- a/WORKFLOW.md +++ b/WORKFLOW.md @@ -4,8 +4,8 @@ Come contribuire in modo semplice a un progetto dataset DataCivicLab. ## Percorsi -- feedback o idee: apri una Discussion o una Issue -- avanzamento: usa gli issues e la Board +- 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 ## Dove andare @@ -28,6 +28,8 @@ Come contribuire in modo semplice a un progetto dataset DataCivicLab. 3. lavora su branch dedicato 4. apri una PR piccola e leggibile +GitHub resta il posto dove deve restare la traccia utile. + ## Flusso tecnico minimo 1. valida la config con `py -m pytest tests/test_contract.py` diff --git a/docs/README.md b/docs/README.md index 4024356..af43896 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,6 +6,9 @@ Per standard del Lab e riferimenti organizzativi vedi [lab_links.md](lab_links.m 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) diff --git a/docs/contributing.md b/docs/contributing.md index d524af7..7fdf8dd 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -1,6 +1,9 @@ # Contributing -Guida rapida per contribuire ai dati senza dover leggere tutta la documentazione tecnica del progetto. +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 @@ -11,7 +14,7 @@ Prerequisiti: - 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. +Il motore della pipeline sta nel repo `toolkit`. ## Contract tests @@ -32,11 +35,11 @@ Per uno smoke test end-to-end: sh scripts/smoke.sh ``` -Se il toolkit non è nel `PATH`, usa il fallback documentato nello script. -Se lo smoke fallisce per un problema del motore, apri il bug nel repo toolkit. +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 è disponibile nel `PATH`, usa una shell POSIX come Git Bash oppure esegui i comandi toolkit equivalenti: +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 @@ -44,6 +47,13 @@ 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. @@ -126,4 +136,4 @@ I notebook usano `toolkit inspect paths --config dataset.yml --year --jso - workflow umano: [../WORKFLOW.md](../WORKFLOW.md) - docs locali: [README.md](README.md) -- standard Lab: [lab_links.md](lab_links.md) +- contesto DataCivicLab, policy comuni e motore: [lab_links.md](lab_links.md) diff --git a/docs/lab_links.md b/docs/lab_links.md index 6268911..ed48ca9 100644 --- a/docs/lab_links.md +++ b/docs/lab_links.md @@ -14,3 +14,8 @@ Usa questa pagina come ponte verso i repository organizzativi corretti. ## 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` From d48b58d84cc7797a3e4b78476a96f0ba64be17bb Mon Sep 17 00:00:00 2001 From: Zio Gabber <78922322+Gabrymi93@users.noreply.github.com> Date: Mon, 2 Mar 2026 23:00:45 +0000 Subject: [PATCH 06/11] Align template notebooks with toolkit path contract --- docs/contributing.md | 8 +++++++- notebooks/00_quickstart.ipynb | 18 ++++-------------- notebooks/03_explore_mart.ipynb | 6 +++--- notebooks/04_quality_checks.ipynb | 6 +++--- notebooks/05_dashboard_export.ipynb | 8 ++++---- notebooks/README.md | 7 +++++++ scripts/smoke.sh | 1 + 7 files changed, 29 insertions(+), 25 deletions(-) diff --git a/docs/contributing.md b/docs/contributing.md index 7fdf8dd..80c85a8 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -83,7 +83,7 @@ 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`, `profile raw` o `gen-sql`, vedi la documentazione advanced del toolkit. +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` @@ -118,6 +118,12 @@ Quando lavori per layer invece che con `run all`, usa questa regola semplice: 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` diff --git a/notebooks/00_quickstart.ipynb b/notebooks/00_quickstart.ipynb index 1592f33..20d11ab 100644 --- a/notebooks/00_quickstart.ipynb +++ b/notebooks/00_quickstart.ipynb @@ -37,20 +37,10 @@ "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", - "try:\n", - " INSPECT = json.loads(subprocess.run(INSPECT_CMD, capture_output=True, text=True, check=True).stdout)\n", - "except Exception:\n", - " OUT_ROOT = (BASE_DIR / CFG.get('root', '.')).resolve()\n", - " INSPECT = {\n", - " 'root': str(OUT_ROOT),\n", - " 'paths': {\n", - " 'raw': {'dir': str(OUT_ROOT / 'data' / 'raw' / DATASET / str(YEAR))},\n", - " 'clean': {'dir': str(OUT_ROOT / 'data' / 'clean' / DATASET / str(YEAR))},\n", - " 'mart': {'dir': str(OUT_ROOT / 'data' / 'mart' / DATASET / str(YEAR))},\n", - " 'run_dir': str(OUT_ROOT / 'data' / '_runs' / DATASET / str(YEAR)),\n", - " },\n", - " 'latest_run': None,\n", - " }\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", diff --git a/notebooks/03_explore_mart.ipynb b/notebooks/03_explore_mart.ipynb index 38f63f5..0b698d1 100644 --- a/notebooks/03_explore_mart.ipynb +++ b/notebooks/03_explore_mart.ipynb @@ -39,7 +39,7 @@ "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 MART_OUTPUTS and 0 <= TABLE_INDEX < len(MART_OUTPUTS) else (Path(MART_OUTPUTS[0]) if MART_OUTPUTS else Path(INSPECT['paths']['mart']['dir']) / f'{TABLE_NAME}.parquet')\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}" ] }, @@ -61,7 +61,7 @@ " metric_col = numeric_rows[0][0]\n", " return year_col, metric_col\n", "\n", - "if MART_PATH.exists():\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", @@ -79,7 +79,7 @@ "metadata": {}, "outputs": [], "source": [ - "if MART_PATH.exists() and YEAR_COL and METRIC_COL:\n", + "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", diff --git a/notebooks/04_quality_checks.ipynb b/notebooks/04_quality_checks.ipynb index f5aca77..6cb3c89 100644 --- a/notebooks/04_quality_checks.ipynb +++ b/notebooks/04_quality_checks.ipynb @@ -41,7 +41,7 @@ "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 MART_OUTPUTS and 0 <= TABLE_INDEX < len(MART_OUTPUTS) else (Path(MART_OUTPUTS[0]) if MART_OUTPUTS else Path(INSPECT['paths']['mart']['dir']) / f'{TABLE_NAME}.parquet')\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}" ] }, @@ -58,7 +58,7 @@ " 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.exists():\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", @@ -71,7 +71,7 @@ "metadata": {}, "outputs": [], "source": [ - "if MART_PATH.exists():\n", + "if MART_PATH and MART_PATH.exists():\n", " if KEY_COLUMNS:\n", " keys = ', '.join(KEY_COLUMNS)\n", " dup_df = con.execute(\n", diff --git a/notebooks/05_dashboard_export.ipynb b/notebooks/05_dashboard_export.ipynb index e14c218..c29b947 100644 --- a/notebooks/05_dashboard_export.ipynb +++ b/notebooks/05_dashboard_export.ipynb @@ -40,7 +40,7 @@ "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 MART_OUTPUTS and 0 <= TABLE_INDEX < len(MART_OUTPUTS) else (Path(MART_OUTPUTS[0]) if MART_OUTPUTS else Path(INSPECT['paths']['mart']['dir']) / f'{TABLE_NAME}.parquet')\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}" @@ -66,7 +66,7 @@ " metric_col = numeric_rows[0]\n", " return year_col, metric_col\n", "\n", - "if MART_PATH.exists():\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", @@ -79,11 +79,11 @@ "metadata": {}, "outputs": [], "source": [ - "if MART_PATH.exists() and YEAR_COL and METRIC_COL:\n", + "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.exists():\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", diff --git a/notebooks/README.md b/notebooks/README.md index f929106..0223d3e 100644 --- a/notebooks/README.md +++ b/notebooks/README.md @@ -8,6 +8,12 @@ Usano `toolkit inspect paths --json` come fonte primaria per localizzare RAW, CL 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. +Contratto minimo degli output: + +- `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 - `00_quickstart.ipynb` - setup, command preview, run opzionale e localizzazione output reali del toolkit @@ -22,4 +28,5 @@ Per i dettagli stabili lato toolkit, vedi `docs/notebook-contract.md` e `docs/fe - 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/scripts/smoke.sh b/scripts/smoke.sh index ec02853..f984a6c 100644 --- a/scripts/smoke.sh +++ b/scripts/smoke.sh @@ -107,3 +107,4 @@ 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 From e71c859d8469c7773b2347a19481223eee45f987 Mon Sep 17 00:00:00 2001 From: Zio Gabber <78922322+Gabrymi93@users.noreply.github.com> Date: Mon, 2 Mar 2026 23:01:04 +0000 Subject: [PATCH 07/11] Add notebook path regression guard --- tests/test_contract.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/test_contract.py b/tests/test_contract.py index e58fc1b..d4e2a68 100644 --- a/tests/test_contract.py +++ b/tests/test_contract.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json import re from pathlib import Path @@ -9,6 +10,7 @@ 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", @@ -150,3 +152,32 @@ def test_data_directory_does_not_contain_committed_outputs() -> None: "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}" + ) From cbc1c93d199d9e93d80a12dc1130f3f5d1657fe6 Mon Sep 17 00:00:00 2001 From: Zio Gabber <78922322+Gabrymi93@users.noreply.github.com> Date: Tue, 3 Mar 2026 10:09:22 +0000 Subject: [PATCH 08/11] Clarify toolkit workflow in template docs --- README.md | 8 ++++++++ WORKFLOW.md | 4 +++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 3788222..430bf71 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,14 @@ I notebook del template usano anche: 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 diff --git a/WORKFLOW.md b/WORKFLOW.md index 6091db9..77127ab 100644 --- a/WORKFLOW.md +++ b/WORKFLOW.md @@ -35,7 +35,9 @@ GitHub resta il posto dove deve restare la traccia utile. 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. usa i notebook per ispezionare RAW, CLEAN, MART e QA +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 ## Maintainers From 190d0bf652132ac7034d0d743490e17a817d66ff Mon Sep 17 00:00:00 2001 From: Zio Gabber <78922322+Gabrymi93@users.noreply.github.com> Date: Tue, 3 Mar 2026 20:41:45 +0000 Subject: [PATCH 09/11] docs: make dataset question model explicit --- README.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/README.md b/README.md index 430bf71..eae6b64 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,15 @@ Esempi: - Dove si osservano miglioramenti o peggioramenti? - Il mio territorio e sopra o sotto la media? +Questa repo dovrebbe avere **una domanda civica principale**. + +Dallo stesso dataset possono nascere anche altre domande utili, ma vanno tenute distinte: + +- 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 @@ -58,9 +67,19 @@ 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 @@ -81,6 +100,9 @@ Qui trovi il minimo necessario per far partire un progetto concreto: - `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`. From ac1c70b89e61c2ed2416b5afb6d4f0e30d413cfd Mon Sep 17 00:00:00 2001 From: Zio Gabber <78922322+Gabrymi93@users.noreply.github.com> Date: Tue, 3 Mar 2026 21:13:44 +0000 Subject: [PATCH 10/11] ci: make smoke workflow self-contained --- .github/workflows/ci.yml | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 36f5faf..7933694 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,13 +62,9 @@ jobs: python -m pip install pytest pyyaml if [ -n "${TOOLKIT_PIP_PACKAGE}" ]; then python -m pip install "${TOOLKIT_PIP_PACKAGE}" - elif [ -d "./toolkit" ]; then - python -m pip install -e "./toolkit" - elif [ -d "../toolkit" ]; then - python -m pip install -e "../toolkit" else - echo "Unable to install toolkit: set TOOLKIT_PIP_PACKAGE or provide ./toolkit or ../toolkit" >&2 - exit 1 + git clone --depth 1 https://github.com/dataciviclab/toolkit.git .toolkit-src + python -m pip install -e ./.toolkit-src fi - name: Export DCL_ROOT From c1e9845ecee1e390bdd85bf3e3e40a1cb90600e4 Mon Sep 17 00:00:00 2001 From: Zio Gabber <78922322+Gabrymi93@users.noreply.github.com> Date: Tue, 3 Mar 2026 21:46:55 +0000 Subject: [PATCH 11/11] docs: add dataset publishing rhythm guidance --- README.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/README.md b/README.md index eae6b64..9298498 100644 --- a/README.md +++ b/README.md @@ -180,3 +180,21 @@ py scripts/publish_to_drive.py --config dataset.yml --drive-root "G:\\DataCivicL ``` 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.