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.
@@ -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")