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.
- catastrogps-1.0.0/.gitignore +10 -0
- catastrogps-1.0.0/LICENSE +21 -0
- catastrogps-1.0.0/PKG-INFO +156 -0
- catastrogps-1.0.0/README.md +126 -0
- catastrogps-1.0.0/catastrogps/__init__.py +41 -0
- catastrogps-1.0.0/catastrogps/_version.py +1 -0
- catastrogps-1.0.0/catastrogps/client.py +157 -0
- catastrogps-1.0.0/catastrogps/errors.py +106 -0
- catastrogps-1.0.0/catastrogps/export.py +25 -0
- catastrogps-1.0.0/catastrogps/parcels.py +89 -0
- catastrogps-1.0.0/catastrogps/py.typed +0 -0
- catastrogps-1.0.0/pyproject.toml +63 -0
- catastrogps-1.0.0/tests/__init__.py +0 -0
- catastrogps-1.0.0/tests/test_client.py +256 -0
|
@@ -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
|