osmfeatures 0.1.0__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.
- osmfeatures/__init__.py +102 -0
- osmfeatures/_http.py +136 -0
- osmfeatures/_pagination.py +112 -0
- osmfeatures/async_client.py +297 -0
- osmfeatures/chunking.py +117 -0
- osmfeatures/cli.py +310 -0
- osmfeatures/client.py +374 -0
- osmfeatures/convenience.py +424 -0
- osmfeatures/models.py +197 -0
- osmfeatures/output.py +124 -0
- osmfeatures/py.typed +0 -0
- osmfeatures/retry.py +144 -0
- osmfeatures-0.1.0.dist-info/METADATA +253 -0
- osmfeatures-0.1.0.dist-info/RECORD +17 -0
- osmfeatures-0.1.0.dist-info/WHEEL +4 -0
- osmfeatures-0.1.0.dist-info/entry_points.txt +2 -0
- osmfeatures-0.1.0.dist-info/licenses/LICENSE +21 -0
osmfeatures/__init__.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""osmfeatures - Python SDK for the MapLark OSM Features API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .async_client import AsyncOSMFeaturesClient
|
|
6
|
+
from .chunking import around_to_bbox, bbox_area_deg2, merge_features, parse_bbox, shapely_to_bbox, split_bbox_tiles
|
|
7
|
+
from .client import OSMFeaturesClient
|
|
8
|
+
from .convenience import (
|
|
9
|
+
get_amenities,
|
|
10
|
+
get_barriers,
|
|
11
|
+
get_boundaries,
|
|
12
|
+
get_building_polygons,
|
|
13
|
+
get_buildings,
|
|
14
|
+
get_cafes,
|
|
15
|
+
get_cycleways,
|
|
16
|
+
get_elements_by_name,
|
|
17
|
+
get_green_spaces,
|
|
18
|
+
get_healthcare,
|
|
19
|
+
get_landuse,
|
|
20
|
+
get_parking,
|
|
21
|
+
get_parks,
|
|
22
|
+
get_place,
|
|
23
|
+
get_public_transport_stops,
|
|
24
|
+
get_restaurants,
|
|
25
|
+
get_roads,
|
|
26
|
+
get_schools,
|
|
27
|
+
get_shops,
|
|
28
|
+
get_trees,
|
|
29
|
+
get_water,
|
|
30
|
+
)
|
|
31
|
+
from .models import (
|
|
32
|
+
BinaryQueryResult,
|
|
33
|
+
CostEstimate,
|
|
34
|
+
OSMFeature,
|
|
35
|
+
OSMFeatureCollection,
|
|
36
|
+
OSMFeaturesAPIError,
|
|
37
|
+
OSMFeaturesAuthError,
|
|
38
|
+
OSMFeaturesError,
|
|
39
|
+
OSMFeaturesForbiddenError,
|
|
40
|
+
OSMFeaturesRateLimitError,
|
|
41
|
+
ResponseMeta,
|
|
42
|
+
)
|
|
43
|
+
from .output import to_dataframe, to_geodataframe
|
|
44
|
+
from .retry import RetryConfig
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
# Clients
|
|
48
|
+
"OSMFeaturesClient",
|
|
49
|
+
"AsyncOSMFeaturesClient",
|
|
50
|
+
# Config
|
|
51
|
+
"RetryConfig",
|
|
52
|
+
# Models
|
|
53
|
+
"OSMFeature",
|
|
54
|
+
"OSMFeatureCollection",
|
|
55
|
+
"BinaryQueryResult",
|
|
56
|
+
"ResponseMeta",
|
|
57
|
+
"CostEstimate",
|
|
58
|
+
# Exceptions
|
|
59
|
+
"OSMFeaturesError",
|
|
60
|
+
"OSMFeaturesAuthError",
|
|
61
|
+
"OSMFeaturesForbiddenError",
|
|
62
|
+
"OSMFeaturesRateLimitError",
|
|
63
|
+
"OSMFeaturesAPIError",
|
|
64
|
+
# Output
|
|
65
|
+
"to_dataframe",
|
|
66
|
+
"to_geodataframe",
|
|
67
|
+
# Chunking utilities
|
|
68
|
+
"split_bbox_tiles",
|
|
69
|
+
"merge_features",
|
|
70
|
+
"parse_bbox",
|
|
71
|
+
"bbox_area_deg2",
|
|
72
|
+
"around_to_bbox",
|
|
73
|
+
"shapely_to_bbox",
|
|
74
|
+
# Convenience helpers - built environment
|
|
75
|
+
"get_buildings",
|
|
76
|
+
"get_building_polygons",
|
|
77
|
+
"get_barriers",
|
|
78
|
+
"get_trees",
|
|
79
|
+
# Convenience helpers - mobility
|
|
80
|
+
"get_roads",
|
|
81
|
+
"get_cycleways",
|
|
82
|
+
"get_public_transport_stops",
|
|
83
|
+
"get_parking",
|
|
84
|
+
# Convenience helpers - POI
|
|
85
|
+
"get_amenities",
|
|
86
|
+
"get_restaurants",
|
|
87
|
+
"get_cafes",
|
|
88
|
+
"get_shops",
|
|
89
|
+
"get_healthcare",
|
|
90
|
+
"get_schools",
|
|
91
|
+
# Convenience helpers - green space
|
|
92
|
+
"get_parks",
|
|
93
|
+
"get_green_spaces",
|
|
94
|
+
"get_water",
|
|
95
|
+
# Convenience helpers - admin / place
|
|
96
|
+
"get_landuse",
|
|
97
|
+
"get_boundaries",
|
|
98
|
+
"get_place",
|
|
99
|
+
"get_elements_by_name",
|
|
100
|
+
]
|
|
101
|
+
|
|
102
|
+
__version__ = "0.1.0"
|
osmfeatures/_http.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Shared HTTP helpers used by both the sync and async clients."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Literal
|
|
6
|
+
|
|
7
|
+
from .models import (
|
|
8
|
+
OSMFeaturesAPIError,
|
|
9
|
+
OSMFeaturesAuthError,
|
|
10
|
+
OSMFeaturesForbiddenError,
|
|
11
|
+
OSMFeaturesRateLimitError,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
DEFAULT_BASE_URL = "https://api.maplark.com"
|
|
15
|
+
GEOJSON_ACCEPT = "application/geo+json"
|
|
16
|
+
|
|
17
|
+
ElementType = Literal["node", "way", "relation"]
|
|
18
|
+
ShapeType = Literal["line", "polygon", "all"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def is_geojson_accept(accept: str | None) -> bool:
|
|
22
|
+
"""True when Accept is omitted or asks for GeoJSON (default encoding)."""
|
|
23
|
+
if not accept or not accept.strip():
|
|
24
|
+
return True
|
|
25
|
+
media = accept.split(",", 1)[0].split(";", 1)[0].strip().lower()
|
|
26
|
+
return media in ("*/*", "*", GEOJSON_ACCEPT)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def build_params(kwargs: dict[str, Any]) -> list[tuple[str, Any]]:
|
|
30
|
+
"""Convert SDK kwargs to a list of (key, value) pairs for requests.
|
|
31
|
+
|
|
32
|
+
Handles repeatable parameters (tags, or_tags, not_tags) - if a
|
|
33
|
+
list/tuple is provided it becomes multiple query-string values.
|
|
34
|
+
The ``type`` parameter is encoded as a single comma-separated value
|
|
35
|
+
because the backend expects ``type=node,way`` instead of repeated keys.
|
|
36
|
+
"""
|
|
37
|
+
repeatable = {"tags", "or_tags", "not_tags"}
|
|
38
|
+
params: list[tuple[str, Any]] = []
|
|
39
|
+
for key, value in kwargs.items():
|
|
40
|
+
if key == "type" and isinstance(value, (list, tuple)):
|
|
41
|
+
params.append((key, ",".join(str(v) for v in value)))
|
|
42
|
+
elif key in repeatable and isinstance(value, (list, tuple)):
|
|
43
|
+
for v in value:
|
|
44
|
+
params.append((key, v))
|
|
45
|
+
elif isinstance(value, bool):
|
|
46
|
+
params.append((key, "true" if value else "false"))
|
|
47
|
+
else:
|
|
48
|
+
params.append((key, value))
|
|
49
|
+
return params
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def raise_for_response(resp: Any) -> None:
|
|
53
|
+
status = resp.status_code
|
|
54
|
+
if status == 401:
|
|
55
|
+
raise OSMFeaturesAuthError(f"Authentication failed (HTTP 401): {resp.text[:200]}")
|
|
56
|
+
if status == 403:
|
|
57
|
+
raise OSMFeaturesForbiddenError(f"Access denied (HTTP 403): {resp.text[:200]}")
|
|
58
|
+
if status == 429:
|
|
59
|
+
body: dict[str, Any] = {}
|
|
60
|
+
try:
|
|
61
|
+
body = resp.json()
|
|
62
|
+
except Exception:
|
|
63
|
+
pass
|
|
64
|
+
retry_after_raw = resp.headers.get("Retry-After") or resp.headers.get("retry-after")
|
|
65
|
+
retry_after: float | None = None
|
|
66
|
+
if retry_after_raw is not None:
|
|
67
|
+
try:
|
|
68
|
+
retry_after = float(retry_after_raw)
|
|
69
|
+
except (TypeError, ValueError):
|
|
70
|
+
pass
|
|
71
|
+
raise OSMFeaturesRateLimitError(
|
|
72
|
+
body.get("detail", body.get("message", f"Rate limit exceeded (HTTP 429): {resp.text[:200]}")),
|
|
73
|
+
error_code=body.get("error", ""),
|
|
74
|
+
tier=body.get("tier", ""),
|
|
75
|
+
estimated_units=body.get("estimated_units"),
|
|
76
|
+
max_units_per_request=body.get("max_units_per_request"),
|
|
77
|
+
retry_after=retry_after,
|
|
78
|
+
)
|
|
79
|
+
if not (200 <= status < 300):
|
|
80
|
+
raise OSMFeaturesAPIError(
|
|
81
|
+
f"API error (HTTP {status}): {resp.text[:200]}",
|
|
82
|
+
status_code=status,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def is_429_retryable(resp: Any) -> bool:
|
|
87
|
+
"""Return True if the 429 should be retried (transient per-second limit).
|
|
88
|
+
|
|
89
|
+
The backend emits ``error == "too_many_requests"`` for all 429s and
|
|
90
|
+
distinguishes the two cases via a ``subtype`` field:
|
|
91
|
+
- ``"rate_limit_second"`` — transient, safe to retry with back-off.
|
|
92
|
+
- ``"rate_limit_monthly"`` — hard monthly cap, do not retry.
|
|
93
|
+
|
|
94
|
+
If ``subtype`` is absent (e.g. unknown proxy / future backend version),
|
|
95
|
+
fall back to retrying so the SDK does not silently swallow genuine
|
|
96
|
+
transient limits.
|
|
97
|
+
"""
|
|
98
|
+
try:
|
|
99
|
+
body = resp.json()
|
|
100
|
+
subtype = body.get("subtype", "")
|
|
101
|
+
if subtype == "rate_limit_monthly":
|
|
102
|
+
return False
|
|
103
|
+
if subtype == "rate_limit_second":
|
|
104
|
+
return True
|
|
105
|
+
# Legacy codes emitted by older backend versions — kept for
|
|
106
|
+
# compatibility during a rolling deploy.
|
|
107
|
+
error = body.get("error", "")
|
|
108
|
+
if error == "rate_limit_monthly":
|
|
109
|
+
return False
|
|
110
|
+
# Default: retry unknown 429s (fail open for transient limits).
|
|
111
|
+
return True
|
|
112
|
+
except Exception:
|
|
113
|
+
return True
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def build_rate_limit_error(resp: Any) -> OSMFeaturesRateLimitError:
|
|
117
|
+
body: dict[str, Any] = {}
|
|
118
|
+
try:
|
|
119
|
+
body = resp.json()
|
|
120
|
+
except Exception:
|
|
121
|
+
pass
|
|
122
|
+
retry_after_raw = resp.headers.get("Retry-After") or resp.headers.get("retry-after")
|
|
123
|
+
retry_after: float | None = None
|
|
124
|
+
if retry_after_raw is not None:
|
|
125
|
+
try:
|
|
126
|
+
retry_after = float(retry_after_raw)
|
|
127
|
+
except (TypeError, ValueError):
|
|
128
|
+
pass
|
|
129
|
+
return OSMFeaturesRateLimitError(
|
|
130
|
+
body.get("detail", body.get("message", "Rate limit exceeded")),
|
|
131
|
+
error_code=body.get("error", ""),
|
|
132
|
+
tier=body.get("tier", ""),
|
|
133
|
+
estimated_units=body.get("estimated_units"),
|
|
134
|
+
max_units_per_request=body.get("max_units_per_request"),
|
|
135
|
+
retry_after=retry_after,
|
|
136
|
+
)
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Auto-pagination helpers for /v2/osm_features."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import AsyncIterator, Iterator
|
|
6
|
+
from typing import Any, Callable
|
|
7
|
+
|
|
8
|
+
from osmfeatures.models import OSMFeatureCollection
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
_MAX_PAGES = 500 # hard safety cap - prevents infinite loops
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def paginate_all(
|
|
15
|
+
fetch_fn: Callable[[dict[str, Any]], OSMFeatureCollection],
|
|
16
|
+
params: dict[str, Any],
|
|
17
|
+
*,
|
|
18
|
+
limit_per_page: int = 1000,
|
|
19
|
+
) -> Iterator[list[dict[str, Any]]]:
|
|
20
|
+
"""Yield pages of raw feature dicts until ``X-Has-More`` is false.
|
|
21
|
+
|
|
22
|
+
Parameters
|
|
23
|
+
----------
|
|
24
|
+
fetch_fn:
|
|
25
|
+
Synchronous callable that accepts a params dict and returns an
|
|
26
|
+
:class:`OSMFeatureCollection` (pagination via ``.meta`` from headers).
|
|
27
|
+
params:
|
|
28
|
+
Base query parameters. Any ``limit`` and ``cursor`` keys are managed
|
|
29
|
+
internally and will be overwritten.
|
|
30
|
+
limit_per_page:
|
|
31
|
+
Upstream ``limit`` per HTTP request (page size).
|
|
32
|
+
|
|
33
|
+
Yields
|
|
34
|
+
------
|
|
35
|
+
list[dict]
|
|
36
|
+
The ``features`` list from each page response.
|
|
37
|
+
"""
|
|
38
|
+
base = {k: v for k, v in params.items() if k not in ("limit", "cursor")}
|
|
39
|
+
base["limit"] = limit_per_page
|
|
40
|
+
cursor: str | None = None
|
|
41
|
+
|
|
42
|
+
for page in range(_MAX_PAGES):
|
|
43
|
+
page_params = dict(base)
|
|
44
|
+
if cursor is not None:
|
|
45
|
+
page_params["cursor"] = cursor
|
|
46
|
+
collection = fetch_fn(page_params)
|
|
47
|
+
features: list[dict[str, Any]] = list(collection.get("features", []))
|
|
48
|
+
yield features
|
|
49
|
+
|
|
50
|
+
if not collection.meta.has_more:
|
|
51
|
+
return
|
|
52
|
+
|
|
53
|
+
if not features:
|
|
54
|
+
raise RuntimeError(
|
|
55
|
+
f"API returned has_more=true but an empty features page at cursor {cursor!r} "
|
|
56
|
+
f"on page {page}."
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
next_cursor = collection.meta.next_cursor
|
|
60
|
+
if not isinstance(next_cursor, str) or not next_cursor or next_cursor == cursor:
|
|
61
|
+
raise RuntimeError(
|
|
62
|
+
f"API returned has_more=true but next_cursor ({next_cursor!r}) "
|
|
63
|
+
f"did not advance beyond current cursor ({cursor!r}) on page {page}."
|
|
64
|
+
)
|
|
65
|
+
cursor = next_cursor
|
|
66
|
+
|
|
67
|
+
raise RuntimeError(
|
|
68
|
+
f"paginate_all exceeded {_MAX_PAGES} pages without has_more=false - "
|
|
69
|
+
"possible infinite pagination loop."
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
async def paginate_all_async(
|
|
74
|
+
fetch_fn: Callable[[dict[str, Any]], Any],
|
|
75
|
+
params: dict[str, Any],
|
|
76
|
+
*,
|
|
77
|
+
limit_per_page: int = 1000,
|
|
78
|
+
) -> AsyncIterator[list[dict[str, Any]]]:
|
|
79
|
+
"""Async counterpart to :func:`paginate_all` (yields pages)."""
|
|
80
|
+
base = {k: v for k, v in params.items() if k not in ("limit", "cursor")}
|
|
81
|
+
base["limit"] = limit_per_page
|
|
82
|
+
cursor: str | None = None
|
|
83
|
+
|
|
84
|
+
for page in range(_MAX_PAGES):
|
|
85
|
+
page_params = dict(base)
|
|
86
|
+
if cursor is not None:
|
|
87
|
+
page_params["cursor"] = cursor
|
|
88
|
+
collection = await fetch_fn(page_params)
|
|
89
|
+
features: list[dict[str, Any]] = list(collection.get("features", []))
|
|
90
|
+
yield features
|
|
91
|
+
|
|
92
|
+
if not collection.meta.has_more:
|
|
93
|
+
return
|
|
94
|
+
|
|
95
|
+
if not features:
|
|
96
|
+
raise RuntimeError(
|
|
97
|
+
f"API returned has_more=true but an empty features page at cursor {cursor!r} "
|
|
98
|
+
f"on page {page}."
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
next_cursor = collection.meta.next_cursor
|
|
102
|
+
if not isinstance(next_cursor, str) or not next_cursor or next_cursor == cursor:
|
|
103
|
+
raise RuntimeError(
|
|
104
|
+
f"API returned has_more=true but next_cursor ({next_cursor!r}) "
|
|
105
|
+
f"did not advance beyond current cursor ({cursor!r}) on page {page}."
|
|
106
|
+
)
|
|
107
|
+
cursor = next_cursor
|
|
108
|
+
|
|
109
|
+
raise RuntimeError(
|
|
110
|
+
f"paginate_all_async exceeded {_MAX_PAGES} pages without has_more=false - "
|
|
111
|
+
"possible infinite pagination loop."
|
|
112
|
+
)
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"""Asynchronous MapLark OSM Features API client (httpx-based)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from .chunking import merge_features, shapely_to_bbox, split_bbox_tiles
|
|
10
|
+
from ._http import (
|
|
11
|
+
DEFAULT_BASE_URL,
|
|
12
|
+
GEOJSON_ACCEPT,
|
|
13
|
+
ElementType,
|
|
14
|
+
ShapeType,
|
|
15
|
+
build_params,
|
|
16
|
+
build_rate_limit_error,
|
|
17
|
+
is_429_retryable,
|
|
18
|
+
is_geojson_accept,
|
|
19
|
+
raise_for_response,
|
|
20
|
+
)
|
|
21
|
+
from .models import (
|
|
22
|
+
BinaryQueryResult,
|
|
23
|
+
CostEstimate,
|
|
24
|
+
OSMFeature,
|
|
25
|
+
OSMFeatureCollection,
|
|
26
|
+
ResponseMeta,
|
|
27
|
+
)
|
|
28
|
+
from ._pagination import paginate_all_async
|
|
29
|
+
from .retry import RetryConfig, retry_async
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class AsyncOSMFeaturesClient:
|
|
33
|
+
"""Asynchronous client for the MapLark OSM Features API.
|
|
34
|
+
|
|
35
|
+
Use as an async context manager::
|
|
36
|
+
|
|
37
|
+
async with AsyncOSMFeaturesClient(api_key="...") as client:
|
|
38
|
+
fc = await client.query_async(bbox="18.06,59.32,18.09,59.34", tags=["building"])
|
|
39
|
+
|
|
40
|
+
Parameters
|
|
41
|
+
----------
|
|
42
|
+
api_key:
|
|
43
|
+
Your MapLark API key.
|
|
44
|
+
base_url:
|
|
45
|
+
API base URL. Defaults to ``https://api.maplark.com``.
|
|
46
|
+
retry_config:
|
|
47
|
+
Retry / backoff settings. Defaults to 3 retries with exponential
|
|
48
|
+
backoff and jitter.
|
|
49
|
+
timeout:
|
|
50
|
+
HTTP request timeout in seconds. Defaults to 30.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
api_key: str,
|
|
56
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
57
|
+
retry_config: RetryConfig | None = None,
|
|
58
|
+
timeout: float = 30.0,
|
|
59
|
+
) -> None:
|
|
60
|
+
self._base_url = base_url.rstrip("/")
|
|
61
|
+
self._timeout = timeout
|
|
62
|
+
self._retry = retry_config or RetryConfig()
|
|
63
|
+
self._headers = {"Authorization": f"Bearer {api_key}"}
|
|
64
|
+
self._client: httpx.AsyncClient | None = None
|
|
65
|
+
|
|
66
|
+
async def _get_client(self) -> httpx.AsyncClient:
|
|
67
|
+
if self._client is None:
|
|
68
|
+
self._client = httpx.AsyncClient(
|
|
69
|
+
headers=self._headers,
|
|
70
|
+
timeout=self._timeout,
|
|
71
|
+
)
|
|
72
|
+
return self._client
|
|
73
|
+
|
|
74
|
+
# ------------------------------------------------------------------
|
|
75
|
+
# Internal helpers
|
|
76
|
+
# ------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
async def _request(
|
|
79
|
+
self, params: dict[str, Any], *, accept: str | None = None
|
|
80
|
+
) -> httpx.Response:
|
|
81
|
+
param_list = build_params(params)
|
|
82
|
+
client = await self._get_client()
|
|
83
|
+
|
|
84
|
+
async def _do() -> httpx.Response:
|
|
85
|
+
return await client.get(
|
|
86
|
+
f"{self._base_url}/v2/osm_features",
|
|
87
|
+
params=param_list,
|
|
88
|
+
headers={"Accept": accept or GEOJSON_ACCEPT},
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
resp = await retry_async(
|
|
92
|
+
_do,
|
|
93
|
+
self._retry,
|
|
94
|
+
get_status=lambda r: r.status_code,
|
|
95
|
+
get_headers=lambda r: dict(r.headers),
|
|
96
|
+
is_rate_limit_error=is_429_retryable,
|
|
97
|
+
build_rate_limit_error=build_rate_limit_error,
|
|
98
|
+
)
|
|
99
|
+
raise_for_response(resp)
|
|
100
|
+
return resp
|
|
101
|
+
|
|
102
|
+
async def _raw_query(
|
|
103
|
+
self, params: dict[str, Any], *, accept: str | None = None
|
|
104
|
+
) -> OSMFeatureCollection:
|
|
105
|
+
resp = await self._request(params, accept=accept)
|
|
106
|
+
return OSMFeatureCollection.from_http(resp.json(), resp.headers)
|
|
107
|
+
|
|
108
|
+
# ------------------------------------------------------------------
|
|
109
|
+
# Public API
|
|
110
|
+
# ------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
async def query_async(
|
|
113
|
+
self,
|
|
114
|
+
*,
|
|
115
|
+
bbox: str | None = None,
|
|
116
|
+
around: str | None = None,
|
|
117
|
+
type: ElementType | list[ElementType] | None = None, # noqa: A002
|
|
118
|
+
shape: ShapeType | None = None,
|
|
119
|
+
osm_ids: str | None = None,
|
|
120
|
+
tags: list[str] | str | None = None,
|
|
121
|
+
or_tags: list[str] | str | None = None,
|
|
122
|
+
not_tags: list[str] | str | None = None,
|
|
123
|
+
limit: int = 1000,
|
|
124
|
+
cursor: str | None = None,
|
|
125
|
+
zoom: float | None = None,
|
|
126
|
+
min_length_m: float | None = None,
|
|
127
|
+
max_length_m: float | None = None,
|
|
128
|
+
min_area_m2: float | None = None,
|
|
129
|
+
max_area_m2: float | None = None,
|
|
130
|
+
disable_budget_warning: bool = False,
|
|
131
|
+
geometry: Any = None,
|
|
132
|
+
centroid: bool = False,
|
|
133
|
+
accept: str | None = None,
|
|
134
|
+
) -> OSMFeatureCollection | BinaryQueryResult:
|
|
135
|
+
"""Fetch a single page of OSM elements asynchronously.
|
|
136
|
+
|
|
137
|
+
Same parameters as :meth:`OSMFeaturesClient.query`. Non-GeoJSON
|
|
138
|
+
``accept`` values return :class:`BinaryQueryResult`.
|
|
139
|
+
"""
|
|
140
|
+
if geometry is not None:
|
|
141
|
+
bbox = shapely_to_bbox(geometry)
|
|
142
|
+
|
|
143
|
+
params: dict[str, Any] = {}
|
|
144
|
+
if bbox is not None:
|
|
145
|
+
params["bbox"] = bbox
|
|
146
|
+
if around is not None:
|
|
147
|
+
params["around"] = around
|
|
148
|
+
if type is not None:
|
|
149
|
+
params["type"] = type
|
|
150
|
+
if shape is not None:
|
|
151
|
+
params["shape"] = shape
|
|
152
|
+
if osm_ids is not None:
|
|
153
|
+
params["osm_ids"] = osm_ids
|
|
154
|
+
if tags is not None:
|
|
155
|
+
params["tags"] = tags
|
|
156
|
+
if or_tags is not None:
|
|
157
|
+
params["or_tags"] = or_tags
|
|
158
|
+
if not_tags is not None:
|
|
159
|
+
params["not_tags"] = not_tags
|
|
160
|
+
params["limit"] = limit
|
|
161
|
+
if cursor is not None:
|
|
162
|
+
params["cursor"] = cursor
|
|
163
|
+
if zoom is not None:
|
|
164
|
+
params["zoom"] = zoom
|
|
165
|
+
if min_length_m is not None:
|
|
166
|
+
params["min_length_m"] = min_length_m
|
|
167
|
+
if max_length_m is not None:
|
|
168
|
+
params["max_length_m"] = max_length_m
|
|
169
|
+
if min_area_m2 is not None:
|
|
170
|
+
params["min_area_m2"] = min_area_m2
|
|
171
|
+
if max_area_m2 is not None:
|
|
172
|
+
params["max_area_m2"] = max_area_m2
|
|
173
|
+
if disable_budget_warning:
|
|
174
|
+
params["disable_budget_warning"] = disable_budget_warning
|
|
175
|
+
if centroid:
|
|
176
|
+
params["centroid"] = True
|
|
177
|
+
|
|
178
|
+
if is_geojson_accept(accept):
|
|
179
|
+
return await self._raw_query(params, accept=accept)
|
|
180
|
+
resp = await self._request(params, accept=accept)
|
|
181
|
+
return BinaryQueryResult.from_http(resp.content, resp.headers)
|
|
182
|
+
|
|
183
|
+
async def query_all_async(
|
|
184
|
+
self,
|
|
185
|
+
*,
|
|
186
|
+
limit_per_page: int = 1000,
|
|
187
|
+
bbox_tiles: int = 2,
|
|
188
|
+
max_features: int | None = 55_000,
|
|
189
|
+
**params: Any,
|
|
190
|
+
) -> OSMFeatureCollection:
|
|
191
|
+
"""Fetch *all* pages of OSM elements asynchronously, auto-paginating.
|
|
192
|
+
|
|
193
|
+
When ``bbox`` is present, splits it into *bbox_tiles* sub-bboxes
|
|
194
|
+
(power of 2; default 2), paginates each tile sequentially, then
|
|
195
|
+
merges and deduplicates by feature ``id``. Use ``bbox_tiles=1`` to
|
|
196
|
+
disable tiling.
|
|
197
|
+
|
|
198
|
+
``max_features`` defaults to 55_000; pass ``None`` for no upper limit.
|
|
199
|
+
Do not pass ``limit`` or ``cursor`` (use ``limit_per_page`` / managed
|
|
200
|
+
pagination). Non-GeoJSON ``accept`` is not supported.
|
|
201
|
+
"""
|
|
202
|
+
if "limit" in params:
|
|
203
|
+
raise ValueError(
|
|
204
|
+
"query_all_async does not take limit; use limit_per_page (page size) "
|
|
205
|
+
"and max_features (total cap)"
|
|
206
|
+
)
|
|
207
|
+
if not is_geojson_accept(params.pop("accept", None)):
|
|
208
|
+
raise TypeError(
|
|
209
|
+
"query_all_async() only supports GeoJSON; use query_async(accept=...) "
|
|
210
|
+
"for binary/table encodings"
|
|
211
|
+
)
|
|
212
|
+
if "cursor" in params:
|
|
213
|
+
raise ValueError("query_all_async manages cursors; do not pass cursor")
|
|
214
|
+
|
|
215
|
+
if "geometry" in params:
|
|
216
|
+
geom = params.pop("geometry")
|
|
217
|
+
params["bbox"] = shapely_to_bbox(geom)
|
|
218
|
+
|
|
219
|
+
bbox = params.get("bbox")
|
|
220
|
+
tile_bboxes = (
|
|
221
|
+
split_bbox_tiles(bbox, bbox_tiles) if isinstance(bbox, str) else [None]
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
feature_lists: list[list[dict[str, Any]]] = []
|
|
225
|
+
count = 0
|
|
226
|
+
truncated = False
|
|
227
|
+
for tile_bbox in tile_bboxes:
|
|
228
|
+
if max_features is not None and count >= max_features:
|
|
229
|
+
truncated = True
|
|
230
|
+
break
|
|
231
|
+
tile_params = dict(params)
|
|
232
|
+
if tile_bbox is not None:
|
|
233
|
+
tile_params["bbox"] = tile_bbox
|
|
234
|
+
tile_features: list[dict[str, Any]] = []
|
|
235
|
+
async for page_features in paginate_all_async(
|
|
236
|
+
self._raw_query, tile_params, limit_per_page=limit_per_page
|
|
237
|
+
):
|
|
238
|
+
if max_features is not None:
|
|
239
|
+
room = max_features - count
|
|
240
|
+
if room <= 0:
|
|
241
|
+
truncated = True
|
|
242
|
+
break
|
|
243
|
+
if len(page_features) > room:
|
|
244
|
+
tile_features.extend(page_features[:room])
|
|
245
|
+
count += room
|
|
246
|
+
truncated = True
|
|
247
|
+
break
|
|
248
|
+
tile_features.extend(page_features)
|
|
249
|
+
count += len(page_features)
|
|
250
|
+
feature_lists.append(tile_features)
|
|
251
|
+
|
|
252
|
+
deduped = merge_features(feature_lists)
|
|
253
|
+
if max_features is not None and len(deduped) > max_features:
|
|
254
|
+
deduped = deduped[:max_features]
|
|
255
|
+
truncated = True
|
|
256
|
+
return OSMFeatureCollection(
|
|
257
|
+
features=[OSMFeature.from_dict(f) for f in deduped],
|
|
258
|
+
meta=ResponseMeta(returned=len(deduped), has_more=truncated),
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
async def estimate_cost_async(self, **params: Any) -> CostEstimate:
|
|
262
|
+
"""Call ``/v2/osm_features/cost`` to preflight the credit cost."""
|
|
263
|
+
if "geometry" in params:
|
|
264
|
+
geom = params.pop("geometry")
|
|
265
|
+
params["bbox"] = shapely_to_bbox(geom)
|
|
266
|
+
|
|
267
|
+
param_list = build_params(params)
|
|
268
|
+
client = await self._get_client()
|
|
269
|
+
|
|
270
|
+
async def _do() -> httpx.Response:
|
|
271
|
+
return await client.get(
|
|
272
|
+
f"{self._base_url}/v2/osm_features/cost",
|
|
273
|
+
params=param_list,
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
resp = await retry_async(
|
|
277
|
+
_do,
|
|
278
|
+
self._retry,
|
|
279
|
+
get_status=lambda r: r.status_code,
|
|
280
|
+
get_headers=lambda r: dict(r.headers),
|
|
281
|
+
is_rate_limit_error=is_429_retryable,
|
|
282
|
+
build_rate_limit_error=build_rate_limit_error,
|
|
283
|
+
)
|
|
284
|
+
raise_for_response(resp)
|
|
285
|
+
return CostEstimate.from_dict(resp.json())
|
|
286
|
+
|
|
287
|
+
async def close(self) -> None:
|
|
288
|
+
"""Close the underlying HTTP client."""
|
|
289
|
+
if self._client is not None:
|
|
290
|
+
await self._client.aclose()
|
|
291
|
+
self._client = None
|
|
292
|
+
|
|
293
|
+
async def __aenter__(self) -> "AsyncOSMFeaturesClient":
|
|
294
|
+
return self
|
|
295
|
+
|
|
296
|
+
async def __aexit__(self, *_: Any) -> None:
|
|
297
|
+
await self.close()
|