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 +8 -0
- opensituas/base.py +106 -0
- opensituas/catalog.py +139 -0
- opensituas/catalog_snapshot.json +966 -0
- opensituas/cli.py +275 -0
- opensituas/output.py +94 -0
- opensituas/territorial.py +226 -0
- opensituas-0.1.0.dist-info/METADATA +107 -0
- opensituas-0.1.0.dist-info/RECORD +11 -0
- opensituas-0.1.0.dist-info/WHEEL +4 -0
- opensituas-0.1.0.dist-info/entry_points.txt +3 -0
opensituas/__init__.py
ADDED
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))
|