opensituas 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,107 @@
1
+ Metadata-Version: 2.3
2
+ Name: opensituas
3
+ Version: 0.1.0
4
+ Summary: CLI per il SITUAS (Sistema Informativo Territoriale delle Unità Amministrative e Statistiche, ISTAT), pensata per l'orchestrazione da parte di agenti AI
5
+ Keywords: situas,istat,comuni,province,regioni,open-data,cli,api
6
+ Author: Andrea Borruso
7
+ Author-email: Andrea Borruso <aborruso@gmail.com>
8
+ License: MIT
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Environment :: Console
11
+ Classifier: Intended Audience :: Science/Research
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Natural Language :: Italian
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Scientific/Engineering :: GIS
19
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
+ Requires-Dist: httpx>=0.28
21
+ Requires-Dist: typer>=0.15
22
+ Requires-Dist: rich>=13.9
23
+ Requires-Python: >=3.12
24
+ Description-Content-Type: text/markdown
25
+
26
+ opensituas
27
+
28
+ CLI per il **SITUAS** — Sistema Informativo Territoriale delle Unità Amministrative e
29
+ Statistiche (ISTAT) — progettata per essere orchestrata da un agente AI: output
30
+ strutturato (`table`/`json`/`csv`), errori auto-esplicativi, catalogo cachato.
31
+
32
+ opensituas è un'idea di [Alice Corona](https://alice-corona.eu/).
33
+
34
+ ## Installazione
35
+
36
+ ```bash
37
+ uv tool install opensituas # CLI a livello di sistema
38
+ # oppure, in sviluppo:
39
+ uv pip install -e .
40
+ ```
41
+
42
+ ## Cosa espone SITUAS
43
+
44
+ Due tipi di interrogazione, entrambi coperti:
45
+
46
+ 1. **Catalogo report** — 74 "microservizi" (report) su unità amministrative e statistiche
47
+ (ripartizioni, regioni, province/uts, comuni, sistemi locali del lavoro, ...). Ogni report
48
+ ha un periodo di validità e restituisce una tabella alla data scelta.
49
+ 2. **Query territoriali interattive** — storia delle variazioni di un'unità nel tempo
50
+ (`storia`) e ricerca dei codici/denominazioni Istat con periodi di validità (`cerca-codice`).
51
+
52
+ ## Comandi
53
+
54
+ ```bash
55
+ # --- catalogo ---
56
+ opensituas catalog # tutti i report (pfun, titolo, validità, ...)
57
+ opensituas catalog "Comuni" --ambito "Unità amministrative"
58
+ opensituas --output json catalog "Ripartizioni" | jq '.[].pfun'
59
+ opensituas catalog --refresh # rilegge dal gateway
60
+
61
+ opensituas info 50 # descrizione + colonne del report 50
62
+ opensituas get 61 # report DATA: dato più recente (fine validità)
63
+ opensituas get 61 --output csv --out comuni.csv
64
+ opensituas count 98 --from 17/03/1861 --to 31/05/2026
65
+ opensituas get 61 --date 01/01/2020 # snapshot storico; errore chiaro se fuori validità
66
+
67
+ # --- query territoriali ---
68
+ opensituas storia comune Roma # storia delle variazioni del comune
69
+ opensituas storia comune 058091 --dettaglio # + provvedimento e unità coinvolte
70
+ opensituas storia regione Lazio
71
+ opensituas storia provincia Roma
72
+ opensituas cerca-codice comune Roma # codici/denominazioni + validità
73
+ ```
74
+
75
+ ## Output per agenti
76
+
77
+ Il flag globale `--output` (`-o`) vale `table` (default), `json` o `csv`. In `json`/`csv`
78
+ l'output su stdout è pulito (i messaggi diagnostici vanno su stderr), pronto per `jq`:
79
+
80
+ ```bash
81
+ opensituas -o json storia comune Roma | jq '.storia[] | {data: ."data evento", evento: .variazione}'
82
+ opensituas -o csv get 50 > comuni.csv
83
+ ```
84
+
85
+ ## Come funziona (in breve)
86
+
87
+ - **Catalogo**: ottenuto via gateway POST (`get_elenco_microservizi`), cachato in
88
+ `~/.cache/opensituas/catalog.json` (TTL 7 giorni), con snapshot incluso come fallback.
89
+ - **Dati report**: endpoint pubblici `situas-servizi.istat.it/publish` (`reportspooljson`,
90
+ `reportspooljsoncount`, `anagrafica_report_metadato_web`). Per i report `DATA`, senza
91
+ `--date` la CLI usa la **fine validità** (dato più recente); i link del catalogo
92
+ porterebbero invece la data di *inizio* (snapshot più vecchio). `--date`/`--from`/`--to`
93
+ sostituiscono il parametro data.
94
+ - **Query territoriali**: servizi `var_get_ua_*` (storia) e `ricercacodice_*` via gateway.
95
+
96
+ Dettagli in [docs/architecture.md](docs/architecture.md). Per orchestrazione da agente vedi
97
+ [skills/situas-explorer/SKILL.md](skills/situas-explorer/SKILL.md).
98
+
99
+ ## Variabili d'ambiente
100
+
101
+ | Variabile | Default | Descrizione |
102
+ |---|---|---|
103
+ | `OPENSITUAS_TIMEOUT` | `60` | Timeout richieste (secondi) |
104
+
105
+ ## Licenza
106
+
107
+ MIT
@@ -0,0 +1,82 @@
1
+ opensituas
2
+
3
+ CLI per il **SITUAS** — Sistema Informativo Territoriale delle Unità Amministrative e
4
+ Statistiche (ISTAT) — progettata per essere orchestrata da un agente AI: output
5
+ strutturato (`table`/`json`/`csv`), errori auto-esplicativi, catalogo cachato.
6
+
7
+ opensituas è un'idea di [Alice Corona](https://alice-corona.eu/).
8
+
9
+ ## Installazione
10
+
11
+ ```bash
12
+ uv tool install opensituas # CLI a livello di sistema
13
+ # oppure, in sviluppo:
14
+ uv pip install -e .
15
+ ```
16
+
17
+ ## Cosa espone SITUAS
18
+
19
+ Due tipi di interrogazione, entrambi coperti:
20
+
21
+ 1. **Catalogo report** — 74 "microservizi" (report) su unità amministrative e statistiche
22
+ (ripartizioni, regioni, province/uts, comuni, sistemi locali del lavoro, ...). Ogni report
23
+ ha un periodo di validità e restituisce una tabella alla data scelta.
24
+ 2. **Query territoriali interattive** — storia delle variazioni di un'unità nel tempo
25
+ (`storia`) e ricerca dei codici/denominazioni Istat con periodi di validità (`cerca-codice`).
26
+
27
+ ## Comandi
28
+
29
+ ```bash
30
+ # --- catalogo ---
31
+ opensituas catalog # tutti i report (pfun, titolo, validità, ...)
32
+ opensituas catalog "Comuni" --ambito "Unità amministrative"
33
+ opensituas --output json catalog "Ripartizioni" | jq '.[].pfun'
34
+ opensituas catalog --refresh # rilegge dal gateway
35
+
36
+ opensituas info 50 # descrizione + colonne del report 50
37
+ opensituas get 61 # report DATA: dato più recente (fine validità)
38
+ opensituas get 61 --output csv --out comuni.csv
39
+ opensituas count 98 --from 17/03/1861 --to 31/05/2026
40
+ opensituas get 61 --date 01/01/2020 # snapshot storico; errore chiaro se fuori validità
41
+
42
+ # --- query territoriali ---
43
+ opensituas storia comune Roma # storia delle variazioni del comune
44
+ opensituas storia comune 058091 --dettaglio # + provvedimento e unità coinvolte
45
+ opensituas storia regione Lazio
46
+ opensituas storia provincia Roma
47
+ opensituas cerca-codice comune Roma # codici/denominazioni + validità
48
+ ```
49
+
50
+ ## Output per agenti
51
+
52
+ Il flag globale `--output` (`-o`) vale `table` (default), `json` o `csv`. In `json`/`csv`
53
+ l'output su stdout è pulito (i messaggi diagnostici vanno su stderr), pronto per `jq`:
54
+
55
+ ```bash
56
+ opensituas -o json storia comune Roma | jq '.storia[] | {data: ."data evento", evento: .variazione}'
57
+ opensituas -o csv get 50 > comuni.csv
58
+ ```
59
+
60
+ ## Come funziona (in breve)
61
+
62
+ - **Catalogo**: ottenuto via gateway POST (`get_elenco_microservizi`), cachato in
63
+ `~/.cache/opensituas/catalog.json` (TTL 7 giorni), con snapshot incluso come fallback.
64
+ - **Dati report**: endpoint pubblici `situas-servizi.istat.it/publish` (`reportspooljson`,
65
+ `reportspooljsoncount`, `anagrafica_report_metadato_web`). Per i report `DATA`, senza
66
+ `--date` la CLI usa la **fine validità** (dato più recente); i link del catalogo
67
+ porterebbero invece la data di *inizio* (snapshot più vecchio). `--date`/`--from`/`--to`
68
+ sostituiscono il parametro data.
69
+ - **Query territoriali**: servizi `var_get_ua_*` (storia) e `ricercacodice_*` via gateway.
70
+
71
+ Dettagli in [docs/architecture.md](docs/architecture.md). Per orchestrazione da agente vedi
72
+ [skills/situas-explorer/SKILL.md](skills/situas-explorer/SKILL.md).
73
+
74
+ ## Variabili d'ambiente
75
+
76
+ | Variabile | Default | Descrizione |
77
+ |---|---|---|
78
+ | `OPENSITUAS_TIMEOUT` | `60` | Timeout richieste (secondi) |
79
+
80
+ ## Licenza
81
+
82
+ MIT
@@ -0,0 +1,49 @@
1
+ [project]
2
+ name = "opensituas"
3
+ version = "0.1.0"
4
+ description = "CLI per il SITUAS (Sistema Informativo Territoriale delle Unità Amministrative e Statistiche, ISTAT), pensata per l'orchestrazione da parte di agenti AI"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Andrea Borruso", email = "aborruso@gmail.com" }
8
+ ]
9
+ license = { text = "MIT" }
10
+ requires-python = ">=3.12"
11
+ keywords = ["situas", "istat", "comuni", "province", "regioni", "open-data", "cli", "api"]
12
+ classifiers = [
13
+ "Development Status :: 4 - Beta",
14
+ "Environment :: Console",
15
+ "Intended Audience :: Science/Research",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Natural Language :: Italian",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Programming Language :: Python :: 3.13",
22
+ "Topic :: Scientific/Engineering :: GIS",
23
+ "Topic :: Software Development :: Libraries :: Python Modules",
24
+ ]
25
+ dependencies = [
26
+ "httpx>=0.28",
27
+ "typer>=0.15",
28
+ "rich>=13.9",
29
+ ]
30
+
31
+ [project.scripts]
32
+ opensituas = "opensituas:main"
33
+
34
+ [dependency-groups]
35
+ dev = ["pytest>=8", "ruff>=0.11"]
36
+
37
+ [build-system]
38
+ requires = ["uv_build>=0.9.7,<0.10.0"]
39
+ build-backend = "uv_build"
40
+
41
+ [tool.uv.build-backend]
42
+ module-name = "opensituas"
43
+ module-root = "src"
44
+
45
+ [tool.pytest.ini_options]
46
+ testpaths = ["tests"]
47
+
48
+ [tool.ruff.lint]
49
+ ignore = ["E402"]
@@ -0,0 +1,8 @@
1
+ """opensituas — CLI per il SITUAS (ISTAT), orchestrabile da agenti AI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .cli import app, main
6
+
7
+ __all__ = ["app", "main"]
8
+ __version__ = "0.1.0"
@@ -0,0 +1,106 @@
1
+ """Strato HTTP per le API SITUAS.
2
+
3
+ Due basi distinte:
4
+
5
+ - **Gateway** (`GATEWAY_URL`): proxy POST verso i servizi Oracle ORDS. Riceve
6
+ `{"url": "<nome_servizio>?<query>"}`. Serve per il catalogo (`get_elenco_microservizi`)
7
+ e per tutte le query interattive (`ricercacodice_*`, `var_get_ua_*`).
8
+ - **Publish** (`PUBLISH_BASE`): endpoint pubblici GET per i dati dei report
9
+ (`reportspooljson`, `reportspooljsoncount`, `anagrafica_report_metadato_web`).
10
+
11
+ Particolarità scoperte sul campo:
12
+
13
+ - I servizi `ricercacodice_*` rispondono vuoto senza un header `Referer` browser-like:
14
+ lo inviamo su **tutte** le chiamate al gateway.
15
+ - L'envelope JSON varia: `resultset` (reportspooljson, ricercacodice) oppure `items`
16
+ (var_get_ua_*, catalogo). `rows()` normalizza entrambi.
17
+ - Gli errori applicativi arrivano con HTTP 200, come `{"resultset":[{"ERRCODE":"..."}]}`
18
+ oppure `{"message": "..."}`: vanno letti dal payload, non dallo status code.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+
25
+ import httpx
26
+
27
+ GATEWAY_URL = "https://situas.istat.it/ShibO2Module/api/Report/ReportByUrl"
28
+ PUBLISH_BASE = "https://situas-servizi.istat.it/publish"
29
+
30
+ DEFAULT_TIMEOUT = 60.0
31
+
32
+ _HEADERS = {
33
+ "Accept": "application/json, text/plain, */*",
34
+ "Content-Type": "application/json",
35
+ "Referer": "https://situas.istat.it/web/",
36
+ "language": "IT",
37
+ }
38
+
39
+
40
+ class SituasError(Exception):
41
+ """Errore applicativo restituito dalle API SITUAS."""
42
+
43
+
44
+ def _client(timeout: float) -> httpx.Client:
45
+ return httpx.Client(timeout=timeout, headers=_HEADERS, follow_redirects=True)
46
+
47
+
48
+ def gateway(service: str, timeout: float = DEFAULT_TIMEOUT) -> dict:
49
+ """Invoca un servizio ORDS tramite il gateway report.
50
+
51
+ `service` è il nome del servizio con la sua querystring, es.
52
+ ``"ricercacodice_criterio_lov?haRuolo&ptipounit=1"``.
53
+ """
54
+ try:
55
+ with _client(timeout) as client:
56
+ resp = client.post(GATEWAY_URL, content=json.dumps({"url": service}))
57
+ resp.raise_for_status()
58
+ except httpx.HTTPError as exc:
59
+ raise SituasError(f"Gateway SITUAS irraggiungibile: {exc}") from exc
60
+ return _parse(resp, service)
61
+
62
+
63
+ def publish_get(url: str, timeout: float = DEFAULT_TIMEOUT) -> dict:
64
+ """GET di un URL publish già formato (es. uno SPOOL_LINK del catalogo)."""
65
+ try:
66
+ with _client(timeout) as client:
67
+ resp = client.get(url)
68
+ resp.raise_for_status()
69
+ except httpx.HTTPError as exc:
70
+ raise SituasError(f"Endpoint SITUAS irraggiungibile: {exc}") from exc
71
+ return _parse(resp, url)
72
+
73
+
74
+ def rows(data: object) -> list[dict]:
75
+ """Estrae la lista di record da un payload SITUAS (envelope `resultset` o `items`)."""
76
+ if isinstance(data, dict):
77
+ for key in ("resultset", "items"):
78
+ value = data.get(key)
79
+ if isinstance(value, list):
80
+ return value
81
+ if isinstance(data, list):
82
+ return data
83
+ return []
84
+
85
+
86
+ def _parse(resp: httpx.Response, ctx: str) -> dict:
87
+ # strict=False: alcuni metadati SITUAS contengono \r\n grezzi dentro le stringhe
88
+ # (caratteri di controllo non escapati), tecnicamente JSON non valido ma tollerabile.
89
+ try:
90
+ data = json.loads(resp.text, strict=False)
91
+ except (json.JSONDecodeError, ValueError) as exc:
92
+ raise SituasError(f"Risposta non-JSON da SITUAS ({ctx[:80]})") from exc
93
+ _check_error(data, ctx)
94
+ return data
95
+
96
+
97
+ def _check_error(data: object, ctx: str) -> None:
98
+ if isinstance(data, dict):
99
+ status = data.get("status")
100
+ if isinstance(status, int) and status >= 400:
101
+ raise SituasError(f"{data.get('title', 'Errore')} (status {status})")
102
+ if "message" in data and "resultset" not in data and "items" not in data:
103
+ raise SituasError(str(data["message"]))
104
+ recs = rows(data)
105
+ if len(recs) == 1 and isinstance(recs[0], dict) and "ERRCODE" in recs[0]:
106
+ raise SituasError(f"{recs[0]['ERRCODE']} ({ctx[:80]})")
@@ -0,0 +1,139 @@
1
+ """Catalogo dei report SITUAS (i 74 "microservizi").
2
+
3
+ Il catalogo si ottiene SOLO via gateway (`get_elenco_microservizi`); il base publish
4
+ restituisce 401. Viene cachato in ``~/.cache/opensituas/catalog.json`` (TTL 7 giorni),
5
+ con fallback allo snapshot incluso nel package per il primo avvio offline.
6
+
7
+ Ogni voce espone link pre-compilati con date valide (`SPOOL_LINK`, `COUNT_LINK`,
8
+ `INFO_LINK`): sono la **fonte di verità** per la data. Per cambiare data si sostituisce
9
+ solo il parametro nel link (vedi `apply_date`), senza ri-derivarla dalla validità.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import time
16
+ from importlib.resources import files
17
+ from pathlib import Path
18
+ from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
19
+
20
+ from . import base
21
+
22
+ _CACHE_DIR = Path.home() / ".cache" / "opensituas"
23
+ _CACHE_FILE = _CACHE_DIR / "catalog.json"
24
+ _TTL = 7 * 24 * 3600 # 7 giorni
25
+ _SERVICE = "get_elenco_microservizi"
26
+
27
+
28
+ def _bundled() -> list[dict]:
29
+ raw = files("opensituas").joinpath("catalog_snapshot.json").read_text("utf-8")
30
+ return json.loads(raw).get("items", [])
31
+
32
+
33
+ def load_catalog(refresh: bool = False) -> list[dict]:
34
+ """Restituisce il catalogo (lista di voci report).
35
+
36
+ Ordine: cache fresca → gateway → cache stantia → snapshot incluso.
37
+ """
38
+ if not refresh and _CACHE_FILE.exists():
39
+ if time.time() - _CACHE_FILE.stat().st_mtime < _TTL:
40
+ return json.loads(_CACHE_FILE.read_text("utf-8"))
41
+ try:
42
+ items = base.gateway(_SERVICE).get("items", [])
43
+ if items:
44
+ _CACHE_DIR.mkdir(parents=True, exist_ok=True)
45
+ _CACHE_FILE.write_text(json.dumps(items, ensure_ascii=False), "utf-8")
46
+ return items
47
+ except base.SituasError:
48
+ pass
49
+ if _CACHE_FILE.exists():
50
+ return json.loads(_CACHE_FILE.read_text("utf-8"))
51
+ return _bundled()
52
+
53
+
54
+ def get_entry(pfun: int | str, catalog: list[dict] | None = None) -> dict:
55
+ """Voce del catalogo per id report (`pfun`)."""
56
+ catalog = catalog if catalog is not None else load_catalog()
57
+ pid = str(pfun)
58
+ for entry in catalog:
59
+ if str(entry.get("Id report")) == pid:
60
+ return entry
61
+ raise base.SituasError(f"Report pfun={pfun} non presente nel catalogo")
62
+
63
+
64
+ def filter_catalog(
65
+ catalog: list[dict],
66
+ keyword: str | None = None,
67
+ ambito: str | None = None,
68
+ unita: str | None = None,
69
+ ) -> list[dict]:
70
+ """Filtra le voci per parola chiave (sul titolo), ambito e unità territoriale."""
71
+
72
+ def match(entry: dict) -> bool:
73
+ if keyword and keyword.lower() not in entry.get("Titolo report", "").lower():
74
+ return False
75
+ if ambito and ambito.lower() not in entry.get("Ambito territoriale", "").lower():
76
+ return False
77
+ if unita and unita.lower() not in entry.get("Unità territoriale", "").lower():
78
+ return False
79
+ return True
80
+
81
+ return [e for e in catalog if match(e)]
82
+
83
+
84
+ def is_range(entry: dict) -> bool:
85
+ """True se il report è a intervallo (PERIODO, parametri pdatada/pdataa)."""
86
+ return "pdatada" in entry.get("parametri necessari", "")
87
+
88
+
89
+ def validity_end(entry: dict) -> str | None:
90
+ """Data di fine validità (DD/MM/YYYY) dal campo catalogo, o None se assente."""
91
+ val = entry.get("Inizio/fine validità report", "")
92
+ if " - " in val:
93
+ return val.split(" - ", 1)[1].strip() or None
94
+ return None
95
+
96
+
97
+ def apply_date(
98
+ link: str,
99
+ date: str | None = None,
100
+ date_from: str | None = None,
101
+ date_to: str | None = None,
102
+ default_date: str | None = None,
103
+ ) -> str:
104
+ """Sostituisce nel link SOLO i parametri data (`pdata` o `pdatada`/`pdataa`).
105
+
106
+ Il resto del link (pre-validato dal catalogo) resta intatto. Per i report a data
107
+ singola (DATA), se non si passa ``date`` viene usata ``default_date`` (di norma la
108
+ fine validità, cioè il dato più recente): i link del catalogo portano invece la data
109
+ di *inizio* validità, che darebbe lo snapshot più vecchio. Solleva errore se si
110
+ mescola `--date` con `--from/--to` o si usa il pattern sbagliato per il tipo report.
111
+ """
112
+ if date and (date_from or date_to):
113
+ raise base.SituasError("Usa --date oppure --from/--to, non entrambi")
114
+
115
+ parts = urlsplit(link)
116
+ query = dict(parse_qsl(parts.query, keep_blank_values=True))
117
+ link_is_range = "pdatada" in query or "pdataa" in query
118
+
119
+ if link_is_range:
120
+ if date:
121
+ raise base.SituasError(
122
+ "Report a intervallo (PERIODO): usa --from/--to, non --date"
123
+ )
124
+ if date_from:
125
+ query["pdatada"] = date_from
126
+ if date_to:
127
+ query["pdataa"] = date_to
128
+ else:
129
+ if date_from or date_to:
130
+ raise base.SituasError(
131
+ "Report a data singola (DATA): usa --date, non --from/--to"
132
+ )
133
+ if date:
134
+ query["pdata"] = date
135
+ elif default_date:
136
+ query["pdata"] = default_date
137
+
138
+ new_query = urlencode(query, safe="/")
139
+ return urlunsplit((parts.scheme, parts.netloc, parts.path, new_query, parts.fragment))