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