catastrogps 1.0.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.
@@ -0,0 +1,10 @@
1
+ .DS_Store
2
+ node_modules/
3
+ dist/
4
+ *.tgz
5
+ __pycache__/
6
+ *.egg-info/
7
+ build/
8
+ .venv/
9
+ .pytest_cache/
10
+ .ruff_cache/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 TheHiddenPanda
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,156 @@
1
+ Metadata-Version: 2.5
2
+ Name: catastrogps
3
+ Version: 1.0.0
4
+ Summary: Official Python client for the Catastro GPS API: cadastral parcels in 31 European countries and regions by reference, coordinates or Spanish address
5
+ Project-URL: Homepage, https://www.catastrogps.es/developers
6
+ Project-URL: Documentation, https://www.catastrogps.es/developers
7
+ Project-URL: Support, https://www.catastrogps.es/developers
8
+ Author-email: The Hidden Panda <soporte@catastrogps.es>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: cadastral,cadastre,cadastre-api,catastro,europe,france,geojson,germany,gis,italy,land-registry,parcel,portugal,real-estate,spain
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Scientific/Engineering :: GIS
22
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: >=3.9
25
+ Requires-Dist: httpx<1,>=0.25
26
+ Provides-Extra: dev
27
+ Requires-Dist: pytest>=7.0; extra == 'dev'
28
+ Requires-Dist: ruff>=0.4.0; extra == 'dev'
29
+ Description-Content-Type: text/markdown
30
+
31
+ # catastrogps
32
+
33
+ Official Python client for the [Catastro GPS API](https://www.catastrogps.es/developers): cadastral parcels in **29 European countries plus the Basque Country and Navarre** (31 country and region codes) with one API key.
34
+
35
+ - Look up a parcel by its **official cadastral reference** or by **coordinates**, with the country detected for you.
36
+ - Turn a **Spanish postal address in free text** into a cadastral reference.
37
+ - Get the **parcel outline** (GeoJSON or a `[lat, lng]` ring), and **KML / GPX / DXF** exports.
38
+ - **Solar** (PVGIS) and **agricultural** context for parcels in Spain, Portugal, France, Italy and Germany.
39
+ - One dependency (`httpx`), typed, Python 3.9+.
40
+
41
+ **Free tier: 250 calls a month, forever. Failed lookups are not charged.** Get a key at [catastrogps.es/developers](https://www.catastrogps.es/developers).
42
+
43
+ ## Install
44
+
45
+ ```bash
46
+ pip install catastrogps
47
+ ```
48
+
49
+ ## Quick start
50
+
51
+ ```python
52
+ from catastrogps import CatastroGPS
53
+
54
+ client = CatastroGPS("pk_live_your_key_here")
55
+
56
+ parcel = client.parcels.get("9872023VH5797S0001WX")
57
+ print(parcel["municipio"], parcel.get("superficieParcela"), parcel["latitud"], parcel["longitud"])
58
+ ```
59
+
60
+ `CatastroGPS()` with no arguments reads `CATASTROGPS_API_KEY` from the environment. Use it as a context manager to close the connection pool:
61
+
62
+ ```python
63
+ with CatastroGPS() as client:
64
+ ...
65
+ ```
66
+
67
+ ## Examples
68
+
69
+ ```python
70
+ match = client.parcels.find_by_address("Calle Mallorca 213, Barcelona")
71
+ match["referenciaCatastral"]
72
+
73
+ in_warsaw = client.parcels.at_point(52.2297, 21.0122)
74
+ in_warsaw["referenciaCatastral"], in_warsaw.get("pais")
75
+
76
+ foral = client.parcels.get("<Navarre reference>", country="NA")
77
+
78
+ outline = client.parcels.geometry("9872023VH5797S", country="ES")
79
+ outline.get("geojson")
80
+
81
+ solar = client.parcels.solar("9872023VH5797S0001WX")
82
+ solar["kwh_year"]
83
+
84
+ kml_bytes = client.export.file("9872023VH5797S0001WX", "kml")
85
+
86
+ guess = client.resolve("05102200100005")
87
+ guess["candidates"]
88
+
89
+ client.last_quota
90
+ ```
91
+
92
+ Responses are the API's `data` object as a `dict`, with the field names the API uses (`refCatastral`, `municipio`, `superficieParcela`…). See the [API reference](https://www.catastrogps.es/developers).
93
+
94
+ ## Errors
95
+
96
+ Every error is a `CatastroGPSError` with `status`, `code` and `details`:
97
+
98
+ ```python
99
+ from catastrogps import AmbiguousReferenceError, CoverageError, NotFoundError, QuotaExceededError
100
+
101
+ try:
102
+ client.parcels.get("05102200100005")
103
+ except AmbiguousReferenceError as error:
104
+ first = error.candidates[0]["country"]
105
+ client.parcels.get("05102200100005", country=first)
106
+ except (NotFoundError, CoverageError) as error:
107
+ print(error.message)
108
+ except QuotaExceededError:
109
+ print("Monthly quota used up")
110
+ ```
111
+
112
+ Also available: `AuthenticationError`, `ValidationError`, `RateLimitError`, `ServiceUnavailableError`, `ServerError`, `TimeoutError`, `NetworkError`.
113
+
114
+ Timeouts, network errors, 429 rate limits and 502/503/504 are retried up to `max_retries` times (default 2) with exponential backoff. An exhausted monthly quota is never retried. Each attempt that reaches the API counts as a call.
115
+
116
+ ## Options
117
+
118
+ | Argument | Default | |
119
+ |----------|---------|---|
120
+ | `api_key` | `CATASTROGPS_API_KEY` | Required |
121
+ | `base_url` | `https://api.catastrogps.es` | |
122
+ | `timeout` | `30.0` seconds | Official cadastres can be slow |
123
+ | `max_retries` | `2` | |
124
+ | `http_client` | new `httpx.Client` | Bring your own (proxies, tests with `httpx.MockTransport`) |
125
+
126
+ ## Coverage
127
+
128
+ | Code | Country / region | Reference | Coordinates | Notes |
129
+ |------|------------------|:---:|:---:|-------|
130
+ | `ES` | Spain | ✅ | ✅ | Free-text address search |
131
+ | `PV` · `NA` | Basque Country · Navarre | ✅ | ✅ | Foral cadastres |
132
+ | `PT` | Portugal | Partial | ✅ | Digital cadastre is partial |
133
+ | `FR` · `IT` | France · Italy | ✅ | ✅ | |
134
+ | `DE` | Germany | Partial | Partial | All Länder except Bavaria |
135
+ | `AT` `CH` `LI` `BE` `NL` `LU` | Austria, Switzerland, Liechtenstein, Belgium, Netherlands, Luxembourg | ✅ | ✅ | |
136
+ | `PL` `CZ` `SK` `SI` `HR` `BG` `GR` `CY` | Poland, Czechia, Slovakia, Slovenia, Croatia, Bulgaria, Greece, Cyprus | ✅ | ✅ | |
137
+ | `DK` `NO` `FI` `IS` `EE` `LV` `LT` `IE` | Denmark, Norway, Finland, Iceland, Estonia, Latvia, Lithuania, Ireland | ✅ | ✅ | |
138
+ | `SE` | Sweden | ✅ | ✅ | Agricultural blocks, not property units |
139
+ | `UK` | United Kingdom | — | Scotland | England, Wales and Northern Ireland not yet |
140
+
141
+ Geometry is available wherever a reference works. Solar and agriculture: `ES`, `PV`, `NA`, `PT`, `FR`, `IT`, `DE`. Data comes live from each official source, so availability follows theirs.
142
+
143
+ ## Pricing
144
+
145
+ | Plan | Price | Calls / month |
146
+ |------|-------|---------------|
147
+ | Free | €0, forever | 100 |
148
+ | Developer | €19 / month | 5,000 |
149
+ | Startup | €49 / month | 15,000 |
150
+ | Growth | €99 / month | 50,000 |
151
+
152
+ The same key works with the JavaScript SDK (`npm install catastrogps`) and the MCP server for AI agents ([catastro-gps-mcp](https://www.npmjs.com/package/catastro-gps-mcp)).
153
+
154
+ ## License
155
+
156
+ MIT
@@ -0,0 +1,126 @@
1
+ # catastrogps
2
+
3
+ Official Python client for the [Catastro GPS API](https://www.catastrogps.es/developers): cadastral parcels in **29 European countries plus the Basque Country and Navarre** (31 country and region codes) with one API key.
4
+
5
+ - Look up a parcel by its **official cadastral reference** or by **coordinates**, with the country detected for you.
6
+ - Turn a **Spanish postal address in free text** into a cadastral reference.
7
+ - Get the **parcel outline** (GeoJSON or a `[lat, lng]` ring), and **KML / GPX / DXF** exports.
8
+ - **Solar** (PVGIS) and **agricultural** context for parcels in Spain, Portugal, France, Italy and Germany.
9
+ - One dependency (`httpx`), typed, Python 3.9+.
10
+
11
+ **Free tier: 250 calls a month, forever. Failed lookups are not charged.** Get a key at [catastrogps.es/developers](https://www.catastrogps.es/developers).
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ pip install catastrogps
17
+ ```
18
+
19
+ ## Quick start
20
+
21
+ ```python
22
+ from catastrogps import CatastroGPS
23
+
24
+ client = CatastroGPS("pk_live_your_key_here")
25
+
26
+ parcel = client.parcels.get("9872023VH5797S0001WX")
27
+ print(parcel["municipio"], parcel.get("superficieParcela"), parcel["latitud"], parcel["longitud"])
28
+ ```
29
+
30
+ `CatastroGPS()` with no arguments reads `CATASTROGPS_API_KEY` from the environment. Use it as a context manager to close the connection pool:
31
+
32
+ ```python
33
+ with CatastroGPS() as client:
34
+ ...
35
+ ```
36
+
37
+ ## Examples
38
+
39
+ ```python
40
+ match = client.parcels.find_by_address("Calle Mallorca 213, Barcelona")
41
+ match["referenciaCatastral"]
42
+
43
+ in_warsaw = client.parcels.at_point(52.2297, 21.0122)
44
+ in_warsaw["referenciaCatastral"], in_warsaw.get("pais")
45
+
46
+ foral = client.parcels.get("<Navarre reference>", country="NA")
47
+
48
+ outline = client.parcels.geometry("9872023VH5797S", country="ES")
49
+ outline.get("geojson")
50
+
51
+ solar = client.parcels.solar("9872023VH5797S0001WX")
52
+ solar["kwh_year"]
53
+
54
+ kml_bytes = client.export.file("9872023VH5797S0001WX", "kml")
55
+
56
+ guess = client.resolve("05102200100005")
57
+ guess["candidates"]
58
+
59
+ client.last_quota
60
+ ```
61
+
62
+ Responses are the API's `data` object as a `dict`, with the field names the API uses (`refCatastral`, `municipio`, `superficieParcela`…). See the [API reference](https://www.catastrogps.es/developers).
63
+
64
+ ## Errors
65
+
66
+ Every error is a `CatastroGPSError` with `status`, `code` and `details`:
67
+
68
+ ```python
69
+ from catastrogps import AmbiguousReferenceError, CoverageError, NotFoundError, QuotaExceededError
70
+
71
+ try:
72
+ client.parcels.get("05102200100005")
73
+ except AmbiguousReferenceError as error:
74
+ first = error.candidates[0]["country"]
75
+ client.parcels.get("05102200100005", country=first)
76
+ except (NotFoundError, CoverageError) as error:
77
+ print(error.message)
78
+ except QuotaExceededError:
79
+ print("Monthly quota used up")
80
+ ```
81
+
82
+ Also available: `AuthenticationError`, `ValidationError`, `RateLimitError`, `ServiceUnavailableError`, `ServerError`, `TimeoutError`, `NetworkError`.
83
+
84
+ Timeouts, network errors, 429 rate limits and 502/503/504 are retried up to `max_retries` times (default 2) with exponential backoff. An exhausted monthly quota is never retried. Each attempt that reaches the API counts as a call.
85
+
86
+ ## Options
87
+
88
+ | Argument | Default | |
89
+ |----------|---------|---|
90
+ | `api_key` | `CATASTROGPS_API_KEY` | Required |
91
+ | `base_url` | `https://api.catastrogps.es` | |
92
+ | `timeout` | `30.0` seconds | Official cadastres can be slow |
93
+ | `max_retries` | `2` | |
94
+ | `http_client` | new `httpx.Client` | Bring your own (proxies, tests with `httpx.MockTransport`) |
95
+
96
+ ## Coverage
97
+
98
+ | Code | Country / region | Reference | Coordinates | Notes |
99
+ |------|------------------|:---:|:---:|-------|
100
+ | `ES` | Spain | ✅ | ✅ | Free-text address search |
101
+ | `PV` · `NA` | Basque Country · Navarre | ✅ | ✅ | Foral cadastres |
102
+ | `PT` | Portugal | Partial | ✅ | Digital cadastre is partial |
103
+ | `FR` · `IT` | France · Italy | ✅ | ✅ | |
104
+ | `DE` | Germany | Partial | Partial | All Länder except Bavaria |
105
+ | `AT` `CH` `LI` `BE` `NL` `LU` | Austria, Switzerland, Liechtenstein, Belgium, Netherlands, Luxembourg | ✅ | ✅ | |
106
+ | `PL` `CZ` `SK` `SI` `HR` `BG` `GR` `CY` | Poland, Czechia, Slovakia, Slovenia, Croatia, Bulgaria, Greece, Cyprus | ✅ | ✅ | |
107
+ | `DK` `NO` `FI` `IS` `EE` `LV` `LT` `IE` | Denmark, Norway, Finland, Iceland, Estonia, Latvia, Lithuania, Ireland | ✅ | ✅ | |
108
+ | `SE` | Sweden | ✅ | ✅ | Agricultural blocks, not property units |
109
+ | `UK` | United Kingdom | — | Scotland | England, Wales and Northern Ireland not yet |
110
+
111
+ Geometry is available wherever a reference works. Solar and agriculture: `ES`, `PV`, `NA`, `PT`, `FR`, `IT`, `DE`. Data comes live from each official source, so availability follows theirs.
112
+
113
+ ## Pricing
114
+
115
+ | Plan | Price | Calls / month |
116
+ |------|-------|---------------|
117
+ | Free | €0, forever | 100 |
118
+ | Developer | €19 / month | 5,000 |
119
+ | Startup | €49 / month | 15,000 |
120
+ | Growth | €99 / month | 50,000 |
121
+
122
+ The same key works with the JavaScript SDK (`npm install catastrogps`) and the MCP server for AI agents ([catastro-gps-mcp](https://www.npmjs.com/package/catastro-gps-mcp)).
123
+
124
+ ## License
125
+
126
+ MIT
@@ -0,0 +1,41 @@
1
+ from catastrogps._version import __version__
2
+ from catastrogps.client import DEFAULT_BASE_URL, CatastroGPS
3
+ from catastrogps.errors import (
4
+ AmbiguousReferenceError,
5
+ AuthenticationError,
6
+ CatastroGPSError,
7
+ CoverageError,
8
+ ForbiddenError,
9
+ NetworkError,
10
+ NotFoundError,
11
+ QuotaExceededError,
12
+ RateLimitError,
13
+ ServerError,
14
+ ServiceUnavailableError,
15
+ TimeoutError,
16
+ ValidationError,
17
+ )
18
+ from catastrogps.export import EXPORT_FORMATS
19
+ from catastrogps.parcels import COUNTRY_CODES, ENRICHMENT_COUNTRY_CODES
20
+
21
+ __all__ = [
22
+ "COUNTRY_CODES",
23
+ "DEFAULT_BASE_URL",
24
+ "ENRICHMENT_COUNTRY_CODES",
25
+ "EXPORT_FORMATS",
26
+ "AmbiguousReferenceError",
27
+ "AuthenticationError",
28
+ "CatastroGPS",
29
+ "CatastroGPSError",
30
+ "CoverageError",
31
+ "ForbiddenError",
32
+ "NetworkError",
33
+ "NotFoundError",
34
+ "QuotaExceededError",
35
+ "RateLimitError",
36
+ "ServerError",
37
+ "ServiceUnavailableError",
38
+ "TimeoutError",
39
+ "ValidationError",
40
+ "__version__",
41
+ ]
@@ -0,0 +1 @@
1
+ __version__ = "1.0.0"
@@ -0,0 +1,157 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import time
5
+ from typing import Any, Union
6
+
7
+ import httpx
8
+ from typing_extensions import Self
9
+
10
+ from catastrogps._version import __version__
11
+ from catastrogps.errors import (
12
+ CatastroGPSError,
13
+ NetworkError,
14
+ RateLimitError,
15
+ TimeoutError,
16
+ error_from_response,
17
+ )
18
+ from catastrogps.export import ExportResource
19
+ from catastrogps.parcels import ParcelsResource
20
+
21
+ DEFAULT_BASE_URL = "https://api.catastrogps.es"
22
+ DEFAULT_TIMEOUT = 30.0
23
+ DEFAULT_MAX_RETRIES = 2
24
+ BACKOFF_BASE_SECONDS = 0.5
25
+ RETRYABLE_STATUS = frozenset({502, 503, 504})
26
+
27
+ Query = dict[str, Union[str, int, float, None]]
28
+
29
+
30
+ class CatastroGPS:
31
+ def __init__(
32
+ self,
33
+ api_key: str | None = None,
34
+ *,
35
+ base_url: str = DEFAULT_BASE_URL,
36
+ timeout: float = DEFAULT_TIMEOUT,
37
+ max_retries: int = DEFAULT_MAX_RETRIES,
38
+ http_client: httpx.Client | None = None,
39
+ ) -> None:
40
+ key = api_key or os.environ.get("CATASTROGPS_API_KEY")
41
+ if not key:
42
+ raise CatastroGPSError(
43
+ "An API key is required: pass api_key or set CATASTROGPS_API_KEY. "
44
+ "Get a free key at https://www.catastrogps.es/developers"
45
+ )
46
+ self._max_retries = max(0, max_retries)
47
+ self._owns_client = http_client is None
48
+ self._http = http_client or httpx.Client(timeout=timeout)
49
+ self._base_url = base_url.rstrip("/")
50
+ self._headers = {
51
+ "X-API-Key": key,
52
+ "User-Agent": f"catastrogps-python/{__version__}",
53
+ }
54
+ self.last_quota: dict[str, Any] | None = None
55
+ self.parcels = ParcelsResource(self)
56
+ self.export = ExportResource(self)
57
+
58
+ def resolve(self, text: str, *, hint: str | None = None) -> dict[str, Any]:
59
+ return self.request("GET", "/api/resolve", query={"q": text, "hint": hint})
60
+
61
+ def request(
62
+ self,
63
+ method: str,
64
+ path: str,
65
+ *,
66
+ query: Query | None = None,
67
+ body: Any = None,
68
+ raw: bool = False,
69
+ ) -> Any:
70
+ attempt = 0
71
+ while True:
72
+ try:
73
+ return self._once(method, path, query, body, raw)
74
+ except CatastroGPSError as error:
75
+ if not self._should_retry(error, attempt):
76
+ raise
77
+ time.sleep(self._retry_delay(error, attempt))
78
+ attempt += 1
79
+
80
+ def close(self) -> None:
81
+ if self._owns_client:
82
+ self._http.close()
83
+
84
+ def __enter__(self) -> Self:
85
+ return self
86
+
87
+ def __exit__(self, *exc: object) -> None:
88
+ self.close()
89
+
90
+ def _once(self, method: str, path: str, query: Query | None, body: Any, raw: bool) -> Any:
91
+ params = {k: v for k, v in (query or {}).items() if v is not None and v != ""}
92
+ headers = dict(self._headers)
93
+ headers["Accept"] = "*/*" if raw else "application/json"
94
+ try:
95
+ response = self._http.request(
96
+ method,
97
+ f"{self._base_url}{path}",
98
+ params=params,
99
+ json=body,
100
+ headers=headers,
101
+ )
102
+ except httpx.TimeoutException as exc:
103
+ raise TimeoutError(f"Request timed out: {exc}") from exc
104
+ except httpx.HTTPError as exc:
105
+ raise NetworkError(f"Network error: {exc}") from exc
106
+
107
+ self._capture_quota(response.headers)
108
+
109
+ if response.status_code >= 300:
110
+ raise error_from_response(response.status_code, _json(response), _retry_after(response))
111
+
112
+ if raw:
113
+ return response.content
114
+
115
+ payload = _json(response)
116
+ return payload.get("data", payload)
117
+
118
+ def _should_retry(self, error: CatastroGPSError, attempt: int) -> bool:
119
+ if attempt >= self._max_retries:
120
+ return False
121
+ if isinstance(error, (TimeoutError, NetworkError, RateLimitError)):
122
+ return True
123
+ return error.status in RETRYABLE_STATUS
124
+
125
+ def _retry_delay(self, error: CatastroGPSError, attempt: int) -> float:
126
+ if isinstance(error, RateLimitError) and error.retry_after:
127
+ return float(error.retry_after)
128
+ return BACKOFF_BASE_SECONDS * (2**attempt)
129
+
130
+ def _capture_quota(self, headers: httpx.Headers) -> None:
131
+ limit = headers.get("X-RateLimit-Limit")
132
+ if limit is None:
133
+ return
134
+ remaining = headers.get("X-RateLimit-Remaining")
135
+ self.last_quota = {
136
+ "plan": headers.get("X-Quota-Tier"),
137
+ "limit": int(limit),
138
+ "remaining": int(remaining) if remaining is not None else None,
139
+ "resets_at": headers.get("X-RateLimit-Reset"),
140
+ }
141
+
142
+
143
+ def _json(response: httpx.Response) -> dict[str, Any]:
144
+ try:
145
+ payload = response.json()
146
+ except ValueError:
147
+ return {}
148
+ return payload if isinstance(payload, dict) else {}
149
+
150
+
151
+ def _retry_after(response: httpx.Response) -> float | None:
152
+ value = response.headers.get("Retry-After")
153
+ try:
154
+ seconds = float(value) if value is not None else None
155
+ except ValueError:
156
+ return None
157
+ return seconds if seconds and seconds > 0 else None
@@ -0,0 +1,106 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+
6
+ class CatastroGPSError(Exception):
7
+ def __init__(
8
+ self,
9
+ message: str,
10
+ status: int | None = None,
11
+ code: str | None = None,
12
+ details: Any = None,
13
+ body: dict[str, Any] | None = None,
14
+ ) -> None:
15
+ super().__init__(message)
16
+ self.message = message
17
+ self.status = status
18
+ self.code = code
19
+ self.details = details
20
+ self.body = body or {}
21
+
22
+ def __repr__(self) -> str:
23
+ return f"{type(self).__name__}(status={self.status!r}, code={self.code!r}, message={self.message!r})"
24
+
25
+
26
+ class AuthenticationError(CatastroGPSError):
27
+ pass
28
+
29
+
30
+ class ForbiddenError(CatastroGPSError):
31
+ pass
32
+
33
+
34
+ class NotFoundError(CatastroGPSError):
35
+ pass
36
+
37
+
38
+ class ValidationError(CatastroGPSError):
39
+ pass
40
+
41
+
42
+ class CoverageError(CatastroGPSError):
43
+ pass
44
+
45
+
46
+ class AmbiguousReferenceError(CatastroGPSError):
47
+ @property
48
+ def candidates(self) -> list[dict[str, Any]]:
49
+ if isinstance(self.details, dict):
50
+ return list(self.details.get("candidates") or [])
51
+ return []
52
+
53
+
54
+ class QuotaExceededError(CatastroGPSError):
55
+ pass
56
+
57
+
58
+ class RateLimitError(CatastroGPSError):
59
+ def __init__(self, *args: Any, retry_after: float | None = None, **kwargs: Any) -> None:
60
+ super().__init__(*args, **kwargs)
61
+ self.retry_after = retry_after
62
+
63
+
64
+ class ServiceUnavailableError(CatastroGPSError):
65
+ pass
66
+
67
+
68
+ class ServerError(CatastroGPSError):
69
+ pass
70
+
71
+
72
+ class TimeoutError(CatastroGPSError):
73
+ pass
74
+
75
+
76
+ class NetworkError(CatastroGPSError):
77
+ pass
78
+
79
+
80
+ def error_from_response(status: int, body: dict[str, Any], retry_after: float | None = None) -> CatastroGPSError:
81
+ code = body.get("code") if isinstance(body.get("code"), str) else None
82
+ message = body.get("error") or body.get("message") or f"HTTP {status}"
83
+ details = body.get("data", body.get("parsed"))
84
+ kwargs: dict[str, Any] = {"status": status, "code": code, "details": details, "body": body}
85
+
86
+ if code == "CNV_AMBIGUOUS" or status == 300:
87
+ return AmbiguousReferenceError(message, **kwargs)
88
+ if code == "CNV_COVERAGE":
89
+ return CoverageError(message, **kwargs)
90
+ if code == "KEY_AUTH_004":
91
+ return QuotaExceededError(message, **kwargs)
92
+ if status == 429:
93
+ return RateLimitError(message, retry_after=retry_after, **kwargs)
94
+ if status == 401:
95
+ return AuthenticationError(message, **kwargs)
96
+ if status == 403:
97
+ return ForbiddenError(message, **kwargs)
98
+ if status == 404:
99
+ return NotFoundError(message, **kwargs)
100
+ if status in (400, 422):
101
+ return ValidationError(message, **kwargs)
102
+ if status == 503:
103
+ return ServiceUnavailableError(message, **kwargs)
104
+ if status >= 500:
105
+ return ServerError(message, **kwargs)
106
+ return CatastroGPSError(message, **kwargs)
@@ -0,0 +1,25 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from catastrogps.errors import CatastroGPSError
6
+
7
+ if TYPE_CHECKING:
8
+ from catastrogps.client import CatastroGPS
9
+
10
+ EXPORT_FORMATS = ("kml", "gpx", "dxf")
11
+
12
+
13
+ class ExportResource:
14
+ def __init__(self, client: CatastroGPS) -> None:
15
+ self._client = client
16
+
17
+ def file(self, reference: str, format: str, *, country: str | None = None) -> bytes:
18
+ if format not in EXPORT_FORMATS:
19
+ raise CatastroGPSError(f"format must be one of {', '.join(EXPORT_FORMATS)}")
20
+ cleaned = (reference or "").strip()
21
+ if not cleaned:
22
+ raise CatastroGPSError("reference must not be empty")
23
+ return self._client.request(
24
+ "GET", f"/api/export/{format}", query={"refcat": cleaned, "country": country}, raw=True
25
+ )
@@ -0,0 +1,89 @@
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ from typing import TYPE_CHECKING, Any
5
+ from urllib.parse import quote
6
+
7
+ from catastrogps.errors import CatastroGPSError
8
+
9
+ if TYPE_CHECKING:
10
+ from catastrogps.client import CatastroGPS
11
+
12
+ COUNTRY_CODES = (
13
+ "ES",
14
+ "PV",
15
+ "NA",
16
+ "PT",
17
+ "FR",
18
+ "IT",
19
+ "DE",
20
+ "AT",
21
+ "CH",
22
+ "LI",
23
+ "BE",
24
+ "NL",
25
+ "LU",
26
+ "PL",
27
+ "CZ",
28
+ "SK",
29
+ "SI",
30
+ "HR",
31
+ "BG",
32
+ "GR",
33
+ "CY",
34
+ "DK",
35
+ "SE",
36
+ "NO",
37
+ "FI",
38
+ "IS",
39
+ "EE",
40
+ "LV",
41
+ "LT",
42
+ "IE",
43
+ "UK",
44
+ )
45
+
46
+ ENRICHMENT_COUNTRY_CODES = ("ES", "PV", "NA", "PT", "FR", "IT", "DE")
47
+
48
+
49
+ def parcel_path(reference: str, suffix: str = "") -> str:
50
+ cleaned = (reference or "").strip()
51
+ if not cleaned:
52
+ raise CatastroGPSError("reference must not be empty")
53
+ return f"/api/catastro/{quote(cleaned, safe='')}{suffix}"
54
+
55
+
56
+ class ParcelsResource:
57
+ def __init__(self, client: CatastroGPS) -> None:
58
+ self._client = client
59
+
60
+ def get(self, reference: str, *, country: str | None = None) -> dict[str, Any]:
61
+ return self._client.request("GET", parcel_path(reference), query={"country": country})
62
+
63
+ def at_point(self, lat: float, lng: float, *, country: str | None = None) -> dict[str, Any]:
64
+ if not (_finite(lat) and _finite(lng)):
65
+ raise CatastroGPSError("lat and lng must be finite numbers")
66
+ return self._client.request(
67
+ "GET", "/api/search/coordinates", query={"lat": lat, "lng": lng, "country": country}
68
+ )
69
+
70
+ def find_by_address(self, address: str) -> dict[str, Any]:
71
+ if not (address or "").strip():
72
+ raise CatastroGPSError("address must not be empty")
73
+ return self._client.request("POST", "/api/search/address/parse", body={"direccion": address})
74
+
75
+ def geometry(self, reference: str, *, country: str | None = None) -> dict[str, Any]:
76
+ return self._client.request("GET", parcel_path(reference, "/polygon"), query={"country": country})
77
+
78
+ def solar(self, reference: str, *, country: str | None = None) -> dict[str, Any]:
79
+ return self._client.request("GET", parcel_path(reference, "/solar"), query={"country": country})
80
+
81
+ def agriculture(self, reference: str, *, country: str | None = None) -> dict[str, Any]:
82
+ data = self._client.request("GET", parcel_path(reference, "/agro"), query={"country": country})
83
+ if isinstance(data, dict) and isinstance(data.get("agro"), dict):
84
+ return data["agro"]
85
+ return data
86
+
87
+
88
+ def _finite(value: Any) -> bool:
89
+ return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value)
File without changes
@@ -0,0 +1,63 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.24"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "catastrogps"
7
+ dynamic = ["version"]
8
+ description = "Official Python client for the Catastro GPS API: cadastral parcels in 31 European countries and regions by reference, coordinates or Spanish address"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.9"
13
+ authors = [
14
+ { name = "The Hidden Panda", email = "soporte@catastrogps.es" },
15
+ ]
16
+ keywords = [
17
+ "cadastre", "cadastral", "catastro", "cadastre-api", "parcel", "land-registry",
18
+ "gis", "geojson", "europe", "spain", "portugal", "france", "italy", "germany", "real-estate",
19
+ ]
20
+ classifiers = [
21
+ "Development Status :: 4 - Beta",
22
+ "Intended Audience :: Developers",
23
+ "Operating System :: OS Independent",
24
+ "Programming Language :: Python :: 3",
25
+ "Programming Language :: Python :: 3.9",
26
+ "Programming Language :: Python :: 3.10",
27
+ "Programming Language :: Python :: 3.11",
28
+ "Programming Language :: Python :: 3.12",
29
+ "Programming Language :: Python :: 3.13",
30
+ "Topic :: Scientific/Engineering :: GIS",
31
+ "Topic :: Software Development :: Libraries :: Python Modules",
32
+ "Typing :: Typed",
33
+ ]
34
+ dependencies = [
35
+ "httpx>=0.25,<1",
36
+ ]
37
+
38
+ [project.urls]
39
+ Homepage = "https://www.catastrogps.es/developers"
40
+ Documentation = "https://www.catastrogps.es/developers"
41
+ "Support" = "https://www.catastrogps.es/developers"
42
+
43
+ [project.optional-dependencies]
44
+ dev = [
45
+ "pytest>=7.0",
46
+ "ruff>=0.4.0",
47
+ ]
48
+
49
+ [tool.hatch.version]
50
+ path = "catastrogps/_version.py"
51
+
52
+ [tool.hatch.build.targets.sdist]
53
+ include = ["catastrogps", "tests", "README.md", "LICENSE", "pyproject.toml"]
54
+
55
+ [tool.hatch.build.targets.wheel]
56
+ packages = ["catastrogps"]
57
+
58
+ [tool.pytest.ini_options]
59
+ testpaths = ["tests"]
60
+
61
+ [tool.ruff]
62
+ line-length = 120
63
+ target-version = "py39"
File without changes
@@ -0,0 +1,256 @@
1
+ import json
2
+ import re
3
+ from pathlib import Path
4
+
5
+ import httpx
6
+ import pytest
7
+
8
+ import catastrogps.client as client_module
9
+ from catastrogps import (
10
+ COUNTRY_CODES,
11
+ AmbiguousReferenceError,
12
+ AuthenticationError,
13
+ CatastroGPS,
14
+ CatastroGPSError,
15
+ CoverageError,
16
+ NetworkError,
17
+ NotFoundError,
18
+ QuotaExceededError,
19
+ ServiceUnavailableError,
20
+ TimeoutError,
21
+ __version__,
22
+ )
23
+
24
+
25
+ class Recorder:
26
+ def __init__(self, *responses):
27
+ self.responses = list(responses)
28
+ self.requests = []
29
+
30
+ def __call__(self, request: httpx.Request) -> httpx.Response:
31
+ self.requests.append(request)
32
+ item = self.responses.pop(0)
33
+ if isinstance(item, Exception):
34
+ raise item
35
+ return item
36
+
37
+ @property
38
+ def last(self) -> httpx.Request:
39
+ return self.requests[-1]
40
+
41
+
42
+ def ok(data, headers=None):
43
+ return httpx.Response(200, json={"success": True, "data": data}, headers=headers)
44
+
45
+
46
+ def fail(status, code=None, error="boom", **extra):
47
+ body = {"success": False, "error": error, **extra}
48
+ if code:
49
+ body["code"] = code
50
+ return httpx.Response(status, json=body)
51
+
52
+
53
+ @pytest.fixture(autouse=True)
54
+ def no_sleep(monkeypatch):
55
+ monkeypatch.setattr(client_module.time, "sleep", lambda _seconds: None)
56
+
57
+
58
+ def make(*responses, max_retries=2):
59
+ recorder = Recorder(*responses)
60
+ http = httpx.Client(transport=httpx.MockTransport(recorder))
61
+ client = CatastroGPS(
62
+ "pk_test_000000000000", base_url="https://api.example.test", http_client=http, max_retries=max_retries
63
+ )
64
+ return client, recorder
65
+
66
+
67
+ def test_requires_an_api_key(monkeypatch):
68
+ monkeypatch.delenv("CATASTROGPS_API_KEY", raising=False)
69
+ with pytest.raises(CatastroGPSError, match="API key is required"):
70
+ CatastroGPS()
71
+
72
+
73
+ def test_reads_the_key_from_the_environment(monkeypatch):
74
+ monkeypatch.setenv("CATASTROGPS_API_KEY", "pk_env_000000000000")
75
+ recorder = Recorder(ok({}))
76
+ client = CatastroGPS(http_client=httpx.Client(transport=httpx.MockTransport(recorder)))
77
+ client.parcels.get("R")
78
+ assert recorder.last.headers["X-API-Key"] == "pk_env_000000000000"
79
+
80
+
81
+ def test_version_matches_the_single_source():
82
+ source = Path(__file__).resolve().parents[1] / "catastrogps" / "_version.py"
83
+ assert re.search(r'"(.+)"', source.read_text()).group(1) == __version__
84
+
85
+
86
+ def test_knows_31_country_and_region_codes():
87
+ assert len(COUNTRY_CODES) == 31
88
+ assert len(set(COUNTRY_CODES)) == 31
89
+
90
+
91
+ def test_get_parcel_unwraps_data_and_sends_headers():
92
+ client, rec = make(ok({"refCatastral": "9872023VH5797S0001WX", "latitud": 40.4}))
93
+ parcel = client.parcels.get("9872023VH5797S0001WX")
94
+ assert parcel["refCatastral"] == "9872023VH5797S0001WX"
95
+ assert rec.last.url.path == "/api/catastro/9872023VH5797S0001WX"
96
+ assert "country" not in rec.last.url.params
97
+ assert rec.last.headers["X-API-Key"] == "pk_test_000000000000"
98
+ assert rec.last.headers["User-Agent"] == f"catastrogps-python/{__version__}"
99
+
100
+
101
+ def test_get_parcel_passes_country_and_encodes_slashes():
102
+ client, rec = make(ok({}))
103
+ client.parcels.get("146510_8.0502.1/3", country="PL")
104
+ assert rec.last.url.raw_path.decode().startswith("/api/catastro/146510_8.0502.1%2F3")
105
+ assert rec.last.url.params["country"] == "PL"
106
+
107
+
108
+ def test_at_point_uses_the_get_endpoint():
109
+ client, rec = make(ok({"referenciaCatastral": "R"}))
110
+ assert client.parcels.at_point(52.2297, 21.0122)["referenciaCatastral"] == "R"
111
+ assert rec.last.method == "GET"
112
+ assert rec.last.url.path == "/api/search/coordinates"
113
+ assert rec.last.url.params["lat"] == "52.2297"
114
+
115
+
116
+ def test_at_point_rejects_bad_numbers_locally():
117
+ client, rec = make()
118
+ with pytest.raises(CatastroGPSError, match="finite"):
119
+ client.parcels.at_point(float("nan"), 2)
120
+ assert rec.requests == []
121
+
122
+
123
+ def test_find_by_address_posts_free_text():
124
+ client, rec = make(ok({"referenciaCatastral": "0485206DF3808E0016EZ"}))
125
+ match = client.parcels.find_by_address("Calle Mallorca 213, Barcelona")
126
+ assert match["referenciaCatastral"] == "0485206DF3808E0016EZ"
127
+ assert rec.last.method == "POST"
128
+ assert rec.last.url.path == "/api/search/address/parse"
129
+ assert json.loads(rec.last.content) == {"direccion": "Calle Mallorca 213, Barcelona"}
130
+
131
+
132
+ @pytest.mark.parametrize("method,suffix", [("geometry", "/polygon"), ("solar", "/solar")])
133
+ def test_enrichment_paths(method, suffix):
134
+ client, rec = make(ok({}))
135
+ getattr(client.parcels, method)("R", country="ES")
136
+ assert rec.last.url.path == f"/api/catastro/R{suffix}"
137
+
138
+
139
+ def test_agriculture_unwraps_agro():
140
+ client, _ = make(ok({"agro": {"uso_suelo": "TA"}}))
141
+ assert client.parcels.agriculture("R") == {"uso_suelo": "TA"}
142
+
143
+
144
+ def test_empty_reference_is_rejected():
145
+ client, rec = make()
146
+ with pytest.raises(CatastroGPSError, match="reference"):
147
+ client.parcels.get(" ")
148
+ assert rec.requests == []
149
+
150
+
151
+ def test_resolve():
152
+ client, rec = make(ok({"input": "x", "candidates": [], "ambiguous": False}))
153
+ assert client.resolve("9872023VH5797S0001WX", hint="ES")["ambiguous"] is False
154
+ assert rec.last.url.params["q"] == "9872023VH5797S0001WX"
155
+ assert rec.last.url.params["hint"] == "ES"
156
+
157
+
158
+ def test_export_returns_bytes():
159
+ client, rec = make(
160
+ httpx.Response(200, content=b"<kml/>", headers={"Content-Type": "application/vnd.google-earth.kml+xml"})
161
+ )
162
+ assert client.export.file("R", "kml", country="FR") == b"<kml/>"
163
+ assert rec.last.url.path == "/api/export/kml"
164
+ assert rec.last.url.params["refcat"] == "R"
165
+
166
+
167
+ def test_export_rejects_unknown_formats():
168
+ client, _ = make()
169
+ with pytest.raises(CatastroGPSError, match="format"):
170
+ client.export.file("R", "pdf")
171
+
172
+
173
+ def test_invalid_key():
174
+ client, _ = make(fail(401, "KEY_AUTH_002", "Clave API inválida"))
175
+ with pytest.raises(AuthenticationError) as info:
176
+ client.parcels.get("R")
177
+ assert info.value.code == "KEY_AUTH_002"
178
+
179
+
180
+ def test_quota_exhausted_is_not_retried():
181
+ client, rec = make(fail(429, "KEY_AUTH_004", "Cuota mensual agotada"))
182
+ with pytest.raises(QuotaExceededError):
183
+ client.parcels.get("R")
184
+ assert len(rec.requests) == 1
185
+
186
+
187
+ def test_not_found_keeps_status_code_and_message():
188
+ client, _ = make(fail(404, "NOT_FOUND", "Parcel not found."))
189
+ with pytest.raises(NotFoundError) as info:
190
+ client.parcels.get("R")
191
+ assert (info.value.status, info.value.code, info.value.message) == (404, "NOT_FOUND", "Parcel not found.")
192
+
193
+
194
+ def test_address_miss_keeps_what_was_parsed():
195
+ client, _ = make(fail(404, error="No se pudo determinar la provincia", parsed={"NombreVia": "MAYOR"}))
196
+ with pytest.raises(NotFoundError) as info:
197
+ client.parcels.find_by_address("Calle Mayor 1")
198
+ assert info.value.details == {"NombreVia": "MAYOR"}
199
+
200
+
201
+ def test_ambiguous_reference_lists_candidates():
202
+ client, _ = make(fail(300, "CNV_AMBIGUOUS", data={"candidates": [{"country": "DE"}, {"country": "PT"}]}))
203
+ with pytest.raises(AmbiguousReferenceError) as info:
204
+ client.parcels.get("05102200100005")
205
+ assert [c["country"] for c in info.value.candidates] == ["DE", "PT"]
206
+
207
+
208
+ def test_coverage_error():
209
+ client, _ = make(fail(422, "CNV_COVERAGE", data={"country": "RO"}))
210
+ with pytest.raises(CoverageError):
211
+ client.parcels.at_point(44.43, 26.1)
212
+
213
+
214
+ def test_retries_a_cadastre_that_is_down_then_gives_up():
215
+ down = [fail(503, "SERVICE_UNAVAILABLE") for _ in range(3)]
216
+ client, rec = make(*down)
217
+ with pytest.raises(ServiceUnavailableError):
218
+ client.parcels.get("R")
219
+ assert len(rec.requests) == 3
220
+
221
+
222
+ def test_recovers_after_a_network_error():
223
+ client, rec = make(httpx.ConnectError("refused"), ok({"refCatastral": "R"}))
224
+ assert client.parcels.get("R")["refCatastral"] == "R"
225
+ assert len(rec.requests) == 2
226
+
227
+
228
+ def test_timeout_and_network_errors_are_typed():
229
+ client, _ = make(httpx.ReadTimeout("slow"), max_retries=0)
230
+ with pytest.raises(TimeoutError):
231
+ client.parcels.get("R")
232
+ client, _ = make(httpx.ConnectError("refused"), max_retries=0)
233
+ with pytest.raises(NetworkError):
234
+ client.parcels.get("R")
235
+
236
+
237
+ def test_records_quota_headers():
238
+ client, _ = make(
239
+ ok(
240
+ {},
241
+ headers={
242
+ "X-RateLimit-Limit": "100",
243
+ "X-RateLimit-Remaining": "97",
244
+ "X-RateLimit-Reset": "2026-10-01T00:00:00Z",
245
+ "X-Quota-Tier": "free",
246
+ },
247
+ )
248
+ )
249
+ client.parcels.get("R")
250
+ assert client.last_quota == {"plan": "free", "limit": 100, "remaining": 97, "resets_at": "2026-10-01T00:00:00Z"}
251
+
252
+
253
+ def test_context_manager_closes_its_own_client():
254
+ with CatastroGPS("k") as client:
255
+ http = client._http
256
+ assert http.is_closed