Metadata-Version: 2.4
Name: lodapi
Version: 0.0.1
Summary: Ergonomic Python SDK for the Lodapi REST API (LoD2 buildings, terrain, 3D tiles).
Project-URL: Homepage, https://lodapi.de
Project-URL: Documentation, https://lodapi.de/docs
Author-email: scenerii GmbH <konsti@scenerii.com>
License: Apache-2.0
Keywords: 3d-tiles,citygml,geospatial,gis,lod2,lodapi,terrain
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: Apache Software License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Scientific/Engineering :: GIS
Requires-Python: >=3.10
Requires-Dist: httpx>=0.27
Requires-Dist: pydantic>=2
Provides-Extra: dev
Requires-Dist: pytest>=8; extra == 'dev'
Requires-Dist: respx>=0.21; extra == 'dev'
Provides-Extra: geo
Requires-Dist: geopandas>=0.14; extra == 'geo'
Requires-Dist: shapely>=2.0; extra == 'geo'
Description-Content-Type: text/markdown

# lodapi — Python SDK

Ergonomic Python client for the [Lodapi](https://lodapi.de) REST API: federated
LoD2 buildings across all 16 German Bundesländer, DGM terrain elevation, and 3D
Tiles tileset discovery.

> **v0 / unstable.** This is `0.0.1`. The public surface may change before
> `0.1`. Pin an exact version if you depend on it.

## Install

```bash
pip install lodapi
pip install lodapi[geo]     # adds geopandas/shapely → .to_geodataframe()
```

Requires Python 3.10+.

## Quickstart

```python
from lodapi import Client

# api_key is optional — without one you use the anonymous free tier.
c = Client(api_key="lod_...")

# One page of buildings for a WGS84 bbox (minLon, minLat, maxLon, maxLat):
page = c.buildings(bbox=(7.0, 50.9, 7.1, 51.0), limit=500)
print(page.count, page.next_cursor)

# Transparent cursor pagination — iterate every feature across all pages:
for feature in c.buildings.iter(bbox=(7.0, 50.9, 7.1, 51.0)):
    ...

# Or page-by-page:
for p in c.buildings.pages(bbox=(7.0, 50.9, 7.1, 51.0)):
    ...

# Single building + its roof surfaces:
b = c.buildings.get("DEBBAL0100000001")
roof = c.buildings.roof("DEBBAL0100000001")

# Stream a binary GLB of the bbox to disk (or get bytes back with out=None):
c.buildings.glb(bbox=(13.40, 52.51, 13.41, 52.52), out="city.glb")

# Terrain:
h = c.terrain.elevation(lat=50.94, lon=7.05)         # ElevationResponse
prof = c.terrain.profile(coords=[(7.0, 50.9), (7.1, 51.0)], samples=100)
c.terrain.datasets()                                  # DGM raster datasets
c.terrain.mesh_datasets()                             # quantized-mesh tilesets

# Datasets + 3D Tiles:
c.datasets()                                          # LoD2 datasets per BL
c.tilesets(bbox=(6.93, 50.92, 7.02, 50.96))           # tilesets in a bbox
c.tilesets.get("nw-koeln")                            # one tileset
c.tilesets.tileset_json("nw-koeln")                   # raw 3D-Tiles root (dict)

c.close()   # or use `with Client(...) as c: ...`
```

There is **no `bl` parameter** — federation across the 16 Bundesländer is
server-side and automatic. A bbox tuple `(minLon, minLat, maxLon, maxLat)` is
serialized to the API's `"minLon,minLat,maxLon,maxLat"` string for you; you may
also pass an already-formatted string.

## Auth

Pass `api_key="lod_..."` to the constructor; it is sent as the `X-API-Key`
header. The free tier is **anonymous** — omit the key entirely and calls still
work. A malformed/unknown/revoked key raises `AuthError` on the first call.

Keys have the shape `lod_` + 32 lowercase chars (see ADR-0014).

### Soft quota

Per the Concierge-Billing MVP (ADR-0014), quota is **soft** — exceeding it is
not an error. The server may return `X-Lodapi-Quota-Used` / `-Limit` headers;
the SDK surfaces them after each call:

```python
c.buildings(bbox=(...))
print(c.last_quota.used, c.last_quota.limit, c.last_quota.remaining)
```

Both fields are `None` for anonymous calls or keys without a configured quota.

## GLB defaults

`c.buildings.glb(...)` applies a neutral, glTF-consumption-friendly default set
for frame/origin/rotation. These differ from the raw REST-API defaults: the API
itself does **not** rotate, while the SDK rotates Z-up → Y-up so the model
stands upright out-of-the-box in Blender / three.js / model-viewer / Cesium
(glTF is Y-up per spec).

| param          | raw API default | SDK default | why the SDK differs                       |
|----------------|-----------------|-------------|-------------------------------------------|
| `target_frame` | `utm`           | `utm`       | metric, scale-true (not MapLibre-bound)   |
| `origin`       | `center`        | `center`    | model centred on the origin               |
| `rotate_x`     | `0`             | `-90`       | Z-up → Y-up for glTF-spec conformance     |
| `rotate_z`     | `0`             | `0`         | no extra spin around the vertical axis    |

All other GLB params (`z_base`, `compression`, `colorize_roofs`,
`merge_buildings`, `include_ground`, `weld_tolerance_m`) pass through to the
API's own defaults when omitted. Every default is overridable per call:

```python
# Reproduce the scenerii-App view (MapLibre-aligned):
c.buildings.glb(
    bbox=(13.40, 52.51, 13.41, 52.52),
    target_frame="mercator", origin="corner", rotate_x=-90, rotate_z=-90,
)

# Get raw geospatial Z-up (no rotation, matches the raw API):
c.buildings.glb(bbox=(13.40, 52.51, 13.41, 52.52), rotate_x=0)
```

## Errors

All API errors follow RFC 7807 `application/problem+json` (base
`https://lodapi.de/errors`). The SDK parses the document onto a typed exception:

```python
from lodapi import LodapiError, AuthError, NotFoundError, RateLimitError, ApiError

try:
    c.buildings.get("NOPE")
except NotFoundError as exc:
    print(exc.status, exc.title, exc.detail, exc.instance)
```

- `AuthError` — 401
- `NotFoundError` — 404
- `RateLimitError` — 429 (reserved; quota is soft today)
- `ApiError` — any other non-2xx
- `LodapiError` — base class + transport/network errors

Transient 5xx and network errors are retried (default 2 retries, exponential
backoff). 4xx are never retried.

## GeoDataFrame support (`geo` extra)

With `pip install lodapi[geo]`, building/roof FeatureCollections convert to a
`geopandas.GeoDataFrame` (CRS `EPSG:4326`):

```python
page = c.buildings(bbox=(7.0, 50.9, 7.1, 51.0))
gdf = page.to_geodataframe()      # this page's features
roof = c.buildings.roof("DEBBAL0100000001")
roof.to_geodataframe()
```

Without the extra, `to_geodataframe()` raises `ImportError` with an install
hint; the rest of the SDK works fine.

## Configuration

```python
Client(
    api_key=None,                      # optional lod_* key
    base_url="https://api.lodapi.de",  # override for staging/local
    timeout=30.0,                      # per-request seconds
    max_retries=2,                     # transient 5xx/network only
)
```

## Regenerating the response models

The response models in `src/lodapi/models.py` are **hand-written**, derived from
the OpenAPI snapshot at `04_engineering/api/openapi/openapi.json` (the live API
serves it at `GET /openapi.json`). For ~11 endpoints this is leaner than full
codegen.

When the API surface grows or churns:

1. Refresh the snapshot (`GET /openapi.json` → the repo copy).
2. Either edit `models.py` by hand against the new `components.schemas`, **or**
   adopt codegen:

   ```bash
   uvx openapi-python-client generate \
       --path 04_engineering/api/openapi/openapi.json
   ```

   and re-point `resources/*` at the generated models. The resource facade is
   intentionally decoupled from the model layer, so swapping the model source
   is mechanical.

## Development

```bash
uv run --extra dev pytest        # from code/sdk-python/
```

Tests mock all HTTP with `respx` — no live calls against prod.

## License

Apache-2.0. © scenerii GmbH.
