openverto 0.1.1__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.
- openverto-0.1.1/PKG-INFO +144 -0
- openverto-0.1.1/README.md +118 -0
- openverto-0.1.1/pyproject.toml +51 -0
- openverto-0.1.1/src/openverto/__init__.py +55 -0
- openverto-0.1.1/src/openverto/base.py +124 -0
- openverto-0.1.1/src/openverto/cache.py +126 -0
- openverto-0.1.1/src/openverto/cli.py +536 -0
- openverto-0.1.1/src/openverto/detect.py +121 -0
- openverto-0.1.1/src/openverto/geo.py +159 -0
- openverto-0.1.1/src/openverto/refdata.py +74 -0
- openverto-0.1.1/src/openverto/systems.py +44 -0
- openverto-0.1.1/src/openverto/transform.py +283 -0
openverto-0.1.1/PKG-INFO
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: openverto
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Python CLI and library for the official IGM Verto Online coordinate transforms between Italian reference systems
|
|
5
|
+
Keywords: igm,verto,coordinate,epsg,crs,italy,rdn2008,gauss-boaga,open-data
|
|
6
|
+
Author: Andrea Borruso
|
|
7
|
+
Author-email: Andrea Borruso <aborruso@gmail.com>
|
|
8
|
+
License: MIT
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Intended Audience :: Science/Research
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Topic :: Scientific/Engineering :: GIS
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
|
+
Requires-Dist: httpx>=0.28.1
|
|
19
|
+
Requires-Dist: typer>=0.24.1
|
|
20
|
+
Requires-Dist: rich>=14.0.0
|
|
21
|
+
Requires-Dist: platformdirs>=4.0.0
|
|
22
|
+
Requires-Python: >=3.12
|
|
23
|
+
Project-URL: Homepage, https://github.com/ondata/openverto
|
|
24
|
+
Project-URL: Issues, https://github.com/ondata/openverto/issues
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# openverto
|
|
28
|
+
|
|
29
|
+
> Tool sperimentale — aiutaci a testarlo [aprendo issue](https://github.com/ondata/openverto/issues) o inviando feedback.
|
|
30
|
+
|
|
31
|
+
CLI e libreria Python per il servizio ufficiale **IGM Verto Online**: trasforma coordinate tra tutti i sistemi di riferimento italiani (Roma40, ED50, IGM95, ETRS89, RDN2008) **senza installare le griglie NTv2**, appoggiandosi al servizio autorevole dell'Istituto Geografico Militare.
|
|
32
|
+
|
|
33
|
+
Pensato per essere **orchestrato da agenti AI**: output `--json`/`--jsonl`/`--csv`, non interattivo, pipeable, read-only. L'intelligenza sugli EPSG (`inspect`, `detect`, `targets`) è completamente **offline**.
|
|
34
|
+
|
|
35
|
+
## Installazione
|
|
36
|
+
|
|
37
|
+
Come strumento CLI (consigliato):
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
uv tool install openverto
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Come libreria:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
uv add openverto # oppure: pip install openverto
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Avvio rapido (CLI)
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
# i 20 sistemi di riferimento supportati (cache offline dopo la prima volta)
|
|
53
|
+
openverto systems
|
|
54
|
+
|
|
55
|
+
# converti una coordinata Roma40 -> RDN2008 (lon lat)
|
|
56
|
+
openverto convert --from 4265 --to 6706 12.4924 41.8902
|
|
57
|
+
|
|
58
|
+
# asse, fuso e false easting prima di una conversione proiettata
|
|
59
|
+
openverto inspect 3003
|
|
60
|
+
|
|
61
|
+
# converti un CSV di punti Gauss-Boaga (colonne est/nord) in RDN2008/TM32
|
|
62
|
+
openverto batch catasto.csv --from 3003 --to 6707 --e-col est --n-col nord --out out.csv
|
|
63
|
+
|
|
64
|
+
# riproietta le geometrie di un GeoJSON
|
|
65
|
+
openverto geojson aree.geojson --from 4230 --to 6706 --out out.geojson
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Formati di output
|
|
69
|
+
|
|
70
|
+
Globale, con `-o/--output`:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
openverto systems # tabella (default in terminale)
|
|
74
|
+
openverto -o json systems # JSON
|
|
75
|
+
openverto -o jsonl convert ... # JSON Lines: un oggetto compatto per riga (ideale per coordinate)
|
|
76
|
+
openverto -o csv convert ... # CSV
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
`jsonl` è il formato più adatto allo streaming di coordinate verso `jq` o pipeline.
|
|
80
|
+
|
|
81
|
+
## Comandi
|
|
82
|
+
|
|
83
|
+
| Comando | Descrizione |
|
|
84
|
+
|---|---|
|
|
85
|
+
| `systems` | Elenco dei sistemi di riferimento supportati (EPSG + descrizione) |
|
|
86
|
+
| `convert` | Converte una o più coordinate (`e n`, o `e,n` da stdin) |
|
|
87
|
+
| `batch` | Converte un CSV in CSV o GeoJSON (auto-chunk a 32000, `--skip-invalid`) |
|
|
88
|
+
| `geojson` | Riproietta le geometrie di un file GeoJSON |
|
|
89
|
+
| `inspect` | Famiglia datum, ordine assi, unità, fuso, false easting di un EPSG |
|
|
90
|
+
| `detect` | Indovina il sistema di origine di una coordinata dalla sua magnitudine |
|
|
91
|
+
| `targets` | Destinazioni di conversione valide per un EPSG (datum diverso) |
|
|
92
|
+
| `roundtrip` | Converte A→B→A e riporta l'errore residuo del datum |
|
|
93
|
+
| `cache` | Ispeziona o svuota la cache offline |
|
|
94
|
+
| `doctor` | Verifica la connettività al servizio |
|
|
95
|
+
|
|
96
|
+
### Funzioni distintive
|
|
97
|
+
|
|
98
|
+
- **`roundtrip`** — certifica che una catena di datum sia lossless entro tolleranza prima di pubblicare dati.
|
|
99
|
+
- **`detect`** — recupera l'EPSG reale di un dataset etichettato vagamente ("UTM", "Gauss-Boaga").
|
|
100
|
+
- **`inspect` / `targets`** — disambigua sistemi simili (3003 vs 3004) ed evita destinazioni con lo stesso datum (rifiutate dal servizio).
|
|
101
|
+
- **cache offline** — replay riproducibile delle conversioni in CI o pipeline offline.
|
|
102
|
+
|
|
103
|
+
## Uso come libreria
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
import openverto as ov
|
|
107
|
+
|
|
108
|
+
# elenco sistemi
|
|
109
|
+
ov.systems() # [{"epsg": 4265, "descrizione": "Monte Mario"}, ...]
|
|
110
|
+
|
|
111
|
+
# conversione (e=est/lon, n=nord/lat); auto-chunk + cache
|
|
112
|
+
ov.convert([(12.4924, 41.8902)], 4265, 6706)
|
|
113
|
+
# -> [(12.4921961827, 41.8908506304)]
|
|
114
|
+
|
|
115
|
+
# intelligenza offline (nessuna chiamata di rete)
|
|
116
|
+
ov.inspect(3003)
|
|
117
|
+
ov.targets(3003)
|
|
118
|
+
ov.detect(2300000, 4640000) # {"kind": "projected", "candidate_epsg": [3004], ...}
|
|
119
|
+
|
|
120
|
+
# salta le coordinate fuori griglia isolandole per bisezione
|
|
121
|
+
results, skipped = ov.convert_skipping(coords, 3003, 6707)
|
|
122
|
+
|
|
123
|
+
# errore residuo di una catena di datum
|
|
124
|
+
ov.roundtrip([(1500000, 4640000)], 3003, 6707)
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Autenticazione
|
|
128
|
+
|
|
129
|
+
Nessuna. Il servizio è libero e gratuito; i campi `utente`/`chiave` richiesti dall'API sono segnaposto, riempiti automaticamente.
|
|
130
|
+
|
|
131
|
+
## Note tecniche
|
|
132
|
+
|
|
133
|
+
- Le coordinate geografiche sono **sempre in gradi sessadecimali**.
|
|
134
|
+
- Le conversioni **tra sistemi con lo stesso datum** non sono ammesse dal servizio (vedi `targets`).
|
|
135
|
+
- Il servizio accetta al massimo **32000 coordinate** per richiesta; `batch` esegue automaticamente il chunking.
|
|
136
|
+
- La griglia IGM copre l'Italia e i mari circostanti: coordinate fuori copertura vengono rifiutate (usa `detect`/`inspect` per controllare assi e EPSG).
|
|
137
|
+
|
|
138
|
+
## Crediti
|
|
139
|
+
|
|
140
|
+
Servizio dati: [IGM Verto Online](https://igmi.esercito.difesa.it/), Istituto Geografico Militare.
|
|
141
|
+
|
|
142
|
+
## Licenza
|
|
143
|
+
|
|
144
|
+
MIT.
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# openverto
|
|
2
|
+
|
|
3
|
+
> Tool sperimentale — aiutaci a testarlo [aprendo issue](https://github.com/ondata/openverto/issues) o inviando feedback.
|
|
4
|
+
|
|
5
|
+
CLI e libreria Python per il servizio ufficiale **IGM Verto Online**: trasforma coordinate tra tutti i sistemi di riferimento italiani (Roma40, ED50, IGM95, ETRS89, RDN2008) **senza installare le griglie NTv2**, appoggiandosi al servizio autorevole dell'Istituto Geografico Militare.
|
|
6
|
+
|
|
7
|
+
Pensato per essere **orchestrato da agenti AI**: output `--json`/`--jsonl`/`--csv`, non interattivo, pipeable, read-only. L'intelligenza sugli EPSG (`inspect`, `detect`, `targets`) è completamente **offline**.
|
|
8
|
+
|
|
9
|
+
## Installazione
|
|
10
|
+
|
|
11
|
+
Come strumento CLI (consigliato):
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
uv tool install openverto
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Come libreria:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
uv add openverto # oppure: pip install openverto
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Avvio rapido (CLI)
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
# i 20 sistemi di riferimento supportati (cache offline dopo la prima volta)
|
|
27
|
+
openverto systems
|
|
28
|
+
|
|
29
|
+
# converti una coordinata Roma40 -> RDN2008 (lon lat)
|
|
30
|
+
openverto convert --from 4265 --to 6706 12.4924 41.8902
|
|
31
|
+
|
|
32
|
+
# asse, fuso e false easting prima di una conversione proiettata
|
|
33
|
+
openverto inspect 3003
|
|
34
|
+
|
|
35
|
+
# converti un CSV di punti Gauss-Boaga (colonne est/nord) in RDN2008/TM32
|
|
36
|
+
openverto batch catasto.csv --from 3003 --to 6707 --e-col est --n-col nord --out out.csv
|
|
37
|
+
|
|
38
|
+
# riproietta le geometrie di un GeoJSON
|
|
39
|
+
openverto geojson aree.geojson --from 4230 --to 6706 --out out.geojson
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Formati di output
|
|
43
|
+
|
|
44
|
+
Globale, con `-o/--output`:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
openverto systems # tabella (default in terminale)
|
|
48
|
+
openverto -o json systems # JSON
|
|
49
|
+
openverto -o jsonl convert ... # JSON Lines: un oggetto compatto per riga (ideale per coordinate)
|
|
50
|
+
openverto -o csv convert ... # CSV
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`jsonl` è il formato più adatto allo streaming di coordinate verso `jq` o pipeline.
|
|
54
|
+
|
|
55
|
+
## Comandi
|
|
56
|
+
|
|
57
|
+
| Comando | Descrizione |
|
|
58
|
+
|---|---|
|
|
59
|
+
| `systems` | Elenco dei sistemi di riferimento supportati (EPSG + descrizione) |
|
|
60
|
+
| `convert` | Converte una o più coordinate (`e n`, o `e,n` da stdin) |
|
|
61
|
+
| `batch` | Converte un CSV in CSV o GeoJSON (auto-chunk a 32000, `--skip-invalid`) |
|
|
62
|
+
| `geojson` | Riproietta le geometrie di un file GeoJSON |
|
|
63
|
+
| `inspect` | Famiglia datum, ordine assi, unità, fuso, false easting di un EPSG |
|
|
64
|
+
| `detect` | Indovina il sistema di origine di una coordinata dalla sua magnitudine |
|
|
65
|
+
| `targets` | Destinazioni di conversione valide per un EPSG (datum diverso) |
|
|
66
|
+
| `roundtrip` | Converte A→B→A e riporta l'errore residuo del datum |
|
|
67
|
+
| `cache` | Ispeziona o svuota la cache offline |
|
|
68
|
+
| `doctor` | Verifica la connettività al servizio |
|
|
69
|
+
|
|
70
|
+
### Funzioni distintive
|
|
71
|
+
|
|
72
|
+
- **`roundtrip`** — certifica che una catena di datum sia lossless entro tolleranza prima di pubblicare dati.
|
|
73
|
+
- **`detect`** — recupera l'EPSG reale di un dataset etichettato vagamente ("UTM", "Gauss-Boaga").
|
|
74
|
+
- **`inspect` / `targets`** — disambigua sistemi simili (3003 vs 3004) ed evita destinazioni con lo stesso datum (rifiutate dal servizio).
|
|
75
|
+
- **cache offline** — replay riproducibile delle conversioni in CI o pipeline offline.
|
|
76
|
+
|
|
77
|
+
## Uso come libreria
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
import openverto as ov
|
|
81
|
+
|
|
82
|
+
# elenco sistemi
|
|
83
|
+
ov.systems() # [{"epsg": 4265, "descrizione": "Monte Mario"}, ...]
|
|
84
|
+
|
|
85
|
+
# conversione (e=est/lon, n=nord/lat); auto-chunk + cache
|
|
86
|
+
ov.convert([(12.4924, 41.8902)], 4265, 6706)
|
|
87
|
+
# -> [(12.4921961827, 41.8908506304)]
|
|
88
|
+
|
|
89
|
+
# intelligenza offline (nessuna chiamata di rete)
|
|
90
|
+
ov.inspect(3003)
|
|
91
|
+
ov.targets(3003)
|
|
92
|
+
ov.detect(2300000, 4640000) # {"kind": "projected", "candidate_epsg": [3004], ...}
|
|
93
|
+
|
|
94
|
+
# salta le coordinate fuori griglia isolandole per bisezione
|
|
95
|
+
results, skipped = ov.convert_skipping(coords, 3003, 6707)
|
|
96
|
+
|
|
97
|
+
# errore residuo di una catena di datum
|
|
98
|
+
ov.roundtrip([(1500000, 4640000)], 3003, 6707)
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Autenticazione
|
|
102
|
+
|
|
103
|
+
Nessuna. Il servizio è libero e gratuito; i campi `utente`/`chiave` richiesti dall'API sono segnaposto, riempiti automaticamente.
|
|
104
|
+
|
|
105
|
+
## Note tecniche
|
|
106
|
+
|
|
107
|
+
- Le coordinate geografiche sono **sempre in gradi sessadecimali**.
|
|
108
|
+
- Le conversioni **tra sistemi con lo stesso datum** non sono ammesse dal servizio (vedi `targets`).
|
|
109
|
+
- Il servizio accetta al massimo **32000 coordinate** per richiesta; `batch` esegue automaticamente il chunking.
|
|
110
|
+
- La griglia IGM copre l'Italia e i mari circostanti: coordinate fuori copertura vengono rifiutate (usa `detect`/`inspect` per controllare assi e EPSG).
|
|
111
|
+
|
|
112
|
+
## Crediti
|
|
113
|
+
|
|
114
|
+
Servizio dati: [IGM Verto Online](https://igmi.esercito.difesa.it/), Istituto Geografico Militare.
|
|
115
|
+
|
|
116
|
+
## Licenza
|
|
117
|
+
|
|
118
|
+
MIT.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "openverto"
|
|
3
|
+
version = "0.1.1"
|
|
4
|
+
description = "Python CLI and library for the official IGM Verto Online coordinate transforms between Italian reference systems"
|
|
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 = ["igm", "verto", "coordinate", "epsg", "crs", "italy", "rdn2008", "gauss-boaga", "open-data"]
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 4 - Beta",
|
|
14
|
+
"Intended Audience :: Science/Research",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.12",
|
|
19
|
+
"Programming Language :: Python :: 3.13",
|
|
20
|
+
"Topic :: Scientific/Engineering :: GIS",
|
|
21
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
22
|
+
]
|
|
23
|
+
dependencies = [
|
|
24
|
+
"httpx>=0.28.1",
|
|
25
|
+
"typer>=0.24.1",
|
|
26
|
+
"rich>=14.0.0",
|
|
27
|
+
"platformdirs>=4.0.0",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.scripts]
|
|
31
|
+
openverto = "openverto:main"
|
|
32
|
+
|
|
33
|
+
[project.urls]
|
|
34
|
+
Homepage = "https://github.com/ondata/openverto"
|
|
35
|
+
Issues = "https://github.com/ondata/openverto/issues"
|
|
36
|
+
|
|
37
|
+
[dependency-groups]
|
|
38
|
+
dev = ["pytest>=8", "ruff>=0.11", "twine>=5", "build>=1"]
|
|
39
|
+
|
|
40
|
+
[tool.pytest.ini_options]
|
|
41
|
+
testpaths = ["tests"]
|
|
42
|
+
# Skip the live (network) tests by default; run them with: pytest -m live
|
|
43
|
+
addopts = "-m 'not live'"
|
|
44
|
+
markers = ["live: tests that hit the live IGM Verto Online service"]
|
|
45
|
+
|
|
46
|
+
[tool.ruff.lint]
|
|
47
|
+
ignore = ["E402"]
|
|
48
|
+
|
|
49
|
+
[build-system]
|
|
50
|
+
requires = ["uv_build>=0.9.7,<0.10.0"]
|
|
51
|
+
build-backend = "uv_build"
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""openverto — Python CLI and library for the IGM Verto Online coordinate service.
|
|
2
|
+
|
|
3
|
+
Convert coordinates between every Italian reference system (Roma40, ED50,
|
|
4
|
+
IGM95, ETRS89, RDN2008) via the official IGM service, with zero grid-file
|
|
5
|
+
setup. Offline EPSG intelligence (``inspect``, ``targets``, ``detect``) needs
|
|
6
|
+
no network.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
__version__ = version("openverto")
|
|
13
|
+
except PackageNotFoundError:
|
|
14
|
+
__version__ = "0.0.0"
|
|
15
|
+
|
|
16
|
+
from .base import (
|
|
17
|
+
MAX_COORD,
|
|
18
|
+
VertoError,
|
|
19
|
+
get_extra_headers,
|
|
20
|
+
set_base_url,
|
|
21
|
+
set_extra_headers,
|
|
22
|
+
set_timeout,
|
|
23
|
+
)
|
|
24
|
+
from .transform import convert, convert_skipping, inspect, roundtrip, targets
|
|
25
|
+
from .detect import detect
|
|
26
|
+
from .geo import reproject_geojson
|
|
27
|
+
from .systems import info, system_by_epsg, systems
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"systems",
|
|
31
|
+
"system_by_epsg",
|
|
32
|
+
"info",
|
|
33
|
+
"convert",
|
|
34
|
+
"convert_skipping",
|
|
35
|
+
"inspect",
|
|
36
|
+
"targets",
|
|
37
|
+
"roundtrip",
|
|
38
|
+
"detect",
|
|
39
|
+
"reproject_geojson",
|
|
40
|
+
"set_timeout",
|
|
41
|
+
"set_base_url",
|
|
42
|
+
"set_extra_headers",
|
|
43
|
+
"get_extra_headers",
|
|
44
|
+
"VertoError",
|
|
45
|
+
"MAX_COORD",
|
|
46
|
+
"__version__",
|
|
47
|
+
"main",
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def main() -> None:
|
|
52
|
+
"""CLI entrypoint (lazy import so the library has no CLI-only deps cost)."""
|
|
53
|
+
from .cli import app
|
|
54
|
+
|
|
55
|
+
app()
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""HTTP layer for the IGM Verto Online service.
|
|
2
|
+
|
|
3
|
+
The service is a single POST endpoint discriminated by the body field
|
|
4
|
+
``richiesta``: ``"info"`` lists the supported reference systems, ``"conversione"``
|
|
5
|
+
transforms an array of ``{e, n}`` coordinates between two EPSG codes. The
|
|
6
|
+
``utente``/``chiave`` fields are mandatory but ignored by the service (they are
|
|
7
|
+
NOT authentication); we send fixed placeholders.
|
|
8
|
+
|
|
9
|
+
Service-level errors are returned inside an HTTP 200 body as
|
|
10
|
+
``{"stato": "errore", "dove": ..., "messaggio": ...}`` and surface as
|
|
11
|
+
:class:`VertoError`.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import time
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
|
|
20
|
+
#: Default endpoint (relative path ``/volapi`` is already included).
|
|
21
|
+
DEFAULT_BASE_URL = "https://igmi.esercito.difesa.it/porta-magna/wps/volapi"
|
|
22
|
+
|
|
23
|
+
#: Maximum number of coordinates accepted per ``conversione`` request.
|
|
24
|
+
MAX_COORD = 32000
|
|
25
|
+
|
|
26
|
+
_USER_AGENT = "openverto"
|
|
27
|
+
|
|
28
|
+
# Module-level configuration (mutable via the setters below).
|
|
29
|
+
_base_url = DEFAULT_BASE_URL
|
|
30
|
+
_timeout = 30.0
|
|
31
|
+
_extra_headers: dict[str, str] = {}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def set_base_url(url: str) -> None:
|
|
35
|
+
"""Override the service endpoint (useful for tests or a mirror)."""
|
|
36
|
+
global _base_url
|
|
37
|
+
_base_url = url
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def get_base_url() -> str:
|
|
41
|
+
return _base_url
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def set_timeout(seconds: float) -> None:
|
|
45
|
+
"""Set the per-request timeout in seconds."""
|
|
46
|
+
global _timeout
|
|
47
|
+
_timeout = float(seconds)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def get_timeout() -> float:
|
|
51
|
+
return _timeout
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def set_extra_headers(headers: dict[str, str]) -> None:
|
|
55
|
+
"""Replace the static extra HTTP headers sent with every request."""
|
|
56
|
+
global _extra_headers
|
|
57
|
+
_extra_headers = dict(headers)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def get_extra_headers() -> dict[str, str]:
|
|
61
|
+
return dict(_extra_headers)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class VertoError(Exception):
|
|
65
|
+
"""A service-level error reported by Verto Online (``stato: errore``).
|
|
66
|
+
|
|
67
|
+
The most common case is an out-of-grid coordinate, signalled by
|
|
68
|
+
``dove == "Proj"`` (the underlying PROJ transform rejecting a coordinate
|
|
69
|
+
outside the IGM grid coverage).
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
def __init__(self, messaggio: str, dove: str = ""):
|
|
73
|
+
self.messaggio = messaggio
|
|
74
|
+
self.dove = dove
|
|
75
|
+
super().__init__(str(self))
|
|
76
|
+
|
|
77
|
+
def __str__(self) -> str:
|
|
78
|
+
if self.dove:
|
|
79
|
+
return f"verto service error ({self.dove}): {self.messaggio}"
|
|
80
|
+
return f"verto service error: {self.messaggio}"
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def outside_grid(self) -> bool:
|
|
84
|
+
"""Whether this is the 'coordinate outside the IGM grid coverage' case."""
|
|
85
|
+
if self.dove.strip().lower() == "proj":
|
|
86
|
+
return True
|
|
87
|
+
m = (self.messaggio or "").lower()
|
|
88
|
+
return "outside grid" in m or "griglia" in m
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def post(body: dict, *, retries: int = 2) -> dict:
|
|
92
|
+
"""POST a request body to the Verto endpoint and return the parsed JSON.
|
|
93
|
+
|
|
94
|
+
Retries transient network/5xx errors with a short backoff. Raises
|
|
95
|
+
:class:`httpx.HTTPError` on a non-recoverable transport error and
|
|
96
|
+
:class:`ValueError` on an unparseable body. Service-level ``stato: errore``
|
|
97
|
+
bodies are returned as-is for the caller to interpret.
|
|
98
|
+
"""
|
|
99
|
+
headers = {"Content-Type": "application/json", "User-Agent": _USER_AGENT}
|
|
100
|
+
headers.update(_extra_headers)
|
|
101
|
+
last_exc: Exception | None = None
|
|
102
|
+
for attempt in range(retries + 1):
|
|
103
|
+
try:
|
|
104
|
+
with httpx.Client(timeout=_timeout) as client:
|
|
105
|
+
resp = client.post(_base_url, json=body, headers=headers)
|
|
106
|
+
if resp.status_code >= 500 and attempt < retries:
|
|
107
|
+
last_exc = httpx.HTTPStatusError(
|
|
108
|
+
f"server error {resp.status_code}", request=resp.request, response=resp
|
|
109
|
+
)
|
|
110
|
+
time.sleep(0.5 * (attempt + 1))
|
|
111
|
+
continue
|
|
112
|
+
resp.raise_for_status()
|
|
113
|
+
try:
|
|
114
|
+
return resp.json()
|
|
115
|
+
except ValueError as exc:
|
|
116
|
+
raise ValueError(f"invalid JSON from Verto service: {exc}") from exc
|
|
117
|
+
except (httpx.TransportError, httpx.HTTPStatusError) as exc:
|
|
118
|
+
last_exc = exc
|
|
119
|
+
if attempt < retries:
|
|
120
|
+
time.sleep(0.5 * (attempt + 1))
|
|
121
|
+
continue
|
|
122
|
+
raise
|
|
123
|
+
# Unreachable, but keeps type-checkers happy.
|
|
124
|
+
raise last_exc if last_exc else RuntimeError("post failed")
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Offline write-through cache for systems and conversion results.
|
|
2
|
+
|
|
3
|
+
Cached results let pipelines replay the same conversions offline and keep CI
|
|
4
|
+
runs reproducible without hitting the live service. Files live under the user
|
|
5
|
+
cache directory (``platformdirs``); set ``OPENVERTO_CACHE_DIR`` to override.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from platformdirs import user_cache_dir
|
|
15
|
+
|
|
16
|
+
# Conversion cache keys round e/n to this many decimals so float jitter on the
|
|
17
|
+
# input does not prevent a hit. 6 decimals is ~0.1 mm at the equator — finer
|
|
18
|
+
# than the service's own precision.
|
|
19
|
+
_KEY_DECIMALS = 6
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def cache_dir() -> Path:
|
|
23
|
+
"""Return the cache directory, honouring ``OPENVERTO_CACHE_DIR``."""
|
|
24
|
+
override = os.environ.get("OPENVERTO_CACHE_DIR")
|
|
25
|
+
d = Path(override) if override else Path(user_cache_dir("openverto"))
|
|
26
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
27
|
+
return d
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _systems_file() -> Path:
|
|
31
|
+
return cache_dir() / "systems.json"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _conv_file() -> Path:
|
|
35
|
+
return cache_dir() / "conversions.json"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def load_systems() -> list[dict]:
|
|
39
|
+
"""Return the cached systems list, or ``[]`` on miss/corruption."""
|
|
40
|
+
f = _systems_file()
|
|
41
|
+
try:
|
|
42
|
+
return json.loads(f.read_text(encoding="utf-8"))
|
|
43
|
+
except (OSError, ValueError):
|
|
44
|
+
return []
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def save_systems(systems: list[dict]) -> None:
|
|
48
|
+
try:
|
|
49
|
+
_systems_file().write_text(
|
|
50
|
+
json.dumps(systems, ensure_ascii=False), encoding="utf-8"
|
|
51
|
+
)
|
|
52
|
+
except OSError:
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _conv_key(in_epsg: int, out_epsg: int, e: float, n: float) -> str:
|
|
57
|
+
return f"{in_epsg}:{out_epsg}:{round(e, _KEY_DECIMALS)}:{round(n, _KEY_DECIMALS)}"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class ConvCache:
|
|
61
|
+
"""A write-through cache of conversion results, persisted as JSON."""
|
|
62
|
+
|
|
63
|
+
def __init__(self, data: dict[str, list[float]] | None = None):
|
|
64
|
+
self._data: dict[str, list[float]] = data or {}
|
|
65
|
+
self._dirty = False
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def load(cls) -> "ConvCache":
|
|
69
|
+
try:
|
|
70
|
+
raw = json.loads(_conv_file().read_text(encoding="utf-8"))
|
|
71
|
+
if isinstance(raw, dict):
|
|
72
|
+
return cls(raw)
|
|
73
|
+
except (OSError, ValueError):
|
|
74
|
+
pass
|
|
75
|
+
return cls()
|
|
76
|
+
|
|
77
|
+
def get(self, in_epsg: int, out_epsg: int, e: float, n: float) -> tuple[float, float] | None:
|
|
78
|
+
v = self._data.get(_conv_key(in_epsg, out_epsg, e, n))
|
|
79
|
+
if v and len(v) == 2:
|
|
80
|
+
return (v[0], v[1])
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
def put(self, in_epsg: int, out_epsg: int, e: float, n: float, result: tuple[float, float]) -> None:
|
|
84
|
+
self._data[_conv_key(in_epsg, out_epsg, e, n)] = [result[0], result[1]]
|
|
85
|
+
self._dirty = True
|
|
86
|
+
|
|
87
|
+
def save(self) -> None:
|
|
88
|
+
if not self._dirty:
|
|
89
|
+
return
|
|
90
|
+
try:
|
|
91
|
+
_conv_file().write_text(
|
|
92
|
+
json.dumps(self._data, ensure_ascii=False), encoding="utf-8"
|
|
93
|
+
)
|
|
94
|
+
self._dirty = False
|
|
95
|
+
except OSError:
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
def __len__(self) -> int:
|
|
99
|
+
return len(self._data)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def stats() -> dict:
|
|
103
|
+
"""Return cache statistics for the ``cache --stats`` command."""
|
|
104
|
+
conv = _conv_file()
|
|
105
|
+
syst = _systems_file()
|
|
106
|
+
return {
|
|
107
|
+
"cache_dir": str(cache_dir()),
|
|
108
|
+
"systems_cached": len(load_systems()),
|
|
109
|
+
"conversions_cached": len(ConvCache.load()),
|
|
110
|
+
"conversions_file": str(conv) if conv.exists() else None,
|
|
111
|
+
"size_bytes": (conv.stat().st_size if conv.exists() else 0)
|
|
112
|
+
+ (syst.stat().st_size if syst.exists() else 0),
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def clear() -> list[str]:
|
|
117
|
+
"""Delete cache files. Returns the paths removed."""
|
|
118
|
+
removed = []
|
|
119
|
+
for f in (_conv_file(), _systems_file()):
|
|
120
|
+
if f.exists():
|
|
121
|
+
try:
|
|
122
|
+
f.unlink()
|
|
123
|
+
removed.append(str(f))
|
|
124
|
+
except OSError:
|
|
125
|
+
pass
|
|
126
|
+
return removed
|