lodapi 0.0.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lodapi-0.0.1/.gitignore +16 -0
- lodapi-0.0.1/PKG-INFO +226 -0
- lodapi-0.0.1/README.md +198 -0
- lodapi-0.0.1/pyproject.toml +48 -0
- lodapi-0.0.1/src/lodapi/__init__.py +61 -0
- lodapi-0.0.1/src/lodapi/_geo.py +30 -0
- lodapi-0.0.1/src/lodapi/_http.py +227 -0
- lodapi-0.0.1/src/lodapi/client.py +108 -0
- lodapi-0.0.1/src/lodapi/exceptions.py +102 -0
- lodapi-0.0.1/src/lodapi/models.py +234 -0
- lodapi-0.0.1/src/lodapi/resources/__init__.py +29 -0
- lodapi-0.0.1/src/lodapi/resources/buildings.py +160 -0
- lodapi-0.0.1/src/lodapi/resources/terrain.py +75 -0
- lodapi-0.0.1/src/lodapi/resources/tilesets.py +44 -0
- lodapi-0.0.1/tests/__init__.py +0 -0
- lodapi-0.0.1/tests/conftest.py +167 -0
- lodapi-0.0.1/tests/test_buildings.py +147 -0
- lodapi-0.0.1/tests/test_geo_extra.py +43 -0
- lodapi-0.0.1/tests/test_glb.py +77 -0
- lodapi-0.0.1/tests/test_terrain_tilesets_datasets.py +109 -0
lodapi-0.0.1/.gitignore
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Build-Artefakte (uv build) — werden in CI frisch gebaut, nie committet.
|
|
2
|
+
dist/
|
|
3
|
+
build/
|
|
4
|
+
*.egg-info/
|
|
5
|
+
|
|
6
|
+
# Lockfile: Library-Konvention (ADR-0015) — NICHT committen, damit Konsumenten
|
|
7
|
+
# ihre eigene Auflösung bekommen. Nur Apps locken.
|
|
8
|
+
uv.lock
|
|
9
|
+
|
|
10
|
+
# venv / Caches
|
|
11
|
+
.venv/
|
|
12
|
+
__pycache__/
|
|
13
|
+
*.pyc
|
|
14
|
+
.pytest_cache/
|
|
15
|
+
.ruff_cache/
|
|
16
|
+
.mypy_cache/
|
lodapi-0.0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: lodapi
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Ergonomic Python SDK for the Lodapi REST API (LoD2 buildings, terrain, 3D tiles).
|
|
5
|
+
Project-URL: Homepage, https://lodapi.de
|
|
6
|
+
Project-URL: Documentation, https://lodapi.de/docs
|
|
7
|
+
Author-email: scenerii GmbH <konsti@scenerii.com>
|
|
8
|
+
License: Apache-2.0
|
|
9
|
+
Keywords: 3d-tiles,citygml,geospatial,gis,lod2,lodapi,terrain
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Topic :: Scientific/Engineering :: GIS
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Requires-Dist: httpx>=0.27
|
|
20
|
+
Requires-Dist: pydantic>=2
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
23
|
+
Requires-Dist: respx>=0.21; extra == 'dev'
|
|
24
|
+
Provides-Extra: geo
|
|
25
|
+
Requires-Dist: geopandas>=0.14; extra == 'geo'
|
|
26
|
+
Requires-Dist: shapely>=2.0; extra == 'geo'
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# lodapi — Python SDK
|
|
30
|
+
|
|
31
|
+
Ergonomic Python client for the [Lodapi](https://lodapi.de) REST API: federated
|
|
32
|
+
LoD2 buildings across all 16 German Bundesländer, DGM terrain elevation, and 3D
|
|
33
|
+
Tiles tileset discovery.
|
|
34
|
+
|
|
35
|
+
> **v0 / unstable.** This is `0.0.1`. The public surface may change before
|
|
36
|
+
> `0.1`. Pin an exact version if you depend on it.
|
|
37
|
+
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install lodapi
|
|
42
|
+
pip install lodapi[geo] # adds geopandas/shapely → .to_geodataframe()
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Requires Python 3.10+.
|
|
46
|
+
|
|
47
|
+
## Quickstart
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from lodapi import Client
|
|
51
|
+
|
|
52
|
+
# api_key is optional — without one you use the anonymous free tier.
|
|
53
|
+
c = Client(api_key="lod_...")
|
|
54
|
+
|
|
55
|
+
# One page of buildings for a WGS84 bbox (minLon, minLat, maxLon, maxLat):
|
|
56
|
+
page = c.buildings(bbox=(7.0, 50.9, 7.1, 51.0), limit=500)
|
|
57
|
+
print(page.count, page.next_cursor)
|
|
58
|
+
|
|
59
|
+
# Transparent cursor pagination — iterate every feature across all pages:
|
|
60
|
+
for feature in c.buildings.iter(bbox=(7.0, 50.9, 7.1, 51.0)):
|
|
61
|
+
...
|
|
62
|
+
|
|
63
|
+
# Or page-by-page:
|
|
64
|
+
for p in c.buildings.pages(bbox=(7.0, 50.9, 7.1, 51.0)):
|
|
65
|
+
...
|
|
66
|
+
|
|
67
|
+
# Single building + its roof surfaces:
|
|
68
|
+
b = c.buildings.get("DEBBAL0100000001")
|
|
69
|
+
roof = c.buildings.roof("DEBBAL0100000001")
|
|
70
|
+
|
|
71
|
+
# Stream a binary GLB of the bbox to disk (or get bytes back with out=None):
|
|
72
|
+
c.buildings.glb(bbox=(13.40, 52.51, 13.41, 52.52), out="city.glb")
|
|
73
|
+
|
|
74
|
+
# Terrain:
|
|
75
|
+
h = c.terrain.elevation(lat=50.94, lon=7.05) # ElevationResponse
|
|
76
|
+
prof = c.terrain.profile(coords=[(7.0, 50.9), (7.1, 51.0)], samples=100)
|
|
77
|
+
c.terrain.datasets() # DGM raster datasets
|
|
78
|
+
c.terrain.mesh_datasets() # quantized-mesh tilesets
|
|
79
|
+
|
|
80
|
+
# Datasets + 3D Tiles:
|
|
81
|
+
c.datasets() # LoD2 datasets per BL
|
|
82
|
+
c.tilesets(bbox=(6.93, 50.92, 7.02, 50.96)) # tilesets in a bbox
|
|
83
|
+
c.tilesets.get("nw-koeln") # one tileset
|
|
84
|
+
c.tilesets.tileset_json("nw-koeln") # raw 3D-Tiles root (dict)
|
|
85
|
+
|
|
86
|
+
c.close() # or use `with Client(...) as c: ...`
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
There is **no `bl` parameter** — federation across the 16 Bundesländer is
|
|
90
|
+
server-side and automatic. A bbox tuple `(minLon, minLat, maxLon, maxLat)` is
|
|
91
|
+
serialized to the API's `"minLon,minLat,maxLon,maxLat"` string for you; you may
|
|
92
|
+
also pass an already-formatted string.
|
|
93
|
+
|
|
94
|
+
## Auth
|
|
95
|
+
|
|
96
|
+
Pass `api_key="lod_..."` to the constructor; it is sent as the `X-API-Key`
|
|
97
|
+
header. The free tier is **anonymous** — omit the key entirely and calls still
|
|
98
|
+
work. A malformed/unknown/revoked key raises `AuthError` on the first call.
|
|
99
|
+
|
|
100
|
+
Keys have the shape `lod_` + 32 lowercase chars (see ADR-0014).
|
|
101
|
+
|
|
102
|
+
### Soft quota
|
|
103
|
+
|
|
104
|
+
Per the Concierge-Billing MVP (ADR-0014), quota is **soft** — exceeding it is
|
|
105
|
+
not an error. The server may return `X-Lodapi-Quota-Used` / `-Limit` headers;
|
|
106
|
+
the SDK surfaces them after each call:
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
c.buildings(bbox=(...))
|
|
110
|
+
print(c.last_quota.used, c.last_quota.limit, c.last_quota.remaining)
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Both fields are `None` for anonymous calls or keys without a configured quota.
|
|
114
|
+
|
|
115
|
+
## GLB defaults
|
|
116
|
+
|
|
117
|
+
`c.buildings.glb(...)` applies a neutral, glTF-consumption-friendly default set
|
|
118
|
+
for frame/origin/rotation. These differ from the raw REST-API defaults: the API
|
|
119
|
+
itself does **not** rotate, while the SDK rotates Z-up → Y-up so the model
|
|
120
|
+
stands upright out-of-the-box in Blender / three.js / model-viewer / Cesium
|
|
121
|
+
(glTF is Y-up per spec).
|
|
122
|
+
|
|
123
|
+
| param | raw API default | SDK default | why the SDK differs |
|
|
124
|
+
|----------------|-----------------|-------------|-------------------------------------------|
|
|
125
|
+
| `target_frame` | `utm` | `utm` | metric, scale-true (not MapLibre-bound) |
|
|
126
|
+
| `origin` | `center` | `center` | model centred on the origin |
|
|
127
|
+
| `rotate_x` | `0` | `-90` | Z-up → Y-up for glTF-spec conformance |
|
|
128
|
+
| `rotate_z` | `0` | `0` | no extra spin around the vertical axis |
|
|
129
|
+
|
|
130
|
+
All other GLB params (`z_base`, `compression`, `colorize_roofs`,
|
|
131
|
+
`merge_buildings`, `include_ground`, `weld_tolerance_m`) pass through to the
|
|
132
|
+
API's own defaults when omitted. Every default is overridable per call:
|
|
133
|
+
|
|
134
|
+
```python
|
|
135
|
+
# Reproduce the scenerii-App view (MapLibre-aligned):
|
|
136
|
+
c.buildings.glb(
|
|
137
|
+
bbox=(13.40, 52.51, 13.41, 52.52),
|
|
138
|
+
target_frame="mercator", origin="corner", rotate_x=-90, rotate_z=-90,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
# Get raw geospatial Z-up (no rotation, matches the raw API):
|
|
142
|
+
c.buildings.glb(bbox=(13.40, 52.51, 13.41, 52.52), rotate_x=0)
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## Errors
|
|
146
|
+
|
|
147
|
+
All API errors follow RFC 7807 `application/problem+json` (base
|
|
148
|
+
`https://lodapi.de/errors`). The SDK parses the document onto a typed exception:
|
|
149
|
+
|
|
150
|
+
```python
|
|
151
|
+
from lodapi import LodapiError, AuthError, NotFoundError, RateLimitError, ApiError
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
c.buildings.get("NOPE")
|
|
155
|
+
except NotFoundError as exc:
|
|
156
|
+
print(exc.status, exc.title, exc.detail, exc.instance)
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
- `AuthError` — 401
|
|
160
|
+
- `NotFoundError` — 404
|
|
161
|
+
- `RateLimitError` — 429 (reserved; quota is soft today)
|
|
162
|
+
- `ApiError` — any other non-2xx
|
|
163
|
+
- `LodapiError` — base class + transport/network errors
|
|
164
|
+
|
|
165
|
+
Transient 5xx and network errors are retried (default 2 retries, exponential
|
|
166
|
+
backoff). 4xx are never retried.
|
|
167
|
+
|
|
168
|
+
## GeoDataFrame support (`geo` extra)
|
|
169
|
+
|
|
170
|
+
With `pip install lodapi[geo]`, building/roof FeatureCollections convert to a
|
|
171
|
+
`geopandas.GeoDataFrame` (CRS `EPSG:4326`):
|
|
172
|
+
|
|
173
|
+
```python
|
|
174
|
+
page = c.buildings(bbox=(7.0, 50.9, 7.1, 51.0))
|
|
175
|
+
gdf = page.to_geodataframe() # this page's features
|
|
176
|
+
roof = c.buildings.roof("DEBBAL0100000001")
|
|
177
|
+
roof.to_geodataframe()
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Without the extra, `to_geodataframe()` raises `ImportError` with an install
|
|
181
|
+
hint; the rest of the SDK works fine.
|
|
182
|
+
|
|
183
|
+
## Configuration
|
|
184
|
+
|
|
185
|
+
```python
|
|
186
|
+
Client(
|
|
187
|
+
api_key=None, # optional lod_* key
|
|
188
|
+
base_url="https://api.lodapi.de", # override for staging/local
|
|
189
|
+
timeout=30.0, # per-request seconds
|
|
190
|
+
max_retries=2, # transient 5xx/network only
|
|
191
|
+
)
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
## Regenerating the response models
|
|
195
|
+
|
|
196
|
+
The response models in `src/lodapi/models.py` are **hand-written**, derived from
|
|
197
|
+
the OpenAPI snapshot at `04_engineering/api/openapi/openapi.json` (the live API
|
|
198
|
+
serves it at `GET /openapi.json`). For ~11 endpoints this is leaner than full
|
|
199
|
+
codegen.
|
|
200
|
+
|
|
201
|
+
When the API surface grows or churns:
|
|
202
|
+
|
|
203
|
+
1. Refresh the snapshot (`GET /openapi.json` → the repo copy).
|
|
204
|
+
2. Either edit `models.py` by hand against the new `components.schemas`, **or**
|
|
205
|
+
adopt codegen:
|
|
206
|
+
|
|
207
|
+
```bash
|
|
208
|
+
uvx openapi-python-client generate \
|
|
209
|
+
--path 04_engineering/api/openapi/openapi.json
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
and re-point `resources/*` at the generated models. The resource facade is
|
|
213
|
+
intentionally decoupled from the model layer, so swapping the model source
|
|
214
|
+
is mechanical.
|
|
215
|
+
|
|
216
|
+
## Development
|
|
217
|
+
|
|
218
|
+
```bash
|
|
219
|
+
uv run --extra dev pytest # from code/sdk-python/
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
Tests mock all HTTP with `respx` — no live calls against prod.
|
|
223
|
+
|
|
224
|
+
## License
|
|
225
|
+
|
|
226
|
+
Apache-2.0. © scenerii GmbH.
|
lodapi-0.0.1/README.md
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# lodapi — Python SDK
|
|
2
|
+
|
|
3
|
+
Ergonomic Python client for the [Lodapi](https://lodapi.de) REST API: federated
|
|
4
|
+
LoD2 buildings across all 16 German Bundesländer, DGM terrain elevation, and 3D
|
|
5
|
+
Tiles tileset discovery.
|
|
6
|
+
|
|
7
|
+
> **v0 / unstable.** This is `0.0.1`. The public surface may change before
|
|
8
|
+
> `0.1`. Pin an exact version if you depend on it.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pip install lodapi
|
|
14
|
+
pip install lodapi[geo] # adds geopandas/shapely → .to_geodataframe()
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Requires Python 3.10+.
|
|
18
|
+
|
|
19
|
+
## Quickstart
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from lodapi import Client
|
|
23
|
+
|
|
24
|
+
# api_key is optional — without one you use the anonymous free tier.
|
|
25
|
+
c = Client(api_key="lod_...")
|
|
26
|
+
|
|
27
|
+
# One page of buildings for a WGS84 bbox (minLon, minLat, maxLon, maxLat):
|
|
28
|
+
page = c.buildings(bbox=(7.0, 50.9, 7.1, 51.0), limit=500)
|
|
29
|
+
print(page.count, page.next_cursor)
|
|
30
|
+
|
|
31
|
+
# Transparent cursor pagination — iterate every feature across all pages:
|
|
32
|
+
for feature in c.buildings.iter(bbox=(7.0, 50.9, 7.1, 51.0)):
|
|
33
|
+
...
|
|
34
|
+
|
|
35
|
+
# Or page-by-page:
|
|
36
|
+
for p in c.buildings.pages(bbox=(7.0, 50.9, 7.1, 51.0)):
|
|
37
|
+
...
|
|
38
|
+
|
|
39
|
+
# Single building + its roof surfaces:
|
|
40
|
+
b = c.buildings.get("DEBBAL0100000001")
|
|
41
|
+
roof = c.buildings.roof("DEBBAL0100000001")
|
|
42
|
+
|
|
43
|
+
# Stream a binary GLB of the bbox to disk (or get bytes back with out=None):
|
|
44
|
+
c.buildings.glb(bbox=(13.40, 52.51, 13.41, 52.52), out="city.glb")
|
|
45
|
+
|
|
46
|
+
# Terrain:
|
|
47
|
+
h = c.terrain.elevation(lat=50.94, lon=7.05) # ElevationResponse
|
|
48
|
+
prof = c.terrain.profile(coords=[(7.0, 50.9), (7.1, 51.0)], samples=100)
|
|
49
|
+
c.terrain.datasets() # DGM raster datasets
|
|
50
|
+
c.terrain.mesh_datasets() # quantized-mesh tilesets
|
|
51
|
+
|
|
52
|
+
# Datasets + 3D Tiles:
|
|
53
|
+
c.datasets() # LoD2 datasets per BL
|
|
54
|
+
c.tilesets(bbox=(6.93, 50.92, 7.02, 50.96)) # tilesets in a bbox
|
|
55
|
+
c.tilesets.get("nw-koeln") # one tileset
|
|
56
|
+
c.tilesets.tileset_json("nw-koeln") # raw 3D-Tiles root (dict)
|
|
57
|
+
|
|
58
|
+
c.close() # or use `with Client(...) as c: ...`
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
There is **no `bl` parameter** — federation across the 16 Bundesländer is
|
|
62
|
+
server-side and automatic. A bbox tuple `(minLon, minLat, maxLon, maxLat)` is
|
|
63
|
+
serialized to the API's `"minLon,minLat,maxLon,maxLat"` string for you; you may
|
|
64
|
+
also pass an already-formatted string.
|
|
65
|
+
|
|
66
|
+
## Auth
|
|
67
|
+
|
|
68
|
+
Pass `api_key="lod_..."` to the constructor; it is sent as the `X-API-Key`
|
|
69
|
+
header. The free tier is **anonymous** — omit the key entirely and calls still
|
|
70
|
+
work. A malformed/unknown/revoked key raises `AuthError` on the first call.
|
|
71
|
+
|
|
72
|
+
Keys have the shape `lod_` + 32 lowercase chars (see ADR-0014).
|
|
73
|
+
|
|
74
|
+
### Soft quota
|
|
75
|
+
|
|
76
|
+
Per the Concierge-Billing MVP (ADR-0014), quota is **soft** — exceeding it is
|
|
77
|
+
not an error. The server may return `X-Lodapi-Quota-Used` / `-Limit` headers;
|
|
78
|
+
the SDK surfaces them after each call:
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
c.buildings(bbox=(...))
|
|
82
|
+
print(c.last_quota.used, c.last_quota.limit, c.last_quota.remaining)
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Both fields are `None` for anonymous calls or keys without a configured quota.
|
|
86
|
+
|
|
87
|
+
## GLB defaults
|
|
88
|
+
|
|
89
|
+
`c.buildings.glb(...)` applies a neutral, glTF-consumption-friendly default set
|
|
90
|
+
for frame/origin/rotation. These differ from the raw REST-API defaults: the API
|
|
91
|
+
itself does **not** rotate, while the SDK rotates Z-up → Y-up so the model
|
|
92
|
+
stands upright out-of-the-box in Blender / three.js / model-viewer / Cesium
|
|
93
|
+
(glTF is Y-up per spec).
|
|
94
|
+
|
|
95
|
+
| param | raw API default | SDK default | why the SDK differs |
|
|
96
|
+
|----------------|-----------------|-------------|-------------------------------------------|
|
|
97
|
+
| `target_frame` | `utm` | `utm` | metric, scale-true (not MapLibre-bound) |
|
|
98
|
+
| `origin` | `center` | `center` | model centred on the origin |
|
|
99
|
+
| `rotate_x` | `0` | `-90` | Z-up → Y-up for glTF-spec conformance |
|
|
100
|
+
| `rotate_z` | `0` | `0` | no extra spin around the vertical axis |
|
|
101
|
+
|
|
102
|
+
All other GLB params (`z_base`, `compression`, `colorize_roofs`,
|
|
103
|
+
`merge_buildings`, `include_ground`, `weld_tolerance_m`) pass through to the
|
|
104
|
+
API's own defaults when omitted. Every default is overridable per call:
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
# Reproduce the scenerii-App view (MapLibre-aligned):
|
|
108
|
+
c.buildings.glb(
|
|
109
|
+
bbox=(13.40, 52.51, 13.41, 52.52),
|
|
110
|
+
target_frame="mercator", origin="corner", rotate_x=-90, rotate_z=-90,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
# Get raw geospatial Z-up (no rotation, matches the raw API):
|
|
114
|
+
c.buildings.glb(bbox=(13.40, 52.51, 13.41, 52.52), rotate_x=0)
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Errors
|
|
118
|
+
|
|
119
|
+
All API errors follow RFC 7807 `application/problem+json` (base
|
|
120
|
+
`https://lodapi.de/errors`). The SDK parses the document onto a typed exception:
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
from lodapi import LodapiError, AuthError, NotFoundError, RateLimitError, ApiError
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
c.buildings.get("NOPE")
|
|
127
|
+
except NotFoundError as exc:
|
|
128
|
+
print(exc.status, exc.title, exc.detail, exc.instance)
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
- `AuthError` — 401
|
|
132
|
+
- `NotFoundError` — 404
|
|
133
|
+
- `RateLimitError` — 429 (reserved; quota is soft today)
|
|
134
|
+
- `ApiError` — any other non-2xx
|
|
135
|
+
- `LodapiError` — base class + transport/network errors
|
|
136
|
+
|
|
137
|
+
Transient 5xx and network errors are retried (default 2 retries, exponential
|
|
138
|
+
backoff). 4xx are never retried.
|
|
139
|
+
|
|
140
|
+
## GeoDataFrame support (`geo` extra)
|
|
141
|
+
|
|
142
|
+
With `pip install lodapi[geo]`, building/roof FeatureCollections convert to a
|
|
143
|
+
`geopandas.GeoDataFrame` (CRS `EPSG:4326`):
|
|
144
|
+
|
|
145
|
+
```python
|
|
146
|
+
page = c.buildings(bbox=(7.0, 50.9, 7.1, 51.0))
|
|
147
|
+
gdf = page.to_geodataframe() # this page's features
|
|
148
|
+
roof = c.buildings.roof("DEBBAL0100000001")
|
|
149
|
+
roof.to_geodataframe()
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Without the extra, `to_geodataframe()` raises `ImportError` with an install
|
|
153
|
+
hint; the rest of the SDK works fine.
|
|
154
|
+
|
|
155
|
+
## Configuration
|
|
156
|
+
|
|
157
|
+
```python
|
|
158
|
+
Client(
|
|
159
|
+
api_key=None, # optional lod_* key
|
|
160
|
+
base_url="https://api.lodapi.de", # override for staging/local
|
|
161
|
+
timeout=30.0, # per-request seconds
|
|
162
|
+
max_retries=2, # transient 5xx/network only
|
|
163
|
+
)
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
## Regenerating the response models
|
|
167
|
+
|
|
168
|
+
The response models in `src/lodapi/models.py` are **hand-written**, derived from
|
|
169
|
+
the OpenAPI snapshot at `04_engineering/api/openapi/openapi.json` (the live API
|
|
170
|
+
serves it at `GET /openapi.json`). For ~11 endpoints this is leaner than full
|
|
171
|
+
codegen.
|
|
172
|
+
|
|
173
|
+
When the API surface grows or churns:
|
|
174
|
+
|
|
175
|
+
1. Refresh the snapshot (`GET /openapi.json` → the repo copy).
|
|
176
|
+
2. Either edit `models.py` by hand against the new `components.schemas`, **or**
|
|
177
|
+
adopt codegen:
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
uvx openapi-python-client generate \
|
|
181
|
+
--path 04_engineering/api/openapi/openapi.json
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
and re-point `resources/*` at the generated models. The resource facade is
|
|
185
|
+
intentionally decoupled from the model layer, so swapping the model source
|
|
186
|
+
is mechanical.
|
|
187
|
+
|
|
188
|
+
## Development
|
|
189
|
+
|
|
190
|
+
```bash
|
|
191
|
+
uv run --extra dev pytest # from code/sdk-python/
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Tests mock all HTTP with `respx` — no live calls against prod.
|
|
195
|
+
|
|
196
|
+
## License
|
|
197
|
+
|
|
198
|
+
Apache-2.0. © scenerii GmbH.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "lodapi"
|
|
7
|
+
version = "0.0.1"
|
|
8
|
+
description = "Ergonomic Python SDK for the Lodapi REST API (LoD2 buildings, terrain, 3D tiles)."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "Apache-2.0" }
|
|
12
|
+
authors = [{ name = "scenerii GmbH", email = "konsti@scenerii.com" }]
|
|
13
|
+
keywords = ["lodapi", "lod2", "citygml", "3d-tiles", "terrain", "geospatial", "gis"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: Apache Software License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.10",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Topic :: Scientific/Engineering :: GIS",
|
|
23
|
+
]
|
|
24
|
+
dependencies = [
|
|
25
|
+
"httpx>=0.27",
|
|
26
|
+
"pydantic>=2",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[project.optional-dependencies]
|
|
30
|
+
# Optional GeoDataFrame support: `pip install lodapi[geo]`.
|
|
31
|
+
geo = [
|
|
32
|
+
"geopandas>=0.14",
|
|
33
|
+
"shapely>=2.0",
|
|
34
|
+
]
|
|
35
|
+
dev = [
|
|
36
|
+
"pytest>=8",
|
|
37
|
+
"respx>=0.21",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
[project.urls]
|
|
41
|
+
Homepage = "https://lodapi.de"
|
|
42
|
+
Documentation = "https://lodapi.de/docs"
|
|
43
|
+
|
|
44
|
+
[tool.hatch.build.targets.wheel]
|
|
45
|
+
packages = ["src/lodapi"]
|
|
46
|
+
|
|
47
|
+
[tool.pytest.ini_options]
|
|
48
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""lodapi — ergonomic Python SDK for the Lodapi REST API.
|
|
2
|
+
|
|
3
|
+
from lodapi import Client
|
|
4
|
+
c = Client(api_key="lod_...") # api_key optional → free tier
|
|
5
|
+
page = c.buildings(bbox=(7.0, 50.9, 7.1, 51.0), limit=500)
|
|
6
|
+
|
|
7
|
+
v0 / unstable: the public surface may change before 0.1. See the README.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from ._http import QuotaInfo
|
|
13
|
+
from .client import Client, __version__
|
|
14
|
+
from .exceptions import (
|
|
15
|
+
ApiError,
|
|
16
|
+
AuthError,
|
|
17
|
+
LodapiError,
|
|
18
|
+
NotFoundError,
|
|
19
|
+
RateLimitError,
|
|
20
|
+
)
|
|
21
|
+
from .models import (
|
|
22
|
+
BuildingDetail,
|
|
23
|
+
BuildingRoofResponse,
|
|
24
|
+
BuildingsBboxResponse,
|
|
25
|
+
Dataset,
|
|
26
|
+
DatasetListResponse,
|
|
27
|
+
ElevationResponse,
|
|
28
|
+
TerrainDataset,
|
|
29
|
+
TerrainDatasetListResponse,
|
|
30
|
+
TerrainMeshDataset,
|
|
31
|
+
TerrainMeshDatasetListResponse,
|
|
32
|
+
TerrainProfileResponse,
|
|
33
|
+
Tileset,
|
|
34
|
+
TilesetListResponse,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"Client",
|
|
39
|
+
"QuotaInfo",
|
|
40
|
+
"__version__",
|
|
41
|
+
# exceptions
|
|
42
|
+
"LodapiError",
|
|
43
|
+
"AuthError",
|
|
44
|
+
"NotFoundError",
|
|
45
|
+
"RateLimitError",
|
|
46
|
+
"ApiError",
|
|
47
|
+
# models
|
|
48
|
+
"BuildingsBboxResponse",
|
|
49
|
+
"BuildingDetail",
|
|
50
|
+
"BuildingRoofResponse",
|
|
51
|
+
"DatasetListResponse",
|
|
52
|
+
"Dataset",
|
|
53
|
+
"ElevationResponse",
|
|
54
|
+
"TerrainProfileResponse",
|
|
55
|
+
"TerrainDatasetListResponse",
|
|
56
|
+
"TerrainDataset",
|
|
57
|
+
"TerrainMeshDatasetListResponse",
|
|
58
|
+
"TerrainMeshDataset",
|
|
59
|
+
"TilesetListResponse",
|
|
60
|
+
"Tileset",
|
|
61
|
+
]
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Optional GeoDataFrame conversion (``pip install lodapi[geo]``).
|
|
2
|
+
|
|
3
|
+
Kept in its own module with a lazy import so the core SDK imports fine
|
|
4
|
+
without geopandas/shapely installed. ``to_geodataframe`` raises a clear,
|
|
5
|
+
actionable error when the extra is missing.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any, Sequence
|
|
11
|
+
|
|
12
|
+
_INSTALL_HINT = (
|
|
13
|
+
"GeoDataFrame support requires the optional 'geo' extra. "
|
|
14
|
+
"Install it with: pip install lodapi[geo]"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def features_to_geodataframe(features: Sequence[dict[str, Any]]) -> Any:
|
|
19
|
+
"""Convert a list of GeoJSON features to a ``geopandas.GeoDataFrame``.
|
|
20
|
+
|
|
21
|
+
Raises ``ImportError`` with an install hint when geopandas is absent.
|
|
22
|
+
"""
|
|
23
|
+
try:
|
|
24
|
+
import geopandas as gpd # noqa: PLC0415 — lazy by design
|
|
25
|
+
except ImportError as exc: # pragma: no cover - exercised via monkeypatch
|
|
26
|
+
raise ImportError(_INSTALL_HINT) from exc
|
|
27
|
+
|
|
28
|
+
# GeoDataFrame.from_features handles the GeoJSON Feature shape directly.
|
|
29
|
+
# Buildings are WGS84 (EPSG:4326).
|
|
30
|
+
return gpd.GeoDataFrame.from_features(list(features), crs="EPSG:4326")
|