apple-maps-api 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.
- apple_maps_api/__init__.py +48 -0
- apple_maps_api/client.py +559 -0
- apple_maps_api/exceptions.py +34 -0
- apple_maps_api/helpers.py +145 -0
- apple_maps_api/models.py +288 -0
- apple_maps_api/version.py +34 -0
- apple_maps_api-0.1.0.dist-info/METADATA +268 -0
- apple_maps_api-0.1.0.dist-info/RECORD +9 -0
- apple_maps_api-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""apple-maps-api: A Python client for the Apple Maps Server API.
|
|
2
|
+
|
|
3
|
+
This package provides a type-safe, production-ready client for interacting with
|
|
4
|
+
Apple Maps geocoding, reverse geocoding, search, and autocomplete APIs.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .client import AppleMapsClient
|
|
8
|
+
from .helpers import geocode_coordinates, geocode_postal_code
|
|
9
|
+
from .models import (
|
|
10
|
+
AddressCategory,
|
|
11
|
+
AutocompleteResult,
|
|
12
|
+
GeocodeResult,
|
|
13
|
+
Location,
|
|
14
|
+
MapRegion,
|
|
15
|
+
Place,
|
|
16
|
+
PlaceResults,
|
|
17
|
+
PoiCategory,
|
|
18
|
+
SearchACResultType,
|
|
19
|
+
SearchAutocompleteResponse,
|
|
20
|
+
SearchPlace,
|
|
21
|
+
SearchResponse,
|
|
22
|
+
SearchResultType,
|
|
23
|
+
StructuredAddress,
|
|
24
|
+
TokenResponse,
|
|
25
|
+
)
|
|
26
|
+
from .version import __version__
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"__version__",
|
|
30
|
+
"AppleMapsClient",
|
|
31
|
+
"geocode_coordinates",
|
|
32
|
+
"geocode_postal_code",
|
|
33
|
+
"AddressCategory",
|
|
34
|
+
"AutocompleteResult",
|
|
35
|
+
"GeocodeResult",
|
|
36
|
+
"Location",
|
|
37
|
+
"MapRegion",
|
|
38
|
+
"Place",
|
|
39
|
+
"PlaceResults",
|
|
40
|
+
"PoiCategory",
|
|
41
|
+
"SearchACResultType",
|
|
42
|
+
"SearchAutocompleteResponse",
|
|
43
|
+
"SearchPlace",
|
|
44
|
+
"SearchResponse",
|
|
45
|
+
"SearchResultType",
|
|
46
|
+
"StructuredAddress",
|
|
47
|
+
"TokenResponse",
|
|
48
|
+
]
|
apple_maps_api/client.py
ADDED
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
"""Apple Maps Server API client library.
|
|
2
|
+
|
|
3
|
+
API docs: https://developer.apple.com/documentation/applemapsserverapi
|
|
4
|
+
|
|
5
|
+
This library provides a Python client for the Apple Maps Server API with JWT-based
|
|
6
|
+
authentication, automatic token management, and retry logic.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
import time
|
|
12
|
+
from collections.abc import Sequence
|
|
13
|
+
from typing import TypedDict, Unpack
|
|
14
|
+
from urllib.parse import parse_qs, urlparse
|
|
15
|
+
|
|
16
|
+
import funcy as f
|
|
17
|
+
import httpx2
|
|
18
|
+
import jwt
|
|
19
|
+
from tenacity import (
|
|
20
|
+
before_sleep_log,
|
|
21
|
+
retry,
|
|
22
|
+
retry_if_exception,
|
|
23
|
+
stop_after_attempt,
|
|
24
|
+
wait_exponential,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
from apple_maps_api.models import (
|
|
28
|
+
AddressCategory,
|
|
29
|
+
AutocompleteResult,
|
|
30
|
+
Location,
|
|
31
|
+
PlaceResults,
|
|
32
|
+
PoiCategory,
|
|
33
|
+
SearchACResultType,
|
|
34
|
+
SearchAutocompleteResponse,
|
|
35
|
+
SearchResponse,
|
|
36
|
+
SearchResultType,
|
|
37
|
+
TokenResponse,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
log = logging.getLogger(__name__)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _csv(value: str | Sequence | None) -> str | None:
|
|
44
|
+
"""Serialize a plain string or a sequence of StrEnum values to a comma-separated string."""
|
|
45
|
+
if value is None:
|
|
46
|
+
return None
|
|
47
|
+
if isinstance(value, str):
|
|
48
|
+
return value
|
|
49
|
+
return ",".join(str(v) for v in value)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _search_location(
|
|
53
|
+
*,
|
|
54
|
+
near: str | None = None,
|
|
55
|
+
lat: float | None = None,
|
|
56
|
+
lng: float | None = None,
|
|
57
|
+
) -> str | None:
|
|
58
|
+
"""Resolve searchLocation from near="lat,lng" or separate lat/lng floats."""
|
|
59
|
+
if near is not None:
|
|
60
|
+
assert lat is None and lng is None, "pass near or lat/lng, not both"
|
|
61
|
+
return near
|
|
62
|
+
|
|
63
|
+
if lat is None and lng is None:
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
assert lat is not None and lng is not None, "lat and lng must both be provided"
|
|
67
|
+
return f"{lat},{lng}"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# shared timeout: 10s connect, 30s read
|
|
71
|
+
_TIMEOUT = httpx2.Timeout(connect=10, read=30, write=10, pool=10)
|
|
72
|
+
|
|
73
|
+
# Auth JWT lifetime is client-chosen; Apple does not document a max for Maps Server API.
|
|
74
|
+
# Access tokens from /v1/token are separately ~30 min (Apple-controlled).
|
|
75
|
+
# https://developer.apple.com/documentation/applemapsserverapi/creating-and-using-tokens-with-maps-server-api
|
|
76
|
+
_DEFAULT_JWT_TTL_SECONDS = 60 * 60
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _is_retryable_http_error(exception: BaseException) -> bool:
|
|
80
|
+
"""Return True to retry, False to stop.
|
|
81
|
+
|
|
82
|
+
Retries on network-level errors and HTTP 429/5xx.
|
|
83
|
+
All other 4xx errors are permanent failures and should not be retried.
|
|
84
|
+
"""
|
|
85
|
+
if not isinstance(exception, httpx2.HTTPError):
|
|
86
|
+
return False
|
|
87
|
+
|
|
88
|
+
if isinstance(exception, httpx2.HTTPStatusError):
|
|
89
|
+
status = (
|
|
90
|
+
exception.response.status_code if exception.response is not None else None
|
|
91
|
+
)
|
|
92
|
+
return status == 429 or (status is not None and status >= 500)
|
|
93
|
+
|
|
94
|
+
return True
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
_retry_policy = retry(
|
|
98
|
+
stop=stop_after_attempt(6),
|
|
99
|
+
wait=wait_exponential(multiplier=1, min=0, max=32),
|
|
100
|
+
retry=retry_if_exception(_is_retryable_http_error),
|
|
101
|
+
before_sleep=before_sleep_log(log, logging.INFO),
|
|
102
|
+
reraise=True,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class GeocodeOptions(TypedDict, total=False):
|
|
107
|
+
limit_to_countries: str
|
|
108
|
+
lang: str
|
|
109
|
+
search_location: str
|
|
110
|
+
search_region: str
|
|
111
|
+
user_location: str
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class ReverseGeocodeOptions(TypedDict, total=False):
|
|
115
|
+
lang: str
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class SearchOptions(TypedDict, total=False):
|
|
119
|
+
near: str
|
|
120
|
+
lat: float
|
|
121
|
+
lng: float
|
|
122
|
+
categories: str | Sequence[PoiCategory]
|
|
123
|
+
exclude_categories: str | Sequence[PoiCategory]
|
|
124
|
+
limit_to_countries: str
|
|
125
|
+
lang: str
|
|
126
|
+
result_type_filter: str | Sequence[SearchResultType]
|
|
127
|
+
search_region: str
|
|
128
|
+
user_location: str
|
|
129
|
+
search_region_priority: str
|
|
130
|
+
enable_pagination: bool
|
|
131
|
+
page_token: str
|
|
132
|
+
include_address_categories: str | Sequence[AddressCategory]
|
|
133
|
+
exclude_address_categories: str | Sequence[AddressCategory]
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class AutocompleteOptions(TypedDict, total=False):
|
|
137
|
+
near: str | None
|
|
138
|
+
lat: float | None
|
|
139
|
+
lng: float | None
|
|
140
|
+
limit_to_countries: str | None
|
|
141
|
+
lang: str | None
|
|
142
|
+
result_type_filter: str | Sequence[SearchACResultType] | None
|
|
143
|
+
include_poi_categories: str | Sequence[PoiCategory] | None
|
|
144
|
+
exclude_poi_categories: str | Sequence[PoiCategory] | None
|
|
145
|
+
search_region: str | None
|
|
146
|
+
user_location: str | None
|
|
147
|
+
search_region_priority: str | None
|
|
148
|
+
include_address_categories: str | Sequence[AddressCategory] | None
|
|
149
|
+
exclude_address_categories: str | Sequence[AddressCategory] | None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class AppleMapsClient:
|
|
153
|
+
"""A client for the Apple Maps Server API.
|
|
154
|
+
|
|
155
|
+
Handles JWT-based authentication with automatic token refresh,
|
|
156
|
+
and provides methods for geocoding, reverse geocoding, search, and autocomplete.
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
def __init__(
|
|
160
|
+
self, *, team_id: str, key_id: str, private_key: str, origin: str | None = None
|
|
161
|
+
):
|
|
162
|
+
"""Initialize the client.
|
|
163
|
+
|
|
164
|
+
:param team_id: Apple Developer Team ID (10-character string).
|
|
165
|
+
:param key_id: Maps key identifier from Apple Developer account.
|
|
166
|
+
:param private_key: ES256 private key, either PEM-encoded or raw base64 DER.
|
|
167
|
+
:param origin: Optional domain restriction for MapKit JS tokens (e.g., "https://example.com").
|
|
168
|
+
"""
|
|
169
|
+
if not team_id:
|
|
170
|
+
raise ValueError("team_id must be provided.")
|
|
171
|
+
|
|
172
|
+
if not key_id:
|
|
173
|
+
raise ValueError("key_id must be provided.")
|
|
174
|
+
|
|
175
|
+
if not private_key:
|
|
176
|
+
raise ValueError("private_key must be provided.")
|
|
177
|
+
|
|
178
|
+
self.team_id = team_id
|
|
179
|
+
self.key_id = key_id
|
|
180
|
+
self.origin = origin
|
|
181
|
+
|
|
182
|
+
# accept both PEM-formatted keys and raw base64 DER (no PEM wrapping)
|
|
183
|
+
stripped = private_key.strip()
|
|
184
|
+
if not stripped.startswith("-----BEGIN"):
|
|
185
|
+
stripped = (
|
|
186
|
+
f"-----BEGIN PRIVATE KEY-----\n{stripped}\n-----END PRIVATE KEY-----"
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
self.private_key = stripped
|
|
190
|
+
self.base_url = "https://maps-api.apple.com"
|
|
191
|
+
|
|
192
|
+
self._access_token: str | None = None
|
|
193
|
+
self._token_expires_at: float = 0.0
|
|
194
|
+
|
|
195
|
+
@classmethod
|
|
196
|
+
def from_env(cls) -> "AppleMapsClient":
|
|
197
|
+
"""Construct a client from APPLE_MAPS_* environment variables.
|
|
198
|
+
|
|
199
|
+
Required:
|
|
200
|
+
- APPLE_MAPS_TEAM_ID
|
|
201
|
+
- APPLE_MAPS_KEY_ID
|
|
202
|
+
- APPLE_MAPS_P8_KEY
|
|
203
|
+
|
|
204
|
+
Optional:
|
|
205
|
+
- APPLE_MAPS_ORIGIN
|
|
206
|
+
"""
|
|
207
|
+
team_id = os.environ.get("APPLE_MAPS_TEAM_ID", "")
|
|
208
|
+
key_id = os.environ.get("APPLE_MAPS_KEY_ID", "")
|
|
209
|
+
private_key = os.environ.get("APPLE_MAPS_P8_KEY", "")
|
|
210
|
+
origin = os.environ.get("APPLE_MAPS_ORIGIN") or None
|
|
211
|
+
|
|
212
|
+
missing = [
|
|
213
|
+
name
|
|
214
|
+
for name, value in (
|
|
215
|
+
("APPLE_MAPS_TEAM_ID", team_id),
|
|
216
|
+
("APPLE_MAPS_KEY_ID", key_id),
|
|
217
|
+
("APPLE_MAPS_P8_KEY", private_key),
|
|
218
|
+
)
|
|
219
|
+
if not value
|
|
220
|
+
]
|
|
221
|
+
if missing:
|
|
222
|
+
raise ValueError(
|
|
223
|
+
f"missing required environment variables: {', '.join(missing)}"
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
return cls(
|
|
227
|
+
team_id=team_id,
|
|
228
|
+
key_id=key_id,
|
|
229
|
+
private_key=private_key,
|
|
230
|
+
origin=origin,
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
def _create_jwt(self, *, ttl_seconds: int = _DEFAULT_JWT_TTL_SECONDS) -> str:
|
|
234
|
+
"""Build an ES256-signed JWT for MapKit JS or /v1/token exchange.
|
|
235
|
+
|
|
236
|
+
Header: alg=ES256, kid, typ=JWT. Payload: iss, iat, exp, optional origin.
|
|
237
|
+
|
|
238
|
+
Spec:
|
|
239
|
+
https://developer.apple.com/documentation/applemapsserverapi/creating-and-using-tokens-with-maps-server-api
|
|
240
|
+
|
|
241
|
+
Vendored mirror (offline):
|
|
242
|
+
docs/apple_maps_documentation/developer.apple.com_documentation_applemapsserverapi_creating-and-using-tokens-with-maps-server-api.md
|
|
243
|
+
|
|
244
|
+
This is the *auth JWT* — a credential proving we own the Maps private key.
|
|
245
|
+
For Server API use it is exchanged for an *access token* (see _fetch_token).
|
|
246
|
+
For MapKit JS it is returned directly via create_mapkit_token().
|
|
247
|
+
"""
|
|
248
|
+
assert ttl_seconds > 0, "ttl_seconds must be positive"
|
|
249
|
+
|
|
250
|
+
now = int(time.time())
|
|
251
|
+
payload: dict[str, object] = {
|
|
252
|
+
"iss": self.team_id,
|
|
253
|
+
"iat": now,
|
|
254
|
+
"exp": now + ttl_seconds,
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if self.origin:
|
|
258
|
+
payload["origin"] = self.origin
|
|
259
|
+
|
|
260
|
+
# ES256 + kid required by Apple Maps token spec (link in docstring above)
|
|
261
|
+
return jwt.encode(
|
|
262
|
+
payload,
|
|
263
|
+
self.private_key,
|
|
264
|
+
algorithm="ES256",
|
|
265
|
+
headers={"kid": self.key_id},
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
@_retry_policy
|
|
269
|
+
def _fetch_token(self, auth_jwt: str) -> TokenResponse:
|
|
270
|
+
"""Exchange the auth JWT for a short-lived Maps access token.
|
|
271
|
+
|
|
272
|
+
Calls GET /v1/token with the auth JWT in the Authorization header.
|
|
273
|
+
Apple returns an *access token* (Bearer) valid for ~30 minutes that
|
|
274
|
+
authorises all subsequent Maps API requests (geocode, search, etc.).
|
|
275
|
+
|
|
276
|
+
Spec: https://developer.apple.com/documentation/applemapsserverapi/-v1-token
|
|
277
|
+
"""
|
|
278
|
+
response = httpx2.get(
|
|
279
|
+
f"{self.base_url}/v1/token",
|
|
280
|
+
headers={"Authorization": f"Bearer {auth_jwt}"},
|
|
281
|
+
timeout=_TIMEOUT,
|
|
282
|
+
)
|
|
283
|
+
response.raise_for_status()
|
|
284
|
+
|
|
285
|
+
return TokenResponse.model_validate(response.json())
|
|
286
|
+
|
|
287
|
+
def _ensure_token(self) -> str:
|
|
288
|
+
"""Return a valid access token, fetching a new one if needed.
|
|
289
|
+
|
|
290
|
+
Auth flow:
|
|
291
|
+
1. _create_jwt() → signs a fresh auth JWT with our ES256 private key
|
|
292
|
+
2. _fetch_token() → GETs /v1/token, gets back an access token
|
|
293
|
+
3. access token → cached in-process; reused until 60 s before expiry
|
|
294
|
+
|
|
295
|
+
The access token is what gets sent as `Authorization: Bearer` on every
|
|
296
|
+
Maps API call. The auth JWT is a one-shot credential and is discarded.
|
|
297
|
+
|
|
298
|
+
Spec:
|
|
299
|
+
https://developer.apple.com/documentation/applemapsserverapi/creating-and-using-tokens-with-maps-server-api
|
|
300
|
+
https://developer.apple.com/documentation/applemapsserverapi/-v1-token
|
|
301
|
+
"""
|
|
302
|
+
if self._access_token and time.time() < self._token_expires_at - 60:
|
|
303
|
+
return self._access_token
|
|
304
|
+
|
|
305
|
+
auth_jwt = self._create_jwt()
|
|
306
|
+
token_response = self._fetch_token(auth_jwt)
|
|
307
|
+
self._access_token = token_response.accessToken
|
|
308
|
+
self._token_expires_at = time.time() + token_response.expiresInSeconds
|
|
309
|
+
|
|
310
|
+
return self._access_token
|
|
311
|
+
|
|
312
|
+
def create_token(self) -> str:
|
|
313
|
+
"""Return a valid Maps access token for Apple Maps Server API use.
|
|
314
|
+
|
|
315
|
+
This is the *access token* (not the auth JWT) suitable for server-side
|
|
316
|
+
API calls (geocode, search, etc.). It is NOT suitable for MapKit JS.
|
|
317
|
+
Use `create_mapkit_token()` for browser-side MapKit JS initialization.
|
|
318
|
+
|
|
319
|
+
Spec: https://developer.apple.com/documentation/applemapsserverapi/-v1-token
|
|
320
|
+
"""
|
|
321
|
+
return self._ensure_token()
|
|
322
|
+
|
|
323
|
+
def create_mapkit_token(
|
|
324
|
+
self, *, ttl_seconds: int = _DEFAULT_JWT_TTL_SECONDS
|
|
325
|
+
) -> str:
|
|
326
|
+
"""Return a signed JWT for MapKit JS browser initialization.
|
|
327
|
+
|
|
328
|
+
MapKit JS requires the raw signed JWT, not the Server API access token.
|
|
329
|
+
Pass this to the `authorizationCallback` done() function.
|
|
330
|
+
If `origin` was set on the client, the token is restricted to that domain.
|
|
331
|
+
|
|
332
|
+
:param ttl_seconds: JWT lifetime in seconds (default: 1 hour).
|
|
333
|
+
Apple does not document a maximum for Maps tokens.
|
|
334
|
+
|
|
335
|
+
Spec:
|
|
336
|
+
https://developer.apple.com/documentation/applemapsserverapi/creating-and-using-tokens-with-maps-server-api
|
|
337
|
+
https://developer.apple.com/documentation/mapkitjs/creating-and-using-tokens-with-mapkit-js
|
|
338
|
+
"""
|
|
339
|
+
return self._create_jwt(ttl_seconds=ttl_seconds)
|
|
340
|
+
|
|
341
|
+
@_retry_policy
|
|
342
|
+
def _make_request(self, path: str, params: dict[str, str]) -> dict[str, object]:
|
|
343
|
+
"""Make an authenticated GET request to the Apple Maps API.
|
|
344
|
+
|
|
345
|
+
:param path: API endpoint path (e.g., '/v1/geocode').
|
|
346
|
+
:param params: Query parameters.
|
|
347
|
+
:return: JSON response as a dictionary.
|
|
348
|
+
"""
|
|
349
|
+
token = self._ensure_token()
|
|
350
|
+
url = f"{self.base_url}{path}"
|
|
351
|
+
headers = {"Authorization": f"Bearer {token}"}
|
|
352
|
+
|
|
353
|
+
response = httpx2.get(url, params=params, headers=headers, timeout=_TIMEOUT)
|
|
354
|
+
response.raise_for_status()
|
|
355
|
+
|
|
356
|
+
return response.json()
|
|
357
|
+
|
|
358
|
+
def geocode(self, query: str, **kwargs: Unpack[GeocodeOptions]) -> PlaceResults:
|
|
359
|
+
"""Convert an address string to coordinates.
|
|
360
|
+
|
|
361
|
+
Maps to GET /v1/geocode.
|
|
362
|
+
|
|
363
|
+
:param query: Address to geocode (e.g., "1 Apple Park Way").
|
|
364
|
+
:param limit_to_countries: Comma-separated ISO 3166-1 alpha-2 country codes to limit results.
|
|
365
|
+
:param lang: BCP 47 language code (default: "en-US").
|
|
366
|
+
:param search_location: Lat/lng hint for search bias (e.g., "37.78,-122.42").
|
|
367
|
+
:param search_region: Bounding box hint as "northLat,eastLng,southLat,westLng".
|
|
368
|
+
:param user_location: User's current location as "lat,lng".
|
|
369
|
+
"""
|
|
370
|
+
if not query:
|
|
371
|
+
raise ValueError("query must be provided.")
|
|
372
|
+
|
|
373
|
+
params: dict[str, str] = dict(
|
|
374
|
+
f.compact(
|
|
375
|
+
{
|
|
376
|
+
"q": query,
|
|
377
|
+
"limitToCountries": kwargs.get("limit_to_countries"),
|
|
378
|
+
"lang": kwargs.get("lang"),
|
|
379
|
+
"searchLocation": kwargs.get("search_location"),
|
|
380
|
+
"searchRegion": kwargs.get("search_region"),
|
|
381
|
+
"userLocation": kwargs.get("user_location"),
|
|
382
|
+
}
|
|
383
|
+
)
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
raw = self._make_request("/v1/geocode", params)
|
|
387
|
+
return PlaceResults.model_validate(raw)
|
|
388
|
+
|
|
389
|
+
def reverse_geocode(
|
|
390
|
+
self,
|
|
391
|
+
coordinates: tuple[float, float] | Location,
|
|
392
|
+
**kwargs: Unpack[ReverseGeocodeOptions],
|
|
393
|
+
) -> PlaceResults:
|
|
394
|
+
"""Convert coordinates to an address.
|
|
395
|
+
|
|
396
|
+
Maps to GET /v1/reverseGeocode.
|
|
397
|
+
|
|
398
|
+
:param coordinates: (latitude, longitude) tuple or Location object.
|
|
399
|
+
:param lang: BCP 47 language code (default: "en-US").
|
|
400
|
+
"""
|
|
401
|
+
if isinstance(coordinates, Location):
|
|
402
|
+
loc_str = f"{coordinates.latitude},{coordinates.longitude}"
|
|
403
|
+
else:
|
|
404
|
+
loc_str = f"{coordinates[0]},{coordinates[1]}"
|
|
405
|
+
|
|
406
|
+
params: dict[str, str] = dict(
|
|
407
|
+
f.compact({"loc": loc_str, "lang": kwargs.get("lang")})
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
raw = self._make_request("/v1/reverseGeocode", params)
|
|
411
|
+
return PlaceResults.model_validate(raw)
|
|
412
|
+
|
|
413
|
+
def search(self, query: str, **kwargs: Unpack[SearchOptions]) -> SearchResponse:
|
|
414
|
+
"""Search for places by name or category.
|
|
415
|
+
|
|
416
|
+
Maps to GET /v1/search.
|
|
417
|
+
|
|
418
|
+
:param query: Search query (e.g., "coffee", "Apple Park").
|
|
419
|
+
:param near: Location bias as "lat,lng" (maps to searchLocation).
|
|
420
|
+
Mutually exclusive with lat/lng.
|
|
421
|
+
:param lat: Latitude for location bias (requires lng).
|
|
422
|
+
:param lng: Longitude for location bias (requires lat).
|
|
423
|
+
:param categories: Comma-separated POI categories to include (e.g., "MovieTheater").
|
|
424
|
+
:param exclude_categories: Comma-separated POI categories to exclude.
|
|
425
|
+
:param limit_to_countries: Comma-separated ISO 3166-1 alpha-2 country codes to limit results.
|
|
426
|
+
:param lang: BCP 47 language code (default: "en-US").
|
|
427
|
+
:param result_type_filter: Comma-separated result types (e.g., "Poi", "Address").
|
|
428
|
+
:param search_region: Bounding box hint as "northLat,eastLng,southLat,westLng".
|
|
429
|
+
:param user_location: User's current location as "lat,lng".
|
|
430
|
+
:param search_region_priority: "default" or "required".
|
|
431
|
+
:param enable_pagination: Request paginated results.
|
|
432
|
+
:param page_token: Token identifying which page of results to return.
|
|
433
|
+
:param include_address_categories: Comma-separated AddressCategory values to include.
|
|
434
|
+
:param exclude_address_categories: Comma-separated AddressCategory values to exclude.
|
|
435
|
+
"""
|
|
436
|
+
if not query:
|
|
437
|
+
raise ValueError("query must be provided.")
|
|
438
|
+
|
|
439
|
+
raw_params: dict[str, object] = {
|
|
440
|
+
"q": query,
|
|
441
|
+
"searchLocation": _search_location(
|
|
442
|
+
near=kwargs.get("near"),
|
|
443
|
+
lat=kwargs.get("lat"),
|
|
444
|
+
lng=kwargs.get("lng"),
|
|
445
|
+
),
|
|
446
|
+
"includePoiCategories": _csv(kwargs.get("categories")),
|
|
447
|
+
"excludePoiCategories": _csv(kwargs.get("exclude_categories")),
|
|
448
|
+
"limitToCountries": kwargs.get("limit_to_countries"),
|
|
449
|
+
"lang": kwargs.get("lang"),
|
|
450
|
+
"resultTypeFilter": _csv(kwargs.get("result_type_filter")),
|
|
451
|
+
"searchRegion": kwargs.get("search_region"),
|
|
452
|
+
"userLocation": kwargs.get("user_location"),
|
|
453
|
+
"searchRegionPriority": kwargs.get("search_region_priority"),
|
|
454
|
+
"pageToken": kwargs.get("page_token"),
|
|
455
|
+
"includeAddressCategories": _csv(kwargs.get("include_address_categories")),
|
|
456
|
+
"excludeAddressCategories": _csv(kwargs.get("exclude_address_categories")),
|
|
457
|
+
}
|
|
458
|
+
if kwargs.get("enable_pagination"):
|
|
459
|
+
raw_params["enablePagination"] = "true"
|
|
460
|
+
|
|
461
|
+
params: dict[str, str] = dict(f.compact(raw_params))
|
|
462
|
+
|
|
463
|
+
raw = self._make_request("/v1/search", params)
|
|
464
|
+
return SearchResponse.model_validate(raw)
|
|
465
|
+
|
|
466
|
+
def autocomplete(
|
|
467
|
+
self, query: str, **kwargs: Unpack[AutocompleteOptions]
|
|
468
|
+
) -> SearchAutocompleteResponse:
|
|
469
|
+
"""Autocomplete partial addresses and place names.
|
|
470
|
+
|
|
471
|
+
Maps to GET /v1/searchAutocomplete.
|
|
472
|
+
|
|
473
|
+
Result count is fixed by Apple; the API has no limit/maxResults parameter.
|
|
474
|
+
For more results, use search() (supports enable_pagination) or
|
|
475
|
+
search_completion() to expand a single autocomplete hit.
|
|
476
|
+
|
|
477
|
+
:param query: Partial address or place name to autocomplete.
|
|
478
|
+
:param near: Location bias as "lat,lng" to prefer nearby results.
|
|
479
|
+
Mutually exclusive with lat/lng.
|
|
480
|
+
:param lat: Latitude for location bias (requires lng).
|
|
481
|
+
:param lng: Longitude for location bias (requires lat).
|
|
482
|
+
:param limit_to_countries: Comma-separated ISO 3166-1 alpha-2 country codes to limit results.
|
|
483
|
+
:param lang: BCP 47 language code (default: "en-US").
|
|
484
|
+
:param result_type_filter: Comma-separated result types (e.g., "Address", "Poi").
|
|
485
|
+
:param include_poi_categories: Comma-separated POI categories to include.
|
|
486
|
+
:param exclude_poi_categories: Comma-separated POI categories to exclude.
|
|
487
|
+
:param search_region: Bounding box hint as "northLat,eastLng,southLat,westLng".
|
|
488
|
+
:param user_location: User's current location as "lat,lng".
|
|
489
|
+
:param search_region_priority: "default" or "required".
|
|
490
|
+
:param include_address_categories: Comma-separated AddressCategory values to include.
|
|
491
|
+
:param exclude_address_categories: Comma-separated AddressCategory values to exclude.
|
|
492
|
+
"""
|
|
493
|
+
if not query:
|
|
494
|
+
raise ValueError("query must be provided.")
|
|
495
|
+
|
|
496
|
+
params: dict[str, str] = dict(
|
|
497
|
+
f.compact(
|
|
498
|
+
{
|
|
499
|
+
"q": query,
|
|
500
|
+
"searchLocation": _search_location(
|
|
501
|
+
near=kwargs.get("near"),
|
|
502
|
+
lat=kwargs.get("lat"),
|
|
503
|
+
lng=kwargs.get("lng"),
|
|
504
|
+
),
|
|
505
|
+
# Note: Apple maps documentation says to use 'limitToCountries' param but it seems to work only with almost full address
|
|
506
|
+
"limitToCountries": kwargs.get("limit_to_countries"),
|
|
507
|
+
"lang": kwargs.get("lang"),
|
|
508
|
+
"resultTypeFilter": _csv(kwargs.get("result_type_filter")),
|
|
509
|
+
"includePoiCategories": _csv(kwargs.get("include_poi_categories")),
|
|
510
|
+
"excludePoiCategories": _csv(kwargs.get("exclude_poi_categories")),
|
|
511
|
+
"searchRegion": kwargs.get("search_region"),
|
|
512
|
+
"userLocation": kwargs.get("user_location"),
|
|
513
|
+
"searchRegionPriority": kwargs.get("search_region_priority"),
|
|
514
|
+
"includeAddressCategories": _csv(
|
|
515
|
+
kwargs.get("include_address_categories")
|
|
516
|
+
),
|
|
517
|
+
"excludeAddressCategories": _csv(
|
|
518
|
+
kwargs.get("exclude_address_categories")
|
|
519
|
+
),
|
|
520
|
+
}
|
|
521
|
+
)
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
raw = self._make_request("/v1/searchAutocomplete", params)
|
|
525
|
+
return SearchAutocompleteResponse.model_validate(raw)
|
|
526
|
+
|
|
527
|
+
def search_completion(
|
|
528
|
+
self,
|
|
529
|
+
completion: AutocompleteResult | str,
|
|
530
|
+
*,
|
|
531
|
+
lang: str | None = None,
|
|
532
|
+
) -> SearchResponse:
|
|
533
|
+
"""Resolve an autocomplete suggestion to full search results.
|
|
534
|
+
|
|
535
|
+
Maps to GET /v1/search using the completionUrl from an AutocompleteResult.
|
|
536
|
+
The completionUrl already encodes the query and opaque metadata Apple needs
|
|
537
|
+
to return precise results for the suggestion.
|
|
538
|
+
|
|
539
|
+
:param completion: An AutocompleteResult or its completionUrl string.
|
|
540
|
+
:param lang: BCP 47 language code (e.g., "en-US"). Apple does not carry the
|
|
541
|
+
language through the completionUrl, so callers must re-specify it here.
|
|
542
|
+
:raises ValueError: If the completion has no completionUrl.
|
|
543
|
+
"""
|
|
544
|
+
if isinstance(completion, AutocompleteResult):
|
|
545
|
+
url_str = completion.completionUrl
|
|
546
|
+
if not url_str:
|
|
547
|
+
raise ValueError("AutocompleteResult has no completionUrl.")
|
|
548
|
+
else:
|
|
549
|
+
url_str = completion
|
|
550
|
+
|
|
551
|
+
parsed = urlparse(url_str)
|
|
552
|
+
params: dict[str, str] = {
|
|
553
|
+
k: v[0] for k, v in parse_qs(parsed.query, keep_blank_values=True).items()
|
|
554
|
+
}
|
|
555
|
+
if lang is not None:
|
|
556
|
+
params["lang"] = lang
|
|
557
|
+
|
|
558
|
+
raw = self._make_request(parsed.path, params)
|
|
559
|
+
return SearchResponse.model_validate(raw)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
class AppleMapsError(Exception):
|
|
2
|
+
"""Base exception for all Apple Maps API errors."""
|
|
3
|
+
|
|
4
|
+
pass
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class AppleMapsAuthError(AppleMapsError):
|
|
8
|
+
"""Raised when authentication fails (e.g., invalid JWT, expired token)."""
|
|
9
|
+
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AppleMapsRequestError(AppleMapsError):
|
|
14
|
+
"""Raised when the API returns a client error (4xx besides 429)."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, message, status_code=None, response=None):
|
|
17
|
+
super().__init__(message)
|
|
18
|
+
self.status_code = status_code
|
|
19
|
+
self.response = response
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class AppleMapsRateLimitError(AppleMapsRequestError):
|
|
23
|
+
"""Raised when the API returns a 429 Too Many Requests error."""
|
|
24
|
+
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AppleMapsServerError(AppleMapsError):
|
|
29
|
+
"""Raised when the API returns a 5xx server error."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, message, status_code=None, response=None):
|
|
32
|
+
super().__init__(message)
|
|
33
|
+
self.status_code = status_code
|
|
34
|
+
self.response = response
|