lodapi 0.0.1__py3-none-any.whl

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/__init__.py ADDED
@@ -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
+ ]
lodapi/_geo.py ADDED
@@ -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")
lodapi/_http.py ADDED
@@ -0,0 +1,227 @@
1
+ """Thin sync HTTP transport over httpx.
2
+
3
+ Owns the cross-cutting concerns shared by every resource:
4
+
5
+ - ``X-API-Key`` header injection (only when a key is configured; the Lodapi
6
+ free tier is anonymous, see ADR-0014).
7
+ - RFC 7807 ``application/problem+json`` error parsing → typed exceptions.
8
+ - Soft-quota header capture (``X-Lodapi-Quota-Used`` / ``-Limit``) into a
9
+ ``QuotaInfo`` exposed on the client as ``last_quota`` — never an error.
10
+ - Retries on transient 5xx and network/timeout errors with exponential
11
+ backoff. **Never** retries 4xx (those are deterministic).
12
+
13
+ v0 is sync only. The structure (a single ``_request`` chokepoint) is kept so
14
+ an async sibling (``_AsyncHttpClient`` over ``httpx.AsyncClient``) can be
15
+ added later without touching the resources.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import time
21
+ from dataclasses import dataclass
22
+ from typing import Any, Optional
23
+
24
+ import httpx
25
+
26
+ from .exceptions import ApiError, LodapiError
27
+
28
+ DEFAULT_BASE_URL = "https://api.lodapi.de"
29
+ DEFAULT_TIMEOUT = 30.0
30
+ DEFAULT_MAX_RETRIES = 2 # => up to 3 total attempts on transient failures
31
+ _RETRY_BACKOFF_BASE = 0.5 # seconds; doubled each retry
32
+
33
+ _QUOTA_USED_HEADER = "X-Lodapi-Quota-Used"
34
+ _QUOTA_LIMIT_HEADER = "X-Lodapi-Quota-Limit"
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class QuotaInfo:
39
+ """Soft-quota visibility from the last response's headers (ADR-0014).
40
+
41
+ ``used`` / ``limit`` are ``None`` when the header was absent (anonymous
42
+ calls, or a key without a configured monthly quota). Quota is *soft* —
43
+ exceeding it is never an error in the MVP.
44
+ """
45
+
46
+ used: Optional[int] = None
47
+ limit: Optional[int] = None
48
+
49
+ @property
50
+ def remaining(self) -> Optional[int]:
51
+ if self.used is None or self.limit is None:
52
+ return None
53
+ return max(0, self.limit - self.used)
54
+
55
+
56
+ def _parse_quota(headers: httpx.Headers) -> QuotaInfo:
57
+ def _int(name: str) -> Optional[int]:
58
+ raw = headers.get(name)
59
+ if raw is None:
60
+ return None
61
+ try:
62
+ return int(raw)
63
+ except (TypeError, ValueError):
64
+ return None
65
+
66
+ return QuotaInfo(used=_int(_QUOTA_USED_HEADER), limit=_int(_QUOTA_LIMIT_HEADER))
67
+
68
+
69
+ class HttpClient:
70
+ """Synchronous HTTP client wrapping a long-lived ``httpx.Client``."""
71
+
72
+ def __init__(
73
+ self,
74
+ *,
75
+ api_key: str | None,
76
+ base_url: str,
77
+ timeout: float,
78
+ max_retries: int,
79
+ user_agent: str,
80
+ transport: httpx.BaseTransport | None = None,
81
+ ) -> None:
82
+ headers = {
83
+ "User-Agent": user_agent,
84
+ "Accept": "application/json",
85
+ }
86
+ if api_key:
87
+ headers["X-API-Key"] = api_key
88
+ self._max_retries = max(0, max_retries)
89
+ self.last_quota: QuotaInfo = QuotaInfo()
90
+ self._client = httpx.Client(
91
+ base_url=base_url.rstrip("/"),
92
+ timeout=timeout,
93
+ headers=headers,
94
+ transport=transport,
95
+ follow_redirects=True,
96
+ )
97
+
98
+ # -- lifecycle ---------------------------------------------------------
99
+
100
+ def close(self) -> None:
101
+ self._client.close()
102
+
103
+ def __enter__(self) -> "HttpClient":
104
+ return self
105
+
106
+ def __exit__(self, *exc: object) -> None:
107
+ self.close()
108
+
109
+ # -- core --------------------------------------------------------------
110
+
111
+ def _send(self, request: httpx.Request) -> httpx.Response:
112
+ """Send with retry on transient 5xx / network errors. Never on 4xx."""
113
+ attempt = 0
114
+ while True:
115
+ try:
116
+ response = self._client.send(request, stream=True)
117
+ except (httpx.TransportError, httpx.TimeoutException) as exc:
118
+ if attempt >= self._max_retries:
119
+ raise LodapiError(
120
+ f"Network error after {attempt + 1} attempt(s): {exc}"
121
+ ) from exc
122
+ time.sleep(_RETRY_BACKOFF_BASE * (2 ** attempt))
123
+ attempt += 1
124
+ continue
125
+
126
+ # Retry transient server errors only.
127
+ if response.status_code >= 500 and attempt < self._max_retries:
128
+ response.close()
129
+ time.sleep(_RETRY_BACKOFF_BASE * (2 ** attempt))
130
+ attempt += 1
131
+ continue
132
+ return response
133
+
134
+ def _raise_for_status(self, response: httpx.Response) -> None:
135
+ if response.status_code < 400:
136
+ return
137
+ # Read the (possibly problem+json) body for diagnostics.
138
+ try:
139
+ response.read()
140
+ body: Any = response.json()
141
+ except Exception: # noqa: BLE001 — body may be empty / non-JSON
142
+ body = response.text or None
143
+ raise LodapiError.from_problem(response.status_code, body)
144
+
145
+ def request_json(
146
+ self,
147
+ method: str,
148
+ path: str,
149
+ *,
150
+ params: dict[str, Any] | None = None,
151
+ ) -> Any:
152
+ """Perform a request and return the decoded JSON body."""
153
+ request = self._client.build_request(method, path, params=_clean(params))
154
+ response = self._send(request)
155
+ try:
156
+ self.last_quota = _parse_quota(response.headers)
157
+ self._raise_for_status(response)
158
+ response.read()
159
+ try:
160
+ return response.json()
161
+ except Exception as exc: # noqa: BLE001
162
+ raise ApiError(
163
+ f"Expected JSON from {path} but could not decode body."
164
+ ) from exc
165
+ finally:
166
+ response.close()
167
+
168
+ def request_bytes(
169
+ self,
170
+ method: str,
171
+ path: str,
172
+ *,
173
+ params: dict[str, Any] | None = None,
174
+ out: Any | None = None,
175
+ ) -> bytes | int:
176
+ """Perform a request and return raw bytes, or stream to ``out``.
177
+
178
+ - ``out=None`` → returns the full ``bytes`` body.
179
+ - ``out`` is a path / file-like → streams the body there and returns
180
+ the number of bytes written.
181
+
182
+ If the server answers with JSON (the GLB endpoint may, e.g. on an
183
+ empty bbox), that JSON is treated as an error/diagnostic and raised as
184
+ ``ApiError`` so callers don't silently write a JSON blob to ``city.glb``.
185
+ """
186
+ request = self._client.build_request(method, path, params=_clean(params))
187
+ response = self._send(request)
188
+ try:
189
+ self.last_quota = _parse_quota(response.headers)
190
+ self._raise_for_status(response)
191
+
192
+ content_type = response.headers.get("content-type", "")
193
+ if content_type.startswith("application/json"):
194
+ response.read()
195
+ try:
196
+ body = response.json()
197
+ except Exception: # noqa: BLE001
198
+ body = response.text
199
+ raise ApiError(
200
+ "Server returned JSON instead of a binary GLB "
201
+ "(likely an empty bbox or a diagnostic message).",
202
+ status=response.status_code,
203
+ raw=body,
204
+ )
205
+
206
+ if out is None:
207
+ response.read()
208
+ return response.content
209
+
210
+ written = 0
211
+ if hasattr(out, "write"):
212
+ for chunk in response.iter_bytes():
213
+ written += out.write(chunk)
214
+ else:
215
+ with open(out, "wb") as fh:
216
+ for chunk in response.iter_bytes():
217
+ written += fh.write(chunk)
218
+ return written
219
+ finally:
220
+ response.close()
221
+
222
+
223
+ def _clean(params: dict[str, Any] | None) -> dict[str, Any] | None:
224
+ """Drop ``None`` values so we don't send empty query params."""
225
+ if not params:
226
+ return params
227
+ return {k: v for k, v in params.items() if v is not None}
lodapi/client.py ADDED
@@ -0,0 +1,108 @@
1
+ """The top-level :class:`Client` — the SDK entry point.
2
+
3
+ from lodapi import Client
4
+
5
+ c = Client(api_key="lod_...") # api_key optional (None => free tier)
6
+ page = c.buildings(bbox=(7.0, 50.9, 7.1, 51.0), limit=500)
7
+ for feat in c.buildings.iter(bbox=(7.0, 50.9, 7.1, 51.0)):
8
+ ...
9
+ h = c.terrain.elevation(lat=50.94, lon=7.05)
10
+ ds = c.datasets()
11
+ c.last_quota # soft-quota visibility (or None-ish)
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from importlib.metadata import PackageNotFoundError, version
17
+ from typing import Optional
18
+
19
+ import httpx
20
+
21
+ from ._http import (
22
+ DEFAULT_BASE_URL,
23
+ DEFAULT_MAX_RETRIES,
24
+ DEFAULT_TIMEOUT,
25
+ HttpClient,
26
+ QuotaInfo,
27
+ )
28
+ from .models import DatasetListResponse
29
+ from .resources.buildings import BuildingsResource
30
+ from .resources.terrain import TerrainResource
31
+ from .resources.tilesets import TilesetsResource
32
+
33
+ try:
34
+ __version__ = version("lodapi")
35
+ except PackageNotFoundError: # pragma: no cover - editable/uninstalled
36
+ __version__ = "0.0.1"
37
+
38
+
39
+ class Client:
40
+ """Synchronous client for the Lodapi REST API.
41
+
42
+ Parameters
43
+ ----------
44
+ api_key:
45
+ Optional ``lod_*`` API key. ``None`` (the default) uses the anonymous
46
+ free tier. A malformed/unknown/revoked key raises ``AuthError`` on the
47
+ first call.
48
+ base_url:
49
+ API base URL. Defaults to ``https://api.lodapi.de``.
50
+ timeout:
51
+ Per-request timeout in seconds (default 30).
52
+ max_retries:
53
+ Retries on transient 5xx / network errors (default 2 → up to 3 tries).
54
+ 4xx are never retried.
55
+ transport:
56
+ Optional ``httpx.BaseTransport`` (e.g. for tests / custom routing).
57
+ """
58
+
59
+ def __init__(
60
+ self,
61
+ api_key: Optional[str] = None,
62
+ *,
63
+ base_url: str = DEFAULT_BASE_URL,
64
+ timeout: float = DEFAULT_TIMEOUT,
65
+ max_retries: int = DEFAULT_MAX_RETRIES,
66
+ transport: httpx.BaseTransport | None = None,
67
+ ) -> None:
68
+ self._http = HttpClient(
69
+ api_key=api_key,
70
+ base_url=base_url,
71
+ timeout=timeout,
72
+ max_retries=max_retries,
73
+ user_agent=f"lodapi-python/{__version__}",
74
+ transport=transport,
75
+ )
76
+ self.buildings = BuildingsResource(self._http)
77
+ self.terrain = TerrainResource(self._http)
78
+ self.tilesets = TilesetsResource(self._http)
79
+
80
+ # -- top-level endpoints ----------------------------------------------
81
+
82
+ def datasets(self) -> DatasetListResponse:
83
+ """GET /v1/datasets — available LoD2 datasets (one per BL snapshot)."""
84
+ data = self._http.request_json("GET", "/v1/datasets")
85
+ return DatasetListResponse.model_validate(data)
86
+
87
+ # -- quota visibility --------------------------------------------------
88
+
89
+ @property
90
+ def last_quota(self) -> QuotaInfo:
91
+ """Soft-quota figures (``used`` / ``limit``) from the last response.
92
+
93
+ Populated from ``X-Lodapi-Quota-*`` headers (ADR-0014). Both fields are
94
+ ``None`` for anonymous calls or keys without a configured quota.
95
+ Exceeding quota is **not** an error in the MVP.
96
+ """
97
+ return self._http.last_quota
98
+
99
+ # -- lifecycle ---------------------------------------------------------
100
+
101
+ def close(self) -> None:
102
+ self._http.close()
103
+
104
+ def __enter__(self) -> "Client":
105
+ return self
106
+
107
+ def __exit__(self, *exc: object) -> None:
108
+ self.close()
lodapi/exceptions.py ADDED
@@ -0,0 +1,102 @@
1
+ """Exception hierarchy for the Lodapi SDK.
2
+
3
+ All API errors follow the RFC 7807 ``application/problem+json`` contract
4
+ (base ``https://lodapi.de/errors``). When the server returns a problem
5
+ document we parse its fields (``type``, ``title``, ``status``, ``detail``,
6
+ ``instance``) onto the raised exception so callers can introspect them.
7
+
8
+ try:
9
+ c.buildings(bbox=(...))
10
+ except AuthError as exc:
11
+ print(exc.status, exc.detail) # 401, "Malformed API key ..."
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import Any
17
+
18
+
19
+ class LodapiError(Exception):
20
+ """Base class for every error raised by the SDK.
21
+
22
+ Attributes mirror the RFC 7807 problem document. They may be ``None`` if
23
+ the server response was not a problem+json body (e.g. a transport error or
24
+ an unexpected non-JSON payload).
25
+ """
26
+
27
+ def __init__(
28
+ self,
29
+ message: str,
30
+ *,
31
+ status: int | None = None,
32
+ type: str | None = None,
33
+ title: str | None = None,
34
+ detail: str | None = None,
35
+ instance: str | None = None,
36
+ raw: Any | None = None,
37
+ ) -> None:
38
+ super().__init__(message)
39
+ self.status = status
40
+ self.type = type
41
+ self.title = title
42
+ self.detail = detail
43
+ self.instance = instance
44
+ # The raw decoded body (dict) or text, for debugging / forward-compat.
45
+ self.raw = raw
46
+
47
+ @classmethod
48
+ def from_problem(
49
+ cls,
50
+ status_code: int,
51
+ body: Any,
52
+ *,
53
+ fallback_message: str | None = None,
54
+ ) -> "LodapiError":
55
+ """Build the most specific subclass from an HTTP status + parsed body.
56
+
57
+ ``body`` is the decoded JSON (ideally an RFC 7807 dict) or, when the
58
+ response wasn't JSON, the raw text / ``None``.
59
+ """
60
+ problem: dict[str, Any] = body if isinstance(body, dict) else {}
61
+ detail = problem.get("detail")
62
+ title = problem.get("title")
63
+ message = detail or title or fallback_message or f"HTTP {status_code}"
64
+
65
+ exc_cls = _STATUS_MAP.get(status_code, ApiError)
66
+ return exc_cls(
67
+ message,
68
+ status=problem.get("status", status_code),
69
+ type=problem.get("type"),
70
+ title=title,
71
+ detail=detail,
72
+ instance=problem.get("instance"),
73
+ raw=body,
74
+ )
75
+
76
+
77
+ class AuthError(LodapiError):
78
+ """401 — malformed, unknown, or revoked API key."""
79
+
80
+
81
+ class NotFoundError(LodapiError):
82
+ """404 — resource (building / tileset / …) not found."""
83
+
84
+
85
+ class RateLimitError(LodapiError):
86
+ """429 — too many requests.
87
+
88
+ The Concierge-Billing MVP treats quota as *soft* (no 429 today, see
89
+ ADR-0014), but the SDK maps 429 here in case hard limits land later.
90
+ """
91
+
92
+
93
+ class ApiError(LodapiError):
94
+ """Any other non-2xx API response (4xx/5xx not covered above)."""
95
+
96
+
97
+ # Status-code → exception subclass. Anything unmapped falls back to ApiError.
98
+ _STATUS_MAP: dict[int, type[LodapiError]] = {
99
+ 401: AuthError,
100
+ 404: NotFoundError,
101
+ 429: RateLimitError,
102
+ }
lodapi/models.py ADDED
@@ -0,0 +1,234 @@
1
+ """Pydantic v2 response models for the Lodapi SDK.
2
+
3
+ These are *hand-written*, derived from the OpenAPI snapshot at
4
+ ``04_engineering/api/openapi/openapi.json`` (components.schemas + the 200
5
+ responses of the public endpoints). For v0 with ~11 endpoints this is leaner
6
+ and more maintainable than a full codegen (openapi-python-client) step.
7
+
8
+ Regeneration / future codegen path
9
+ -----------------------------------
10
+ When the surface grows or churns, the path is:
11
+
12
+ 1. Refresh the snapshot: the API emits it at ``GET /openapi.json``; the
13
+ repo copy lives at ``04_engineering/api/openapi/openapi.json``.
14
+ 2. Either keep editing this file by hand against the new schemas, OR adopt
15
+ codegen, e.g.::
16
+
17
+ uvx openapi-python-client generate \\
18
+ --path 04_engineering/api/openapi/openapi.json
19
+
20
+ and re-point the resources/* at the generated models. The resource
21
+ facade (``client.py`` + ``resources/``) is intentionally decoupled from
22
+ the model layer so swapping the model source is mechanical.
23
+
24
+ Design notes
25
+ ------------
26
+ - ``model_config = ConfigDict(extra="allow")`` everywhere: the API is young
27
+ and may add fields; we never want a new server field to raise on parse.
28
+ - GeoJSON Feature lists are typed as ``list[dict]`` (matching the API's own
29
+ choice — see TerrainProfileResponse docstring in the OpenAPI) because the
30
+ feature schema is polymorphic and strict typing would cost more than it pays.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ from datetime import date, datetime
36
+ from typing import Any, Optional
37
+
38
+ from pydantic import BaseModel, ConfigDict, Field
39
+
40
+
41
+ class _Model(BaseModel):
42
+ model_config = ConfigDict(extra="allow")
43
+
44
+
45
+ # --------------------------------------------------------------------------
46
+ # /v1/buildings
47
+ # --------------------------------------------------------------------------
48
+
49
+ class BuildingsBboxAttribution(_Model):
50
+ bundesland: str
51
+ license: Optional[str] = None
52
+ attribution_text: Optional[str] = None
53
+
54
+
55
+ class BuildingsBboxLodapiMeta(_Model):
56
+ next: Optional[str] = None
57
+ attribution: list[BuildingsBboxAttribution] = Field(default_factory=list)
58
+
59
+
60
+ class BuildingsBboxResponse(_Model):
61
+ """GET /v1/buildings — federated GeoJSON FeatureCollection for a WGS84 bbox."""
62
+
63
+ type: str = "FeatureCollection"
64
+ bbox: list[float]
65
+ count: int
66
+ limit: int
67
+ features: list[dict[str, Any]]
68
+ lodapi: BuildingsBboxLodapiMeta
69
+
70
+ @property
71
+ def next_cursor(self) -> Optional[str]:
72
+ """Opaque cursor for the next page, or ``None`` when exhausted."""
73
+ return self.lodapi.next
74
+
75
+ def to_geodataframe(self) -> Any:
76
+ """Convert this page's features to a ``geopandas.GeoDataFrame``.
77
+
78
+ Requires the optional ``geo`` extra (``pip install lodapi[geo]``);
79
+ raises ``ImportError`` with an install hint otherwise. Note this
80
+ converts only the *current page* — collect features across pages via
81
+ ``client.buildings.iter()`` if you need everything.
82
+ """
83
+ from ._geo import features_to_geodataframe
84
+
85
+ return features_to_geodataframe(self.features)
86
+
87
+
88
+ # --------------------------------------------------------------------------
89
+ # /v1/buildings/{gmlid} and /v1/buildings/{gmlid}/roof
90
+ # --------------------------------------------------------------------------
91
+
92
+ class BuildingDetail(_Model):
93
+ gmlid: str
94
+ bundesland_code: str
95
+ building_id: int
96
+ geometry: Optional[dict[str, Any]] = None
97
+ lod: str = "LoD2"
98
+
99
+
100
+ class BuildingRoofResponse(_Model):
101
+ """GET /v1/buildings/{gmlid}/roof — GeoJSON FeatureCollection of RoofSurfaces."""
102
+
103
+ type: str = "FeatureCollection"
104
+ features: list[dict[str, Any]]
105
+ count: int
106
+
107
+ def to_geodataframe(self) -> Any:
108
+ """Convert the RoofSurface features to a ``geopandas.GeoDataFrame``.
109
+
110
+ Requires the optional ``geo`` extra (``pip install lodapi[geo]``).
111
+ """
112
+ from ._geo import features_to_geodataframe
113
+
114
+ return features_to_geodataframe(self.features)
115
+
116
+
117
+ # --------------------------------------------------------------------------
118
+ # /v1/datasets
119
+ # --------------------------------------------------------------------------
120
+
121
+ class Dataset(_Model):
122
+ id: str
123
+ bundesland_code: str
124
+ name: str
125
+ license_id: str
126
+ snapshot_date: Optional[date] = None
127
+ building_count: Optional[int] = None
128
+ validation_pass_pct: Optional[float] = None
129
+ source_url: Optional[str] = None
130
+ last_sync: Optional[datetime] = None
131
+
132
+
133
+ class DatasetListResponse(_Model):
134
+ datasets: list[Dataset]
135
+ count: int
136
+
137
+
138
+ # --------------------------------------------------------------------------
139
+ # /v1/terrain/datasets
140
+ # --------------------------------------------------------------------------
141
+
142
+ class TerrainDataset(_Model):
143
+ bl: str
144
+ snapshot: Optional[str] = None
145
+ source: Optional[str] = None
146
+ license: Optional[str] = None
147
+ attribution: Optional[str] = None
148
+ crs: Optional[str] = None
149
+ vertical_datum: Optional[str] = None
150
+ format: Optional[str] = None
151
+ tile_count: Optional[int] = None
152
+ coverage_pct: Optional[float] = None
153
+
154
+
155
+ class TerrainDatasetListResponse(_Model):
156
+ datasets: list[TerrainDataset]
157
+ count: int
158
+
159
+
160
+ # --------------------------------------------------------------------------
161
+ # /v1/terrain-mesh/datasets
162
+ # --------------------------------------------------------------------------
163
+
164
+ class TerrainMeshDataset(_Model):
165
+ bundesland_code: str
166
+ snapshot_date: str
167
+ tileset_url: str
168
+ license: str
169
+ built_at: str
170
+ attribution: Optional[str] = None
171
+ tile_count: Optional[int] = None
172
+ tiling_scheme: Optional[str] = None
173
+ rtin_tolerance_m: Optional[float] = None
174
+
175
+
176
+ class TerrainMeshDatasetListResponse(_Model):
177
+ datasets: list[TerrainMeshDataset]
178
+
179
+
180
+ # --------------------------------------------------------------------------
181
+ # /v1/terrain/elevation
182
+ # --------------------------------------------------------------------------
183
+
184
+ class ElevationResponse(_Model):
185
+ elevation_m: float
186
+ datum: str
187
+ source_bl: str
188
+ snapshot: str
189
+ license: str
190
+ attribution: str
191
+ tile_id: str
192
+
193
+
194
+ # --------------------------------------------------------------------------
195
+ # /v1/terrain/profile
196
+ # --------------------------------------------------------------------------
197
+
198
+ class TerrainProfileProperties(_Model):
199
+ datum: str
200
+ coords_count: int
201
+ samples: int
202
+ total_length_m: float
203
+ partial: bool
204
+ failed_tile_ids: list[str] = Field(default_factory=list)
205
+
206
+
207
+ class TerrainProfileResponse(_Model):
208
+ type: str = "FeatureCollection"
209
+ properties: TerrainProfileProperties
210
+ features: list[dict[str, Any]]
211
+
212
+
213
+ # --------------------------------------------------------------------------
214
+ # /v1/tilesets and /v1/tilesets/{tileset_id}
215
+ # --------------------------------------------------------------------------
216
+
217
+ class Tileset(_Model):
218
+ tileset_id: str
219
+ region_code: str
220
+ bundesland_code: str
221
+ s3_key: str
222
+ generated_at: datetime
223
+ snapshot_date: Optional[date] = None
224
+ building_count: Optional[int] = None
225
+ tile_count: Optional[int] = None
226
+ total_bytes: Optional[int] = None
227
+ tileset_url: Optional[str] = None
228
+ bounding_volume: Optional[dict[str, Any]] = None
229
+
230
+
231
+ class TilesetListResponse(_Model):
232
+ tilesets: list[Tileset]
233
+ count: int
234
+ bbox: list[float]
@@ -0,0 +1,29 @@
1
+ """Resource facades for the Lodapi SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Sequence, Union
6
+
7
+ BBox = Union[Sequence[float], str]
8
+
9
+
10
+ def serialize_bbox(bbox: BBox) -> str:
11
+ """Serialize a bbox to the API's ``minLon,minLat,maxLon,maxLat`` string.
12
+
13
+ Accepts either a 4-tuple/list of floats ``(minx, miny, maxx, maxy)`` or an
14
+ already-formatted string (passed through untouched).
15
+ """
16
+ if isinstance(bbox, str):
17
+ return bbox
18
+ values = list(bbox)
19
+ if len(values) != 4:
20
+ raise ValueError(
21
+ f"bbox must be (minLon, minLat, maxLon, maxLat) — got {len(values)} values."
22
+ )
23
+ return ",".join(_fmt(v) for v in values)
24
+
25
+
26
+ def _fmt(value: float) -> str:
27
+ """Format a coordinate without trailing-zero noise (7.0 -> '7.0')."""
28
+ # repr keeps full float precision; good enough and round-trips cleanly.
29
+ return repr(float(value))
@@ -0,0 +1,160 @@
1
+ """``client.buildings`` — the buildings resource facade.
2
+
3
+ The instance is *callable* (one page) and also exposes ``.iter()`` (transparent
4
+ cursor pagination), ``.get()``, ``.roof()`` and ``.glb()``.
5
+
6
+ c.buildings(bbox=(7.0, 50.9, 7.1, 51.0), limit=500) # one BuildingsBboxResponse
7
+ for feature in c.buildings.iter(bbox=(...)): # all features, paged
8
+ ...
9
+ c.buildings.get("DEBBAL...") # BuildingDetail
10
+ c.buildings.roof("DEBBAL...") # BuildingRoofResponse
11
+ c.buildings.glb(bbox=(...), out="city.glb") # stream GLB to disk
12
+
13
+ NB: there is **no** ``bl`` parameter — federation across the 16 Bundesländer
14
+ is server-side and automatic.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from typing import Any, Iterator, Optional
20
+ from urllib.parse import quote
21
+
22
+ from .._http import HttpClient
23
+ from ..models import BuildingDetail, BuildingRoofResponse, BuildingsBboxResponse
24
+ from . import BBox, serialize_bbox
25
+
26
+ # Neutral SDK defaults for the GLB endpoint, tuned for general glTF consumption
27
+ # (Blender / three.js / model-viewer / Cesium) rather than any one app:
28
+ # - target_frame="utm": metric, scale-true coordinates (not MapLibre-specific).
29
+ # - origin="center": model centred on the origin.
30
+ # - rotate_x=-90: Z-up (geospatial) → Y-up. glTF is Y-up per spec, so
31
+ # this makes the GLB stand upright out-of-the-box.
32
+ # - rotate_z=0: no extra spin around the vertical axis.
33
+ # These differ from the raw API defaults (utm / center / 0 / 0 — i.e. the API
34
+ # does NOT rotate) — see README "GLB defaults".
35
+ _GLB_SDK_DEFAULTS: dict[str, Any] = {
36
+ "target_frame": "utm",
37
+ "origin": "center",
38
+ "rotate_x": -90,
39
+ "rotate_z": 0,
40
+ }
41
+
42
+
43
+ class BuildingsResource:
44
+ def __init__(self, http: HttpClient) -> None:
45
+ self._http = http
46
+
47
+ # -- one page (callable) ----------------------------------------------
48
+
49
+ def __call__(
50
+ self,
51
+ *,
52
+ bbox: BBox,
53
+ limit: Optional[int] = None,
54
+ cursor: Optional[str] = None,
55
+ ) -> BuildingsBboxResponse:
56
+ """Fetch a single page of buildings for a WGS84 bbox.
57
+
58
+ Use ``response.next_cursor`` to fetch the next page manually, or
59
+ prefer :meth:`iter` for transparent pagination.
60
+ """
61
+ data = self._http.request_json(
62
+ "GET",
63
+ "/v1/buildings",
64
+ params={"bbox": serialize_bbox(bbox), "limit": limit, "cursor": cursor},
65
+ )
66
+ return BuildingsBboxResponse.model_validate(data)
67
+
68
+ # -- pagination iterators ---------------------------------------------
69
+
70
+ def pages(
71
+ self,
72
+ *,
73
+ bbox: BBox,
74
+ limit: Optional[int] = None,
75
+ ) -> Iterator[BuildingsBboxResponse]:
76
+ """Yield each page (``BuildingsBboxResponse``), following the cursor."""
77
+ cursor: Optional[str] = None
78
+ while True:
79
+ page = self(bbox=bbox, limit=limit, cursor=cursor)
80
+ yield page
81
+ cursor = page.next_cursor
82
+ if not cursor:
83
+ return
84
+
85
+ def iter(
86
+ self,
87
+ *,
88
+ bbox: BBox,
89
+ limit: Optional[int] = None,
90
+ ) -> Iterator[dict[str, Any]]:
91
+ """Yield individual GeoJSON building features across all pages."""
92
+ for page in self.pages(bbox=bbox, limit=limit):
93
+ yield from page.features
94
+
95
+ # -- single building ---------------------------------------------------
96
+
97
+ def get(self, gmlid: str) -> BuildingDetail:
98
+ """GET /v1/buildings/{gmlid} — single-building detail."""
99
+ data = self._http.request_json(
100
+ "GET", f"/v1/buildings/{quote(gmlid, safe='')}"
101
+ )
102
+ return BuildingDetail.model_validate(data)
103
+
104
+ def roof(self, gmlid: str) -> BuildingRoofResponse:
105
+ """GET /v1/buildings/{gmlid}/roof — RoofSurfaces FeatureCollection."""
106
+ data = self._http.request_json(
107
+ "GET", f"/v1/buildings/{quote(gmlid, safe='')}/roof"
108
+ )
109
+ return BuildingRoofResponse.model_validate(data)
110
+
111
+ # -- GLB export --------------------------------------------------------
112
+
113
+ def glb(
114
+ self,
115
+ *,
116
+ bbox: BBox,
117
+ out: Any | None = None,
118
+ z_base: Optional[str] = None,
119
+ compression: Optional[str] = None,
120
+ colorize_roofs: Optional[bool] = None,
121
+ merge_buildings: Optional[bool] = None,
122
+ target_frame: Optional[str] = None,
123
+ origin: Optional[str] = None,
124
+ rotate_x: Optional[float] = None,
125
+ rotate_z: Optional[float] = None,
126
+ include_ground: Optional[bool] = None,
127
+ weld_tolerance_m: Optional[float] = None,
128
+ ) -> bytes | int:
129
+ """GET /v1/buildings/3d.glb — binary glTF for a bbox.
130
+
131
+ - ``out=None`` → returns the GLB as ``bytes``.
132
+ - ``out`` is a path or file-like → streams to it, returns bytes written.
133
+
134
+ Frame/origin/rotation default to a neutral glTF-consumption set
135
+ (``utm`` / ``center`` / ``rotate_x=-90`` / ``rotate_z=0``) so the model
136
+ is metric, centred and upright (Y-up) in common 3D tools. Pass explicit
137
+ values to override — e.g. ``rotate_x=0`` for raw geospatial Z-up, or
138
+ ``target_frame="mercator", origin="corner", rotate_x=-90, rotate_z=-90``
139
+ to reproduce the scenerii-App view.
140
+ """
141
+ params: dict[str, Any] = {
142
+ "bbox": serialize_bbox(bbox),
143
+ "z_base": z_base,
144
+ "compression": compression,
145
+ "colorize_roofs": colorize_roofs,
146
+ "merge_buildings": merge_buildings,
147
+ "target_frame": target_frame,
148
+ "origin": origin,
149
+ "rotate_x": rotate_x,
150
+ "rotate_z": rotate_z,
151
+ "include_ground": include_ground,
152
+ "weld_tolerance_m": weld_tolerance_m,
153
+ }
154
+ # Apply SDK defaults only where the caller didn't specify.
155
+ for key, default in _GLB_SDK_DEFAULTS.items():
156
+ if params.get(key) is None:
157
+ params[key] = default
158
+ return self._http.request_bytes(
159
+ "GET", "/v1/buildings/3d.glb", params=params, out=out
160
+ )
@@ -0,0 +1,75 @@
1
+ """``client.terrain`` — terrain elevation / profile / dataset discovery.
2
+
3
+ c.terrain.elevation(lat=50.94, lon=7.05)
4
+ c.terrain.profile(coords=[(7.0, 50.9), (7.1, 51.0)], samples=100)
5
+ c.terrain.datasets() # DGM tiff datasets
6
+ c.terrain.mesh_datasets() # quantized-mesh tilesets
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Optional, Sequence
12
+
13
+ from .._http import HttpClient
14
+ from ..models import (
15
+ ElevationResponse,
16
+ TerrainDatasetListResponse,
17
+ TerrainMeshDatasetListResponse,
18
+ TerrainProfileResponse,
19
+ )
20
+
21
+
22
+ def _serialize_coords(coords: Sequence[tuple[float, float]]) -> str:
23
+ """Serialize waypoints to the API's ``lon1,lat1;lon2,lat2[;...]`` form.
24
+
25
+ NB: the API expects **lon,lat** order (not lat,lon) and semicolon
26
+ separators between points — at least 2 points required.
27
+ """
28
+ pts = list(coords)
29
+ if len(pts) < 2:
30
+ raise ValueError("profile coords need at least 2 (lon, lat) waypoints.")
31
+ return ";".join(f"{repr(float(lon))},{repr(float(lat))}" for lon, lat in pts)
32
+
33
+
34
+ class TerrainResource:
35
+ def __init__(self, http: HttpClient) -> None:
36
+ self._http = http
37
+
38
+ def elevation(
39
+ self, *, lat: float, lon: float, srs: Optional[str] = None
40
+ ) -> ElevationResponse:
41
+ """GET /v1/terrain/elevation — DGM elevation at a WGS84 point."""
42
+ data = self._http.request_json(
43
+ "GET",
44
+ "/v1/terrain/elevation",
45
+ params={"lat": lat, "lon": lon, "srs": srs},
46
+ )
47
+ return ElevationResponse.model_validate(data)
48
+
49
+ def profile(
50
+ self,
51
+ *,
52
+ coords: Sequence[tuple[float, float]],
53
+ samples: Optional[int] = None,
54
+ ) -> TerrainProfileResponse:
55
+ """GET /v1/terrain/profile — elevation profile along a polyline.
56
+
57
+ ``coords`` is a sequence of ``(lon, lat)`` waypoints (WGS84), at least
58
+ two. ``samples`` is the total number of sample points (2–512).
59
+ """
60
+ data = self._http.request_json(
61
+ "GET",
62
+ "/v1/terrain/profile",
63
+ params={"coords": _serialize_coords(coords), "samples": samples},
64
+ )
65
+ return TerrainProfileResponse.model_validate(data)
66
+
67
+ def datasets(self) -> TerrainDatasetListResponse:
68
+ """GET /v1/terrain/datasets — available DGM (raster) datasets per BL."""
69
+ data = self._http.request_json("GET", "/v1/terrain/datasets")
70
+ return TerrainDatasetListResponse.model_validate(data)
71
+
72
+ def mesh_datasets(self) -> TerrainMeshDatasetListResponse:
73
+ """GET /v1/terrain-mesh/datasets — available quantized-mesh tilesets."""
74
+ data = self._http.request_json("GET", "/v1/terrain-mesh/datasets")
75
+ return TerrainMeshDatasetListResponse.model_validate(data)
@@ -0,0 +1,44 @@
1
+ """``client.tilesets`` — 3D-Tiles tileset discovery.
2
+
3
+ c.tilesets(bbox=(...)) # TilesetListResponse
4
+ c.tilesets.get("be-2024") # Tileset
5
+ c.tilesets.tileset_json("be-2024") # raw 3D-Tiles tileset.json (dict)
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+ from urllib.parse import quote
12
+
13
+ from .._http import HttpClient
14
+ from ..models import Tileset, TilesetListResponse
15
+ from . import BBox, serialize_bbox
16
+
17
+
18
+ class TilesetsResource:
19
+ def __init__(self, http: HttpClient) -> None:
20
+ self._http = http
21
+
22
+ def __call__(self, *, bbox: BBox) -> TilesetListResponse:
23
+ """GET /v1/tilesets?bbox=… — tilesets intersecting a WGS84 bbox."""
24
+ data = self._http.request_json(
25
+ "GET", "/v1/tilesets", params={"bbox": serialize_bbox(bbox)}
26
+ )
27
+ return TilesetListResponse.model_validate(data)
28
+
29
+ def get(self, tileset_id: str) -> Tileset:
30
+ """GET /v1/tilesets/{tileset_id} — single tileset metadata."""
31
+ data = self._http.request_json(
32
+ "GET", f"/v1/tilesets/{quote(tileset_id, safe='')}"
33
+ )
34
+ return Tileset.model_validate(data)
35
+
36
+ def tileset_json(self, tileset_id: str) -> dict[str, Any]:
37
+ """GET /v1/tilesets/{tileset_id}/tileset.json — the 3D-Tiles root.
38
+
39
+ Returned as a raw ``dict`` (the 3D-Tiles tileset schema is large and
40
+ owned by the OGC spec, not by Lodapi — no SDK model for it).
41
+ """
42
+ return self._http.request_json(
43
+ "GET", f"/v1/tilesets/{quote(tileset_id, safe='')}/tileset.json"
44
+ )
@@ -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.
@@ -0,0 +1,13 @@
1
+ lodapi/__init__.py,sha256=DKST9AwuRqtPTMK3pOeYseH-o_W1bJkeWwEGgEAFf7U,1391
2
+ lodapi/_geo.py,sha256=Y2Fy3j4XzKGNUbfGf5LdtrPcMctI9HYwZP5383IllCc,1093
3
+ lodapi/_http.py,sha256=I4Jt6xRupxw7SYcSSWp2WXZKH3QDoLdTq9QBVDLFmc8,7795
4
+ lodapi/client.py,sha256=LOvoX5UXkzA5MKEkN_sqnrEfZHAwbAE8LBBGm9bfmmk,3468
5
+ lodapi/exceptions.py,sha256=gouWsJ3LXA0sFmh3OgaIyJqwXMaHwsFjnBGbT8rMGVg,3066
6
+ lodapi/models.py,sha256=Qmn8TnEMdjERWKJDEzdWd1_0Vqha9hlPRYUJ8mtAvqM,7316
7
+ lodapi/resources/__init__.py,sha256=CKAP6h9kBKrRUBRQHG_J2zYSwmGZozP-9sNxokYPr8w,898
8
+ lodapi/resources/buildings.py,sha256=ZbXflP3HAgqBGdJhUPvaTQ-0c7tpv7MIiaih6h-RcjQ,6148
9
+ lodapi/resources/terrain.py,sha256=Y7NTEogD-qnGVobDa2lYq_uVcutC9ks7Mt76wxCZUag,2763
10
+ lodapi/resources/tilesets.py,sha256=tjLXdV4G4HmeNUkzI2lwj95-zMF2Vt8I2UzPhUSfCDw,1596
11
+ lodapi-0.0.1.dist-info/METADATA,sha256=LlImyjSuZcMd76t6WFcDhxRwnz0BRLXDrRJ0ip_Li4s,7984
12
+ lodapi-0.0.1.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
13
+ lodapi-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any