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 +61 -0
- lodapi/_geo.py +30 -0
- lodapi/_http.py +227 -0
- lodapi/client.py +108 -0
- lodapi/exceptions.py +102 -0
- lodapi/models.py +234 -0
- lodapi/resources/__init__.py +29 -0
- lodapi/resources/buildings.py +160 -0
- lodapi/resources/terrain.py +75 -0
- lodapi/resources/tilesets.py +44 -0
- lodapi-0.0.1.dist-info/METADATA +226 -0
- lodapi-0.0.1.dist-info/RECORD +13 -0
- lodapi-0.0.1.dist-info/WHEEL +4 -0
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,,
|