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.
- opensituas-0.1.0/PKG-INFO +107 -0
- opensituas-0.1.0/README.md +82 -0
- opensituas-0.1.0/pyproject.toml +49 -0
- opensituas-0.1.0/src/opensituas/__init__.py +8 -0
- opensituas-0.1.0/src/opensituas/base.py +106 -0
- opensituas-0.1.0/src/opensituas/catalog.py +139 -0
- opensituas-0.1.0/src/opensituas/catalog_snapshot.json +966 -0
- opensituas-0.1.0/src/opensituas/cli.py +275 -0
- opensituas-0.1.0/src/opensituas/output.py +94 -0
- opensituas-0.1.0/src/opensituas/territorial.py +226 -0
|
@@ -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,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))
|