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.
@@ -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
+ ]
@@ -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