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.
@@ -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