Metadata-Version: 2.5
Name: catastrogps
Version: 2.2.0
Summary: Official Python client for the Catastro GPS API: parcels from 29 European countries (including the Basque Country and Navarre foral cadastres) by reference, coordinates or address; building units, terrain, ground motion, solar and more
Project-URL: Homepage, https://www.parcelgps.com/developers
Project-URL: Documentation, https://www.parcelgps.com/developers
Project-URL: Support, https://www.parcelgps.com/developers
Author-email: The Hidden Panda <soporte@catastrogps.es>
License-Expression: MIT
License-File: LICENSE
Keywords: cadastral,cadastre,cadastre-api,catastro,europe,france,geojson,germany,gis,italy,land-registry,parcel,portugal,real-estate,spain
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Scientific/Engineering :: GIS
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.9
Requires-Dist: httpx<1,>=0.25
Requires-Dist: typing-extensions>=4.0
Provides-Extra: dev
Requires-Dist: pytest>=7.0; extra == 'dev'
Requires-Dist: ruff>=0.4.0; extra == 'dev'
Description-Content-Type: text/markdown

# catastrogps

Official Python client for the [Catastro GPS API](https://www.parcelgps.com/developers): cadastral parcels from **29 European countries (including the Basque Country and Navarre foral cadastres)**, with one API key: by cadastral reference in 27, by coordinates in all 29.

- Look up a parcel by its **official cadastral reference** or by **coordinates**, with the country detected for you.
- Turn a **postal address into parcels** in Spain and 26 more countries, ranked by confidence.
- Import **every unit of a Spanish building** (dwellings, shops, garages) with paging and free `304` refreshes.
- Get the **parcel outline** (GeoJSON or a `[lat, lng]` ring), **KML / GPX** exports and the **adjoining parcels**.
- **Crops and land use of each part of a parcel** (Spain with the foral territories, Austria, Estonia, Greece), with crop names in 10 languages and a free crop code catalogue.
- **Spanish provinces, municipalities and streets** to build a structured address search.
- **Relief, Natura 2000 and climate** (Copernicus DEM, EEA, ERA5-Land) and **ground motion** (Copernicus EGMS, mm/year) for any covered country.
- **Solar**, **agriculture**, **market**, **score**, **value history** and **comparison** for Spain, Portugal, France, Italy and Germany.
- Quota, prepaid balance and per-minute limit of every response, typed exceptions for every API code.
- Two small dependencies (`httpx`, `typing_extensions`), typed, Python 3.9+.

**Free tier: 250 calls a month, forever. Failed lookups are not charged.** Get a key at [parcelgps.com/developers](https://www.parcelgps.com/developers).

## Install

```bash
pip install catastrogps
```

## Quick start

```python
from catastrogps import CatastroGPS

client = CatastroGPS("pk_live_your_key_here")

parcel = client.parcels.get("9872023VH5797S0001WX")
print(parcel["municipality"], parcel.get("parcelArea"), parcel["latitude"], parcel["longitude"])
```

`CatastroGPS()` with no arguments reads `CATASTROGPS_API_KEY` from the environment. Use it as a context manager to close the connection pool:

```python
with CatastroGPS() as client:
    ...
```

## Methods

| Method | Endpoint | Countries |
|--------|----------|-----------|
| `parcels.get(ref, country=, lang=)` | `GET /v1/parcels/{ref}` | All references (see coverage). Carries `subparcelCount` and `cropSummary` |
| `parcels.subparcels(ref, country=, lang=)` | `GET /v1/parcels/{ref}/subparcels` | ES (with PV/NA), AT, DE, CZ, NL, FR, PT, SE, CH, FI, BE, IE, SI, EE, GR. 1 unit; 0 when the parcel has none |
| `parcels.sigpac(ref, country=, lang=)` | `GET /v1/parcels/{ref}/sigpac` | ES, PV, NA. SIGPAC enclosures with land use, irrigation, slope, CAP eligibility, incidents and outline. 1 unit; 0 when there are none; `SIG_001` is not charged |
| `parcels.sigpac_geojson(ref, country=, lang=)` | `GET /v1/parcels/{ref}/sigpac?format=geojson` | The same as a GeoJSON FeatureCollection (dict) |
| `parcels.neighbors(ref, country=)` | `GET /v1/parcels/{ref}/neighbors` | Spain, and countries where we hold a copy; `supported: false` (free) elsewhere |
| `parcels.at_point(lat, lng, country=)` | `GET /v1/search/coordinates` | All, including HR, SE and UK (Scotland) |
| `search_address_candidates(q, street=, number=, municipality=, postcode=, country=, limit=)` | `GET /v1/search/address/candidates` | Spain (with Basque Country and Navarre) and 26 more; not SE or HR |
| `parcels.find_by_address(text)` | `POST /v1/search/address/parse` | Spain, central Catastro, live (prefer the candidates search) |
| `get_units(ref14, country=, previous=)`, `iter_unit_pages` | `GET /v1/parcels/{ref14}/units` | `ES`, `PV`, `NA` |
| `parcels.geometry(ref, country=)` | `GET /v1/parcels/{ref}/polygon` | All but UK |
| `parcels.terrain(ref, country=, lat=, lng=)` | `GET /v1/parcels/{ref}/terrain` | All; `lat`/`lng` required outside ES, PV, NA, PT, FR, IT, DE |
| `parcels.ground_motion(ref, country=, lat=, lng=)` | `GET /v1/parcels/{ref}/ground-motion` | Same as terrain, inside EGMS coverage |
| `parcels.solar`, `parcels.agriculture`, `parcels.score` | `/solar`, `/agriculture`, `/score` | ES, PV, NA, PT, FR, IT, DE |
| `parcels.market(ref, country=)` | `/market` | Sales figures in FR, IT and DE (NRW); `zonePrices` (official province prices) in ES, PV, NA; PT `available: false` |
| `parcels.value_history(ref, country=)` | `/value-history` | ES, PV, NA, PT, FR, IT, DE, AT |
| `parcels.compare([(ref, country), ...])` | `POST /v1/parcels/compare` | 2 or 3 parcels in ES, PT, FR, IT, DE |
| `export.file(ref, "kml" \| "gpx", country=)` | `GET /v1/export/{format}` | Where a geometry exists |
| `resolve(text, hint=)` | `GET /v1/resolve` | Free, no quota: which country and kind a text is |
| `crop_catalog(lang=)` | `GET /v1/reference/crops` | Free, no quota: every `cropCode` with its name in the 10 languages |
| `locations.provinces()`, `locations.municipalities(region)`, `locations.streets(region, municipality, name=)` | `GET /v1/locations/...` | Spain, common territory |

## Examples

```python
in_warsaw = client.parcels.at_point(52.2297, 21.0122)
in_warsaw["reference"], in_warsaw.get("country")

found = client.search_address_candidates("Damrak 1, 1012 LG Amsterdam", country="NL")
found["candidates"][0]["reference"], found["candidates"][0]["confidence"]

terrain = client.parcels.terrain("10194A00110004")
terrain["relief"]["slope"]["class"], terrain["protectedAreas"]["natura2000"], terrain.get("climate")

brussels = client.parcels.terrain("21004C0123/00", country="BE", lat=50.848139, lng=4.353613)

motion = client.parcels.ground_motion("0745901TG4304N0002KH")
motion["status"], motion.get("groundMotion", {}).get("vertical", {}).get("meanMmYear")

comparison = client.parcels.compare([("9872023VH5797S0001WX", "ES"), ("750560000AB0001", "FR")])

kml_bytes = client.export.file("9872023VH5797S0001WX", "kml")

guess = client.resolve("05102200100005")
guess["candidates"]

crops = client.parcels.subparcels("46063A00100121", lang="en")
for row in crops["subparcels"]:
    print(row["cropCode"], row.get("crop"), row.get("cropOriginal"), row["area"], row.get("useClass"))

sigpac = client.parcels.sigpac("46063A00100121", lang="en")
for enclosure in sigpac["enclosures"]:
    print(enclosure["id"], enclosure["landUseCode"], enclosure.get("landUse"), enclosure["areaHa"], enclosure["irrigationPct"])
collection = client.parcels.sigpac_geojson("46063A00100121")

catalog = client.crop_catalog(lang="en")
names = {crop["code"]: crop["name"] for crop in catalog["crops"]}

around = client.parcels.neighbors("9872023VH5797S")
[n["reference"] for n in around["neighbors"]]

provinces = client.locations.provinces()["provinces"]
streets = client.locations.streets("CIUDAD REAL", "AGUDO", name="GLORIA")["streets"]
```

Crop names follow `lang=` (`es`, `ca`, `en`, `pt`, `fr`, `it`, `de`, `pl`, `nl`, `cs`; see `CROP_LANGUAGES`), then the `Accept-Language` header, then English; `cropOriginal` always keeps the label as the source writes it. The full rows with geometry come from `parcels.subparcels`; `parcels.get` only carries `subparcelCount` and a `cropSummary` (share of each crop). If the source does not answer, `subparcels` raises `ServiceUnavailableError` with code `SUB_001` and nothing is charged.

`terrain` and `ground_motion` carry a `source.attribution` string that must be shown next to the data. A ground motion answer with `status: "no_data"` and `reason: "no_reflectors"` means the satellite had nothing stable to measure (fields, forest, water): there is no figure, not a zero.

Responses are the `data` object of the English v1 API (`https://api.parcelgps.com/v1`) as a `dict`: `reference`, `municipality`, `parcelArea`, `latitude`, `longitude`… Values that come from an official source (land use, addresses) stay in its language. See the [API reference](https://www.parcelgps.com/en/api/docs) and the OpenAPI spec at `https://api.parcelgps.com/v1/openapi.json`. Version 2 of this SDK talks only to `/v1`; 1.x used the deprecated Spanish-named `/api` routes.

## Import a building (comunidad de propietarios, Spain)

Address, then finca (14-character reference), then every unit with use, area, participation coefficient, stair, floor and door.

```python
found = client.search_address_candidates("Avenida José Rodríguez de la Borbolla Camoyán 10, Dos Hermanas")
finca = found["candidates"][0]

comunidad = client.get_units(finca["reference"], country=finca["country"])
comunidad["propertyUnitCount"]
for u in comunidad["units"]:
    print(u["reference"], u["use"], u["area"], u.get("ownershipShare"), u["staircase"], u["floor"], u["door"])
comunidad["dataSource"], comunidad["attribution"]

refreshed = client.get_units(finca["reference"], previous=comunidad)
refreshed["changed"]
```

- `get_units` follows `nextCursor` for you (up to 200 units per page). `iter_unit_pages(ref)` yields page by page.
- **Cost: one quota unit per unit served** in each page, minimum one per page; a page is cut to the quota you have left. Pass the previous result as `previous=` to re-import: unchanged pages come back as `304 Not Modified`, cost nothing and are reused; `changed` tells you if anything moved.
- Basque Country and Navarre (`country="PV"` or `"NA"`) are served from our copy of each foral cadastre: pass the foral reference of any unit or the finca key as the reference. The foral open data carry no participation coefficient.
- Organisations with a per-finca cap agreed by contract (Startup and Growth) pay at most that cap per finca and period: see `client.last_quota["finca_cap"]` (`{"cap", "used"}`), also in each page's `quota`.

## Quota, prepaid balance and limits

`client.last_quota` is read from the headers of the last response; `conditional_request` also returns the `quota` of that exact response.

| Key | Header | Meaning |
|-----|--------|---------|
| `plan`, `limit`, `remaining`, `resets_at` | `X-Quota-Tier`, `X-Quota-Limit`, `X-Quota-Remaining`, `X-Quota-Reset` | Monthly quota of your plan, after this response |
| `overage`: `balance`, `unit_price`, `remaining_units` | `X-Quota-Overage`, `X-Quota-Balance`, `X-Quota-Overage-Unit-Price`, `X-Quota-Overage-Remaining` | Prepaid pay-as-you-go balance in euros, used once the monthly quota is spent |
| `rate_limit`: `limit`, `remaining`, `reset_seconds` | `X-RateLimit-*` | Per-minute burst limit of your key (Free 10, Developer 60, Startup 120, Growth 300) |
| `finca_cap`: `cap`, `used` | `X-Quota-Finca-Cap`, `X-Quota-Finca-Cap-Used` | Only on `/units`, only with a contract cap |

When the monthly quota is spent and there is no prepaid balance left, the API answers `429 KEY_AUTH_004` (never `402`): the client raises `QuotaExceededError` with the `quota` of that response, and `prepaid_balance_empty` is true when your organisation has the prepaid overage on (top up at `https://parcelgps.com/app/developer`). Only 2xx responses spend quota; `300`, `304`, `4xx` and `5xx` are free, and so are 2xx answers without data.

## Errors

Every error is a `CatastroGPSError` with `status`, `code` and `details`:

```python
from catastrogps import (
    AmbiguousReferenceError,
    CoverageError,
    NotFoundError,
    PlaceNameError,
    QuotaExceededError,
)

try:
    client.parcels.get("05102200100005")
except AmbiguousReferenceError as error:
    client.parcels.get("05102200100005", country=error.candidates[0]["country"])
except PlaceNameError as error:
    print("A place name, not a reference", error.location)
except CoverageError as error:
    print(error.country, error.feature, error.supported_countries)
except NotFoundError as error:
    print(error.message)
except QuotaExceededError as error:
    print("Quota spent", error.quota, error.prepaid_balance_empty)
```

| Exception | API code / status |
|-----------|-------------------|
| `AmbiguousReferenceError` | `300 CNV_AMBIGUOUS`: bare digits of several countries; `candidates` lists them |
| `CoverageError` | `422 CNV_COVERAGE`: country not covered, or an analysis outside its countries |
| `PlaceNameError` (a `ValidationError`) | `422 CNV_PLACE_NAME`: a place name, with the geocoded `location` when found |
| `ValidationError` | `400 VALIDATION_ERROR`, other `422` |
| `AuthenticationError` / `ForbiddenError` | `401 KEY_AUTH_001..003`, `403` |
| `NotFoundError` | `404 NOT_FOUND` |
| `QuotaExceededError` | `429 KEY_AUTH_004` |
| `RateLimitError` | `429 KEY_RATE_002`, `RATE_LIMIT_EXCEEDED`; `retry_after` |
| `ServiceUnavailableError` | `503 SERVICE_UNAVAILABLE`, `SUB_001` / `SIG_001` (subparcels or SIGPAC source down, not charged); `retry_after` |
| `ServerError`, `TimeoutError`, `NetworkError` | `5xx`, client timeout, network failure |

Timeouts, network errors, per-minute 429s and 502/503/504 are retried up to `max_retries` times (default 2) with exponential backoff, waiting `Retry-After` when the API sends it (up to 60 s). An exhausted monthly quota is never retried.

## Options

| Argument | Default | |
|----------|---------|---|
| `api_key` | `CATASTROGPS_API_KEY` env var | Required |
| `base_url` | `https://api.parcelgps.com` | Paths are under `/v1` |
| `timeout` | `30.0` seconds | Official cadastres can be slow |
| `max_retries` | `2` | |
| `http_client` | a new `httpx.Client` | Inject your own for proxies or tests (`httpx.MockTransport`) |

## Coverage

Measured against production on 1 October 2026. 29 European countries, including the Basque Country and Navarre foral cadastres: by cadastral reference in 27, by coordinates in all 29; in Croatia, Sweden and the United Kingdom the source is partial, as the table shows.

| Code | Country / region | Reference | Coordinates | Address | Notes |
|------|------------------|:---:|:---:|:---:|-------|
| `ES` | Spain | Yes | Yes | Yes | Units per building |
| `PV` · `NA` | Basque Country · Navarre | Yes | Yes | With `country="ES"` | Foral cadastres; units per building |
| `FR` · `IT` | France · Italy | Yes | Yes | Yes | |
| `DE` | Germany | Partial | Partial | Partial | 15 of 16 Länder: **no Bavaria** |
| `PT` | Portugal | Yes | Partial | Partial | The cadastre does not cover Lisbon, Porto or Coimbra |
| `AT` `NL` `BE` `PL` `CZ` `DK` `NO` `FI` `EE` | Austria, Netherlands, Belgium, Poland, Czechia, Denmark, Norway, Finland, Estonia | Yes | Yes | Yes | Belgium is slow (3-15 s) |
| `CH` | Switzerland | Yes | Partial | Yes | Not in cantons that publish no parcels (e.g. Vaud) |
| `LT` `SI` `SK` `BG` `LU` `LI` `IS` `CY` | Lithuania, Slovenia, Slovakia, Bulgaria, Luxembourg, Liechtenstein, Iceland, Cyprus | Yes | Yes | Yes | |
| `GR` · `LV` | Greece · Latvia | With the country set | Yes | Yes | |
| `IE` | Ireland | With `country="IE"` | Partial | Partial | Numeric SP_ID |
| `SE` | Sweden | Agricultural blocks | Agricultural blocks | No | Not property units |
| `HR` | Croatia | No | ARKOD only | No | Agricultural parcels of the Paying Agency, not the cadastre |
| `UK` | United Kingdom | No | Scotland | Scotland | |

Not covered: Hungary, Romania and the rest (`CoverageError`). Terrain and ground motion work wherever a parcel outline exists, with `lat`/`lng` outside the seven analysis countries. Data comes from each official source, so availability follows theirs.

## Pricing

| Plan | Price | Calls / month | Prepaid overage per 1,000 |
|------|-------|---------------|---------------------------|
| Free | €0, forever | 250 | €10 |
| Developer | €19 / month | 5,000 | €5 |
| Startup | €49 / month | 15,000 | €4 |
| Growth | €99 / month | 50,000 | €2.50 |

Overage prices are net (VAT apart) and are charged from a prepaid balance you top up in the portal; with the balance at zero the API answers `429`, never debt.

The same key works with the JavaScript SDK (`npm install catastrogps`), the Go SDK and the MCP server for AI agents ([`catastro-gps-mcp`](https://www.npmjs.com/package/catastro-gps-mcp)).

## License

MIT
