opensituas 0.1.0__py3-none-any.whl

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.
opensituas/__init__.py ADDED
@@ -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"
opensituas/base.py ADDED
@@ -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]})")
opensituas/catalog.py ADDED
@@ -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))