apple-maps-api 0.1.0__tar.gz → 0.2.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: apple-maps-api
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: A modern Python client for the Apple Maps Server API with automatic JWT management and type safety
5
5
  Keywords: apple-maps,geocoding,search,geolocation
6
6
  Author: Michael Bianco
@@ -32,6 +32,13 @@ The goal of this library is to provide similar functionality to the radar python
32
32
  - **Resilient**: Automatic retries with exponential backoff for transient network and server errors.
33
33
  - **Modern**: Uses `httpx` for synchronous requests, with a structure that's ready for future async support.
34
34
 
35
+ ## Full-stack Example
36
+
37
+ See [`examples/full-stack/`](examples/full-stack/) for a runnable FastAPI + React/MapKit JS app that demonstrates:
38
+
39
+ - Minting MapKit JS tokens server-side (`create_mapkit_token`) and rendering an interactive map
40
+ - Address autocomplete via this client, proxied through a small FastAPI API
41
+
35
42
  ## Installation
36
43
 
37
44
  ```bash
@@ -73,6 +80,29 @@ from apple_maps_api import AppleMapsClient
73
80
  client = AppleMapsClient.from_env()
74
81
  ```
75
82
 
83
+ ### Generating a Long-lived Frontend Token
84
+
85
+ MapKit JS needs a signed JWT (not the Server API access token). Use `create_mapkit_token()`:
86
+
87
+ ```python
88
+ from apple_maps_api import AppleMapsClient
89
+
90
+ client = AppleMapsClient.from_env()
91
+
92
+ # default TTL is 1 hour — fine for production token endpoints
93
+ token = client.create_mapkit_token()
94
+
95
+ # long-lived token for local FE work (e.g. 30 days)
96
+ token = client.create_mapkit_token(ttl_seconds=30 * 24 * 60 * 60)
97
+ print(token)
98
+ ```
99
+
100
+ Paste the printed JWT into MapKit JS / [mapkit-react](https://github.com/Nicolapps/mapkit-react) during development so you do not need a backend token endpoint.
101
+
102
+ Optional domain lock: set `origin` on the client (or `APPLE_MAPS_ORIGIN`) so the token only works from that origin (e.g. `http://localhost:5173`). Omit `origin` for unrestricted local play.
103
+
104
+ Do not ship long-lived tokens in production frontend builds — mint short-lived tokens from your server instead (see [`examples/full-stack/`](examples/full-stack/)).
105
+
76
106
  ### Geocoding
77
107
 
78
108
  Convert an address string to coordinates:
@@ -95,7 +125,7 @@ for place in results.results:
95
125
  Convert coordinates back to a structured address:
96
126
 
97
127
  ```python
98
- results = client.reverse_geocode((37.3346, -122.0090))
128
+ results = client.reverse_geocode(lat=37.3346, lng=-122.0090)
99
129
 
100
130
  if results.results:
101
131
  print(results.results[0].formattedAddressLines)
@@ -110,8 +140,7 @@ if results.results:
110
140
  Search for points of interest near a location:
111
141
 
112
142
  ```python
113
- results = client.search("pizza", near="37.3346,-122.0090")
114
- # or: client.search("pizza", lat=37.3346, lng=-122.0090)
143
+ results = client.search("pizza", lat=37.3346, lng=-122.0090)
115
144
 
116
145
  for place in results.results:
117
146
  print(f"{place.name} - {place.formattedAddressLines}")
@@ -127,7 +156,12 @@ for place in results.results:
127
156
  Provide search completions for a partial query and filter by country:
128
157
 
129
158
  ```python
130
- results = client.autocomplete("1 Apple Park", limit_to_countries="US", lat=37.3346, lng=-122.0090)
159
+ results = client.autocomplete(
160
+ "1 Apple Park",
161
+ limit_to_countries=["US"],
162
+ lat=37.3346,
163
+ lng=-122.0090,
164
+ )
131
165
 
132
166
  for completion in results.results:
133
167
  print(completion.displayLines)
@@ -137,6 +171,47 @@ for completion in results.results:
137
171
  # results.results[0].completionUrl => "/v1/search?q=1%20Apple%20Park%20Way..."
138
172
  ```
139
173
 
174
+ Multi-value filters (countries, categories, result types, …) accept a list; a single string still works.
175
+
176
+ ### Importance of Lat/Lng Hinting for Result Relevance
177
+
178
+ Always pass `lat`/`lng`. Autocomplete ranking is heavily biased by location — without it you get scattered results across the country for common street names. For server-side autocomplete, resolve the user's approximate location from their IP:
179
+
180
+ 1. Get the client public IP with [fastapi-ipware](https://github.com/iloveitaly/fastapi-ipware)
181
+ 2. Look up lat/lng via [observabilitystack/geoip-api](https://github.com/observabilitystack/geoip-api) (`ghcr.io/observabilitystack/geoip-api:latest`)
182
+
183
+ ### Location Bias Options
184
+
185
+ Search, autocomplete, and geocode share the same location-hint shape:
186
+
187
+ - **`lat` / `lng`** — app-defined search bias (Apple `searchLocation`). Prefer nearby results around this map point.
188
+ - **`user_lat` / `user_lng`** — the user's current position (Apple `userLocation`). Used for ranking; if `lat`/`lng` are omitted, some endpoints may fall back to this.
189
+ - **`search_region`** — bounding-box hint as a `MapRegion` (Apple `searchRegion`).
190
+ - **`search_region_priority`** — `SearchRegionPriority.default` or `.required` (or the strings `"default"` / `"required"`).
191
+
192
+ ```python
193
+ from apple_maps_api import MapRegion, SearchRegionPriority
194
+
195
+ region = MapRegion(
196
+ northLatitude=37.5,
197
+ eastLongitude=-121.7,
198
+ southLatitude=37.1,
199
+ westLongitude=-122.5,
200
+ )
201
+
202
+ results = client.search(
203
+ "coffee",
204
+ lat=37.3346,
205
+ lng=-122.0090,
206
+ user_lat=37.3317,
207
+ user_lng=-122.0307,
208
+ search_region=region,
209
+ search_region_priority=SearchRegionPriority.required,
210
+ categories=["Cafe"],
211
+ limit_to_countries=["US"],
212
+ )
213
+ ```
214
+
140
215
  ### Helper Functions
141
216
 
142
217
  The library includes high-level helpers for common geocoding operations:
@@ -207,7 +282,7 @@ result = geocode_coordinates(client, lat=40.7589, lon=-73.9851)
207
282
  Apple Park, Cupertino CA (`37.3346, -122.0090`):
208
283
 
209
284
  ```python
210
- place = client.reverse_geocode((37.3346, -122.0090)).results[0]
285
+ place = client.reverse_geocode(lat=37.3346, lng=-122.0090).results[0]
211
286
  # place.name => "Apple Park"
212
287
  # place.formattedAddressLines => ["Apple Park", "1 Apple Park Way", "Cupertino, CA 95014", "United States"]
213
288
  # place.structuredAddress.administrativeArea => "California"
@@ -226,7 +301,7 @@ place = client.reverse_geocode((37.3346, -122.0090)).results[0]
226
301
  Times Square, NYC (`40.7589, -73.9851`):
227
302
 
228
303
  ```python
229
- place = client.reverse_geocode((40.7589, -73.9851)).results[0]
304
+ place = client.reverse_geocode(lat=40.7589, lng=-73.9851).results[0]
230
305
  # place.name => "Times Square"
231
306
  # place.formattedAddressLines => ["Times Square", "1552–1568 Broadway", "New York, NY 10036", "United States"]
232
307
  # place.structuredAddress.administrativeArea => "New York"
@@ -245,7 +320,7 @@ place = client.reverse_geocode((40.7589, -73.9851)).results[0]
245
320
  Beverly Hills, CA (`34.1025226, -118.4167959`) — note `locality` vs formatted address mismatch:
246
321
 
247
322
  ```python
248
- place = client.reverse_geocode((34.1025226, -118.4167959)).results[0]
323
+ place = client.reverse_geocode(lat=34.1025226, lng=-118.4167959).results[0]
249
324
  # place.name => "1731 N Franklin Canyon Dr"
250
325
  # place.formattedAddressLines => ["1731 N Franklin Canyon Dr", "Beverly Hills, CA 90210", "United States"]
251
326
  # place.structuredAddress.administrativeArea => "California"
@@ -261,6 +336,11 @@ place = client.reverse_geocode((34.1025226, -118.4167959)).results[0]
261
336
  # place.structuredAddress.dependentLocalities => ["Beverly Crest"]
262
337
  ```
263
338
 
339
+ ## Related Projects
340
+
341
+ - [fastapi-ipware](https://github.com/iloveitaly/fastapi-ipware) — client IP extraction for FastAPI (useful for lat/lng hinting via GeoIP)
342
+ - [mapkit-react](https://github.com/Nicolapps/mapkit-react) — React bindings for MapKit JS (used in the full-stack example)
343
+
264
344
  ## [MIT License](LICENSE.md)
265
345
 
266
346
  ---
@@ -16,6 +16,13 @@ The goal of this library is to provide similar functionality to the radar python
16
16
  - **Resilient**: Automatic retries with exponential backoff for transient network and server errors.
17
17
  - **Modern**: Uses `httpx` for synchronous requests, with a structure that's ready for future async support.
18
18
 
19
+ ## Full-stack Example
20
+
21
+ See [`examples/full-stack/`](examples/full-stack/) for a runnable FastAPI + React/MapKit JS app that demonstrates:
22
+
23
+ - Minting MapKit JS tokens server-side (`create_mapkit_token`) and rendering an interactive map
24
+ - Address autocomplete via this client, proxied through a small FastAPI API
25
+
19
26
  ## Installation
20
27
 
21
28
  ```bash
@@ -57,6 +64,29 @@ from apple_maps_api import AppleMapsClient
57
64
  client = AppleMapsClient.from_env()
58
65
  ```
59
66
 
67
+ ### Generating a Long-lived Frontend Token
68
+
69
+ MapKit JS needs a signed JWT (not the Server API access token). Use `create_mapkit_token()`:
70
+
71
+ ```python
72
+ from apple_maps_api import AppleMapsClient
73
+
74
+ client = AppleMapsClient.from_env()
75
+
76
+ # default TTL is 1 hour — fine for production token endpoints
77
+ token = client.create_mapkit_token()
78
+
79
+ # long-lived token for local FE work (e.g. 30 days)
80
+ token = client.create_mapkit_token(ttl_seconds=30 * 24 * 60 * 60)
81
+ print(token)
82
+ ```
83
+
84
+ Paste the printed JWT into MapKit JS / [mapkit-react](https://github.com/Nicolapps/mapkit-react) during development so you do not need a backend token endpoint.
85
+
86
+ Optional domain lock: set `origin` on the client (or `APPLE_MAPS_ORIGIN`) so the token only works from that origin (e.g. `http://localhost:5173`). Omit `origin` for unrestricted local play.
87
+
88
+ Do not ship long-lived tokens in production frontend builds — mint short-lived tokens from your server instead (see [`examples/full-stack/`](examples/full-stack/)).
89
+
60
90
  ### Geocoding
61
91
 
62
92
  Convert an address string to coordinates:
@@ -79,7 +109,7 @@ for place in results.results:
79
109
  Convert coordinates back to a structured address:
80
110
 
81
111
  ```python
82
- results = client.reverse_geocode((37.3346, -122.0090))
112
+ results = client.reverse_geocode(lat=37.3346, lng=-122.0090)
83
113
 
84
114
  if results.results:
85
115
  print(results.results[0].formattedAddressLines)
@@ -94,8 +124,7 @@ if results.results:
94
124
  Search for points of interest near a location:
95
125
 
96
126
  ```python
97
- results = client.search("pizza", near="37.3346,-122.0090")
98
- # or: client.search("pizza", lat=37.3346, lng=-122.0090)
127
+ results = client.search("pizza", lat=37.3346, lng=-122.0090)
99
128
 
100
129
  for place in results.results:
101
130
  print(f"{place.name} - {place.formattedAddressLines}")
@@ -111,7 +140,12 @@ for place in results.results:
111
140
  Provide search completions for a partial query and filter by country:
112
141
 
113
142
  ```python
114
- results = client.autocomplete("1 Apple Park", limit_to_countries="US", lat=37.3346, lng=-122.0090)
143
+ results = client.autocomplete(
144
+ "1 Apple Park",
145
+ limit_to_countries=["US"],
146
+ lat=37.3346,
147
+ lng=-122.0090,
148
+ )
115
149
 
116
150
  for completion in results.results:
117
151
  print(completion.displayLines)
@@ -121,6 +155,47 @@ for completion in results.results:
121
155
  # results.results[0].completionUrl => "/v1/search?q=1%20Apple%20Park%20Way..."
122
156
  ```
123
157
 
158
+ Multi-value filters (countries, categories, result types, …) accept a list; a single string still works.
159
+
160
+ ### Importance of Lat/Lng Hinting for Result Relevance
161
+
162
+ Always pass `lat`/`lng`. Autocomplete ranking is heavily biased by location — without it you get scattered results across the country for common street names. For server-side autocomplete, resolve the user's approximate location from their IP:
163
+
164
+ 1. Get the client public IP with [fastapi-ipware](https://github.com/iloveitaly/fastapi-ipware)
165
+ 2. Look up lat/lng via [observabilitystack/geoip-api](https://github.com/observabilitystack/geoip-api) (`ghcr.io/observabilitystack/geoip-api:latest`)
166
+
167
+ ### Location Bias Options
168
+
169
+ Search, autocomplete, and geocode share the same location-hint shape:
170
+
171
+ - **`lat` / `lng`** — app-defined search bias (Apple `searchLocation`). Prefer nearby results around this map point.
172
+ - **`user_lat` / `user_lng`** — the user's current position (Apple `userLocation`). Used for ranking; if `lat`/`lng` are omitted, some endpoints may fall back to this.
173
+ - **`search_region`** — bounding-box hint as a `MapRegion` (Apple `searchRegion`).
174
+ - **`search_region_priority`** — `SearchRegionPriority.default` or `.required` (or the strings `"default"` / `"required"`).
175
+
176
+ ```python
177
+ from apple_maps_api import MapRegion, SearchRegionPriority
178
+
179
+ region = MapRegion(
180
+ northLatitude=37.5,
181
+ eastLongitude=-121.7,
182
+ southLatitude=37.1,
183
+ westLongitude=-122.5,
184
+ )
185
+
186
+ results = client.search(
187
+ "coffee",
188
+ lat=37.3346,
189
+ lng=-122.0090,
190
+ user_lat=37.3317,
191
+ user_lng=-122.0307,
192
+ search_region=region,
193
+ search_region_priority=SearchRegionPriority.required,
194
+ categories=["Cafe"],
195
+ limit_to_countries=["US"],
196
+ )
197
+ ```
198
+
124
199
  ### Helper Functions
125
200
 
126
201
  The library includes high-level helpers for common geocoding operations:
@@ -191,7 +266,7 @@ result = geocode_coordinates(client, lat=40.7589, lon=-73.9851)
191
266
  Apple Park, Cupertino CA (`37.3346, -122.0090`):
192
267
 
193
268
  ```python
194
- place = client.reverse_geocode((37.3346, -122.0090)).results[0]
269
+ place = client.reverse_geocode(lat=37.3346, lng=-122.0090).results[0]
195
270
  # place.name => "Apple Park"
196
271
  # place.formattedAddressLines => ["Apple Park", "1 Apple Park Way", "Cupertino, CA 95014", "United States"]
197
272
  # place.structuredAddress.administrativeArea => "California"
@@ -210,7 +285,7 @@ place = client.reverse_geocode((37.3346, -122.0090)).results[0]
210
285
  Times Square, NYC (`40.7589, -73.9851`):
211
286
 
212
287
  ```python
213
- place = client.reverse_geocode((40.7589, -73.9851)).results[0]
288
+ place = client.reverse_geocode(lat=40.7589, lng=-73.9851).results[0]
214
289
  # place.name => "Times Square"
215
290
  # place.formattedAddressLines => ["Times Square", "1552–1568 Broadway", "New York, NY 10036", "United States"]
216
291
  # place.structuredAddress.administrativeArea => "New York"
@@ -229,7 +304,7 @@ place = client.reverse_geocode((40.7589, -73.9851)).results[0]
229
304
  Beverly Hills, CA (`34.1025226, -118.4167959`) — note `locality` vs formatted address mismatch:
230
305
 
231
306
  ```python
232
- place = client.reverse_geocode((34.1025226, -118.4167959)).results[0]
307
+ place = client.reverse_geocode(lat=34.1025226, lng=-118.4167959).results[0]
233
308
  # place.name => "1731 N Franklin Canyon Dr"
234
309
  # place.formattedAddressLines => ["1731 N Franklin Canyon Dr", "Beverly Hills, CA 90210", "United States"]
235
310
  # place.structuredAddress.administrativeArea => "California"
@@ -245,6 +320,11 @@ place = client.reverse_geocode((34.1025226, -118.4167959)).results[0]
245
320
  # place.structuredAddress.dependentLocalities => ["Beverly Crest"]
246
321
  ```
247
322
 
323
+ ## Related Projects
324
+
325
+ - [fastapi-ipware](https://github.com/iloveitaly/fastapi-ipware) — client IP extraction for FastAPI (useful for lat/lng hinting via GeoIP)
326
+ - [mapkit-react](https://github.com/Nicolapps/mapkit-react) — React bindings for MapKit JS (used in the full-stack example)
327
+
248
328
  ## [MIT License](LICENSE.md)
249
329
 
250
330
  ---
@@ -18,6 +18,7 @@ from .models import (
18
18
  SearchACResultType,
19
19
  SearchAutocompleteResponse,
20
20
  SearchPlace,
21
+ SearchRegionPriority,
21
22
  SearchResponse,
22
23
  SearchResultType,
23
24
  StructuredAddress,
@@ -41,6 +42,7 @@ __all__ = [
41
42
  "SearchACResultType",
42
43
  "SearchAutocompleteResponse",
43
44
  "SearchPlace",
45
+ "SearchRegionPriority",
44
46
  "SearchResponse",
45
47
  "SearchResultType",
46
48
  "StructuredAddress",
@@ -10,7 +10,7 @@ import logging
10
10
  import os
11
11
  import time
12
12
  from collections.abc import Sequence
13
- from typing import TypedDict, Unpack
13
+ from typing import Required, TypedDict, Unpack, overload
14
14
  from urllib.parse import parse_qs, urlparse
15
15
 
16
16
  import funcy as f
@@ -27,11 +27,12 @@ from tenacity import (
27
27
  from apple_maps_api.models import (
28
28
  AddressCategory,
29
29
  AutocompleteResult,
30
- Location,
30
+ MapRegion,
31
31
  PlaceResults,
32
32
  PoiCategory,
33
33
  SearchACResultType,
34
34
  SearchAutocompleteResponse,
35
+ SearchRegionPriority,
35
36
  SearchResponse,
36
37
  SearchResultType,
37
38
  TokenResponse,
@@ -41,7 +42,7 @@ log = logging.getLogger(__name__)
41
42
 
42
43
 
43
44
  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
+ """Serialize a string or sequence of values to Apple's comma-separated form."""
45
46
  if value is None:
46
47
  return None
47
48
  if isinstance(value, str):
@@ -49,17 +50,12 @@ def _csv(value: str | Sequence | None) -> str | None:
49
50
  return ",".join(str(v) for v in value)
50
51
 
51
52
 
52
- def _search_location(
53
+ def _lat_lng_to_apple_str(
53
54
  *,
54
- near: str | None = None,
55
55
  lat: float | None = None,
56
56
  lng: float | None = None,
57
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
-
58
+ """Serialize an optional lat/lng pair to Apple's 'lat,lng' string form."""
63
59
  if lat is None and lng is None:
64
60
  return None
65
61
 
@@ -67,6 +63,17 @@ def _search_location(
67
63
  return f"{lat},{lng}"
68
64
 
69
65
 
66
+ def _search_region(region: MapRegion | None) -> str | None:
67
+ """Serialize MapRegion to Apple's searchRegion form: northLat,eastLng,southLat,westLng."""
68
+ if region is None:
69
+ return None
70
+
71
+ return (
72
+ f"{region.northLatitude},{region.eastLongitude},"
73
+ f"{region.southLatitude},{region.westLongitude}"
74
+ )
75
+
76
+
70
77
  # shared timeout: 10s connect, 30s read
71
78
  _TIMEOUT = httpx2.Timeout(connect=10, read=30, write=10, pool=10)
72
79
 
@@ -103,50 +110,119 @@ _retry_policy = retry(
103
110
  )
104
111
 
105
112
 
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
113
+ class _GeocodeOptionsBase(TypedDict, total=False):
114
+ """Shared optional kwargs for :meth:`AppleMapsClient.geocode`.
112
115
 
116
+ Location params (all optional; different roles):
117
+
118
+ - ``lat`` / ``lng``: app-defined point hint; sent as Apple's
119
+ ``searchLocation`` (must pass both). Primary geographic bias.
120
+ - ``search_region``: app-defined bounding-box hint as :class:`MapRegion`.
121
+ - ``user_lat`` / ``user_lng``: the *user's* current position (must pass both).
122
+ Used for ranking/relevance; if ``lat``/``lng`` are omitted, some Apple
123
+ endpoints may fall back to user position as the search hint.
124
+ """
113
125
 
114
- class ReverseGeocodeOptions(TypedDict, total=False):
126
+ limit_to_countries: str | Sequence[str]
115
127
  lang: str
128
+ search_region: MapRegion
129
+ user_lat: float
130
+ user_lng: float
116
131
 
117
132
 
118
- class SearchOptions(TypedDict, total=False):
119
- near: str
133
+ class GeocodeOptionsLatLng(_GeocodeOptionsBase, total=False):
134
+ """Geocode options with required ``lat`` and ``lng`` location bias."""
135
+
136
+ lat: Required[float]
137
+ lng: Required[float]
138
+
139
+
140
+ class GeocodeOptions(_GeocodeOptionsBase, total=False):
141
+ """All optional kwargs for :meth:`AppleMapsClient.geocode`."""
142
+
120
143
  lat: float
121
144
  lng: float
122
- categories: str | Sequence[PoiCategory]
123
- exclude_categories: str | Sequence[PoiCategory]
124
- limit_to_countries: str
145
+
146
+
147
+ # Location bias is optional; when set both lat and lng are required.
148
+ # Overloads below enforce that for pyright; runtime asserts in _lat_lng_to_apple_str.
149
+ class _SearchOptionsBase(TypedDict, total=False):
150
+ """Shared optional kwargs for :meth:`AppleMapsClient.search`.
151
+
152
+ Location params (all optional; different roles):
153
+
154
+ - ``lat`` / ``lng``: app-defined point hint; sent as Apple's
155
+ ``searchLocation`` (must pass both). Primary geographic bias —
156
+ "search near this map point".
157
+ - ``search_region``: app-defined bounding-box hint as :class:`MapRegion`.
158
+ - ``user_lat`` / ``user_lng``: the *user's* current position (must pass both).
159
+ Used for ranking/relevance; Search may fall back to it as
160
+ ``searchLocation`` when ``lat``/``lng`` are omitted.
161
+ """
162
+
163
+ categories: str | Sequence[str | PoiCategory]
164
+ exclude_categories: str | Sequence[str | PoiCategory]
165
+ limit_to_countries: str | Sequence[str]
125
166
  lang: str
126
- result_type_filter: str | Sequence[SearchResultType]
127
- search_region: str
128
- user_location: str
129
- search_region_priority: str
167
+ result_type_filter: str | Sequence[str | SearchResultType]
168
+ search_region: MapRegion
169
+ user_lat: float
170
+ user_lng: float
171
+ search_region_priority: SearchRegionPriority | str
130
172
  enable_pagination: bool
131
173
  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
174
+ include_address_categories: str | Sequence[str | AddressCategory]
175
+ exclude_address_categories: str | Sequence[str | AddressCategory]
176
+
177
+
178
+ class SearchOptionsLatLng(_SearchOptionsBase, total=False):
179
+ """Search options with required ``lat`` and ``lng`` location bias."""
180
+
181
+ lat: Required[float]
182
+ lng: Required[float]
183
+
184
+
185
+ class SearchOptions(_SearchOptionsBase, total=False):
186
+ """All optional kwargs for :meth:`AppleMapsClient.search`."""
187
+
188
+ lat: float
189
+ lng: float
190
+
191
+
192
+ class _AutocompleteOptionsBase(TypedDict, total=False):
193
+ """Shared optional kwargs for :meth:`AppleMapsClient.autocomplete`.
194
+
195
+ Location params match search (see :class:`_SearchOptionsBase`):
196
+ ``lat``/``lng`` → ``searchLocation`` bias; ``search_region`` as MapRegion;
197
+ ``user_lat``/``user_lng`` is the user's position (ranking; may fall back as
198
+ ``searchLocation`` when lat/lng are omitted).
199
+ """
200
+
201
+ limit_to_countries: str | Sequence[str]
202
+ lang: str
203
+ result_type_filter: str | Sequence[str | SearchACResultType]
204
+ include_poi_categories: str | Sequence[str | PoiCategory]
205
+ exclude_poi_categories: str | Sequence[str | PoiCategory]
206
+ search_region: MapRegion
207
+ user_lat: float
208
+ user_lng: float
209
+ search_region_priority: SearchRegionPriority | str
210
+ include_address_categories: str | Sequence[str | AddressCategory]
211
+ exclude_address_categories: str | Sequence[str | AddressCategory]
212
+
213
+
214
+ class AutocompleteOptionsLatLng(_AutocompleteOptionsBase, total=False):
215
+ """Autocomplete options with required ``lat`` and ``lng`` location bias."""
216
+
217
+ lat: Required[float]
218
+ lng: Required[float]
219
+
220
+
221
+ class AutocompleteOptions(_AutocompleteOptionsBase, total=False):
222
+ """All optional kwargs for :meth:`AppleMapsClient.autocomplete`."""
223
+
224
+ lat: float
225
+ lng: float
150
226
 
151
227
 
152
228
  class AppleMapsClient:
@@ -355,17 +431,33 @@ class AppleMapsClient:
355
431
 
356
432
  return response.json()
357
433
 
434
+ @overload
435
+ def geocode(
436
+ self, query: str, **kwargs: Unpack[GeocodeOptionsLatLng]
437
+ ) -> PlaceResults: ...
438
+
439
+ @overload
440
+ def geocode(
441
+ self, query: str, **kwargs: Unpack[_GeocodeOptionsBase]
442
+ ) -> PlaceResults: ...
443
+
358
444
  def geocode(self, query: str, **kwargs: Unpack[GeocodeOptions]) -> PlaceResults:
359
445
  """Convert an address string to coordinates.
360
446
 
361
447
  Maps to GET /v1/geocode.
362
448
 
363
449
  :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.
450
+ :param limit_to_countries: ISO 3166-1 alpha-2 country codes to limit results
451
+ (e.g. ``["US", "CA"]``).
365
452
  :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".
453
+ :param lat: Latitude for app-defined search bias (must pass with lng).
454
+ Sent as Apple's ``searchLocation``.
455
+ :param lng: Longitude for app-defined search bias (must pass with lat).
456
+ :param search_region: App-defined bounding-box hint as :class:`MapRegion`.
457
+ :param user_lat: Latitude of the user's position (must pass with user_lng).
458
+ Used for ranking/relevance; if ``lat``/``lng`` are omitted, some
459
+ endpoints may fall back to this as the search hint.
460
+ :param user_lng: Longitude of the user's position (must pass with user_lat).
369
461
  """
370
462
  if not query:
371
463
  raise ValueError("query must be provided.")
@@ -374,11 +466,17 @@ class AppleMapsClient:
374
466
  f.compact(
375
467
  {
376
468
  "q": query,
377
- "limitToCountries": kwargs.get("limit_to_countries"),
469
+ "limitToCountries": _csv(kwargs.get("limit_to_countries")),
378
470
  "lang": kwargs.get("lang"),
379
- "searchLocation": kwargs.get("search_location"),
380
- "searchRegion": kwargs.get("search_region"),
381
- "userLocation": kwargs.get("user_location"),
471
+ "searchLocation": _lat_lng_to_apple_str(
472
+ lat=kwargs.get("lat"),
473
+ lng=kwargs.get("lng"),
474
+ ),
475
+ "searchRegion": _search_region(kwargs.get("search_region")),
476
+ "userLocation": _lat_lng_to_apple_str(
477
+ lat=kwargs.get("user_lat"),
478
+ lng=kwargs.get("user_lng"),
479
+ ),
382
480
  }
383
481
  )
384
482
  )
@@ -388,70 +486,98 @@ class AppleMapsClient:
388
486
 
389
487
  def reverse_geocode(
390
488
  self,
391
- coordinates: tuple[float, float] | Location,
392
- **kwargs: Unpack[ReverseGeocodeOptions],
489
+ *,
490
+ lat: float,
491
+ lng: float,
492
+ lang: str | None = None,
393
493
  ) -> PlaceResults:
394
494
  """Convert coordinates to an address.
395
495
 
396
496
  Maps to GET /v1/reverseGeocode.
397
497
 
398
- :param coordinates: (latitude, longitude) tuple or Location object.
498
+ :param lat: Latitude of the point to reverse geocode.
499
+ :param lng: Longitude of the point to reverse geocode.
399
500
  :param lang: BCP 47 language code (default: "en-US").
400
501
  """
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
- )
502
+ params: dict[str, str] = dict(f.compact({"loc": f"{lat},{lng}", "lang": lang}))
409
503
 
410
504
  raw = self._make_request("/v1/reverseGeocode", params)
411
505
  return PlaceResults.model_validate(raw)
412
506
 
413
- def search(self, query: str, **kwargs: Unpack[SearchOptions]) -> SearchResponse:
507
+ @overload
508
+ def search(
509
+ self, query: str, **kwargs: Unpack[SearchOptionsLatLng]
510
+ ) -> SearchResponse: ...
511
+
512
+ @overload
513
+ def search(
514
+ self, query: str = "", **kwargs: Unpack[_SearchOptionsBase]
515
+ ) -> SearchResponse: ...
516
+
517
+ def search(
518
+ self, query: str = "", **kwargs: Unpack[SearchOptions]
519
+ ) -> SearchResponse:
414
520
  """Search for places by name or category.
415
521
 
416
522
  Maps to GET /v1/search.
417
523
 
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.
524
+ Location bias (optional): pass ``lat=`` and ``lng=`` together.
525
+
526
+ For page 2+, pass only ``page_token`` from a prior response's
527
+ ``paginationInfo.nextPageToken``. Apple rejects other search params
528
+ (including ``q`` and ``enablePagination``) on token requests.
529
+
530
+ :param query: Search query (e.g., "coffee", "Apple Park"). Required
531
+ unless ``page_token`` is set.
532
+ :param lat: Latitude for app-defined search bias (must pass with lng).
533
+ Sent as Apple's ``searchLocation`` — "search near this map point".
534
+ :param lng: Longitude for app-defined search bias (must pass with lat).
535
+ :param categories: POI categories to include (e.g. ``["MovieTheater", "Cafe"]``).
536
+ :param exclude_categories: POI categories to exclude (e.g. ``["Parking"]``).
537
+ :param limit_to_countries: ISO 3166-1 alpha-2 country codes
538
+ (e.g. ``["US", "CA"]``).
426
539
  :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.
540
+ :param result_type_filter: Result types (e.g. ``["Poi", "Address"]``).
541
+ :param search_region: App-defined bounding-box hint as :class:`MapRegion`.
542
+ :param user_lat: Latitude of the user's position (must pass with user_lng).
543
+ Used for ranking/relevance; Search may fall back to it as
544
+ ``searchLocation`` when ``lat``/``lng`` are omitted.
545
+ :param user_lng: Longitude of the user's position (must pass with user_lat).
546
+ :param search_region_priority: Importance of ``search_region``
547
+ (:class:`SearchRegionPriority` or ``"default"`` / ``"required"``).
548
+ :param enable_pagination: Request paginated results (first page only).
549
+ :param page_token: Token from ``paginationInfo`` for a subsequent page.
550
+ When set, sent alone — do not combine with query or other filters.
551
+ :param include_address_categories: Address categories to include
552
+ (e.g. ``["AdministrativeArea"]``).
553
+ :param exclude_address_categories: Address categories to exclude.
435
554
  """
555
+ page_token = kwargs.get("page_token")
556
+ if page_token:
557
+ # Apple: pageToken requests must not include q, enablePagination, etc.
558
+ raw = self._make_request("/v1/search", {"pageToken": page_token})
559
+ return SearchResponse.model_validate(raw)
560
+
436
561
  if not query:
437
562
  raise ValueError("query must be provided.")
438
563
 
439
564
  raw_params: dict[str, object] = {
440
565
  "q": query,
441
- "searchLocation": _search_location(
442
- near=kwargs.get("near"),
566
+ "searchLocation": _lat_lng_to_apple_str(
443
567
  lat=kwargs.get("lat"),
444
568
  lng=kwargs.get("lng"),
445
569
  ),
446
570
  "includePoiCategories": _csv(kwargs.get("categories")),
447
571
  "excludePoiCategories": _csv(kwargs.get("exclude_categories")),
448
- "limitToCountries": kwargs.get("limit_to_countries"),
572
+ "limitToCountries": _csv(kwargs.get("limit_to_countries")),
449
573
  "lang": kwargs.get("lang"),
450
574
  "resultTypeFilter": _csv(kwargs.get("result_type_filter")),
451
- "searchRegion": kwargs.get("search_region"),
452
- "userLocation": kwargs.get("user_location"),
575
+ "searchRegion": _search_region(kwargs.get("search_region")),
576
+ "userLocation": _lat_lng_to_apple_str(
577
+ lat=kwargs.get("user_lat"),
578
+ lng=kwargs.get("user_lng"),
579
+ ),
453
580
  "searchRegionPriority": kwargs.get("search_region_priority"),
454
- "pageToken": kwargs.get("page_token"),
455
581
  "includeAddressCategories": _csv(kwargs.get("include_address_categories")),
456
582
  "excludeAddressCategories": _csv(kwargs.get("exclude_address_categories")),
457
583
  }
@@ -463,6 +589,16 @@ class AppleMapsClient:
463
589
  raw = self._make_request("/v1/search", params)
464
590
  return SearchResponse.model_validate(raw)
465
591
 
592
+ @overload
593
+ def autocomplete(
594
+ self, query: str, **kwargs: Unpack[AutocompleteOptionsLatLng]
595
+ ) -> SearchAutocompleteResponse: ...
596
+
597
+ @overload
598
+ def autocomplete(
599
+ self, query: str, **kwargs: Unpack[_AutocompleteOptionsBase]
600
+ ) -> SearchAutocompleteResponse: ...
601
+
466
602
  def autocomplete(
467
603
  self, query: str, **kwargs: Unpack[AutocompleteOptions]
468
604
  ) -> SearchAutocompleteResponse:
@@ -470,25 +606,32 @@ class AppleMapsClient:
470
606
 
471
607
  Maps to GET /v1/searchAutocomplete.
472
608
 
609
+ Location bias (optional): pass ``lat=`` and ``lng=`` together.
610
+
473
611
  Result count is fixed by Apple; the API has no limit/maxResults parameter.
474
612
  For more results, use search() (supports enable_pagination) or
475
613
  search_completion() to expand a single autocomplete hit.
476
614
 
477
615
  :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.
616
+ :param lat: Latitude for app-defined search bias (must pass with lng).
617
+ Sent as Apple's ``searchLocation`` — "search near this map point".
618
+ :param lng: Longitude for app-defined search bias (must pass with lat).
619
+ :param limit_to_countries: ISO 3166-1 alpha-2 country codes
620
+ (e.g. ``["US", "CA"]``).
483
621
  :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.
622
+ :param result_type_filter: Result types (e.g. ``["Address", "Poi"]``).
623
+ :param include_poi_categories: POI categories to include
624
+ (e.g. ``["Cafe"]``).
625
+ :param exclude_poi_categories: POI categories to exclude.
626
+ :param search_region: App-defined bounding-box hint as :class:`MapRegion`.
627
+ :param user_lat: Latitude of the user's position (must pass with user_lng).
628
+ Used for ranking/relevance; may fall back as ``searchLocation``
629
+ when ``lat``/``lng`` are omitted.
630
+ :param user_lng: Longitude of the user's position (must pass with user_lat).
631
+ :param search_region_priority: Importance of ``search_region``
632
+ (:class:`SearchRegionPriority` or ``"default"`` / ``"required"``).
633
+ :param include_address_categories: Address categories to include.
634
+ :param exclude_address_categories: Address categories to exclude.
492
635
  """
493
636
  if not query:
494
637
  raise ValueError("query must be provided.")
@@ -497,19 +640,21 @@ class AppleMapsClient:
497
640
  f.compact(
498
641
  {
499
642
  "q": query,
500
- "searchLocation": _search_location(
501
- near=kwargs.get("near"),
643
+ "searchLocation": _lat_lng_to_apple_str(
502
644
  lat=kwargs.get("lat"),
503
645
  lng=kwargs.get("lng"),
504
646
  ),
505
647
  # 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"),
648
+ "limitToCountries": _csv(kwargs.get("limit_to_countries")),
507
649
  "lang": kwargs.get("lang"),
508
650
  "resultTypeFilter": _csv(kwargs.get("result_type_filter")),
509
651
  "includePoiCategories": _csv(kwargs.get("include_poi_categories")),
510
652
  "excludePoiCategories": _csv(kwargs.get("exclude_poi_categories")),
511
- "searchRegion": kwargs.get("search_region"),
512
- "userLocation": kwargs.get("user_location"),
653
+ "searchRegion": _search_region(kwargs.get("search_region")),
654
+ "userLocation": _lat_lng_to_apple_str(
655
+ lat=kwargs.get("user_lat"),
656
+ lng=kwargs.get("user_lng"),
657
+ ),
513
658
  "searchRegionPriority": kwargs.get("search_region_priority"),
514
659
  "includeAddressCategories": _csv(
515
660
  kwargs.get("include_address_categories")
@@ -119,7 +119,7 @@ def geocode_coordinates(
119
119
  GeocodeResult with lat, lon, zip_code, city, and state information.
120
120
  Returns None if geocoding fails.
121
121
  """
122
- location_result = client.reverse_geocode((lat, lon))
122
+ location_result = client.reverse_geocode(lat=lat, lng=lon)
123
123
 
124
124
  if len(location_result.results) == 0:
125
125
  _log_geocode_event(
@@ -119,6 +119,16 @@ class SearchACResultType(StrEnum):
119
119
  query = "query"
120
120
 
121
121
 
122
+ class SearchRegionPriority(StrEnum):
123
+ """Importance of searchRegion for /v1/search and /v1/searchAutocomplete.
124
+
125
+ Apple possible values: default, required.
126
+ """
127
+
128
+ default = "default"
129
+ required = "required"
130
+
131
+
122
132
  class Location(BaseModel):
123
133
  """A coordinate pair from Apple Maps API.
124
134
 
@@ -195,10 +205,11 @@ class Place(BaseModel):
195
205
  class SearchPlace(Place):
196
206
  """Extended Place returned by /v1/search with POI category info.
197
207
 
198
- poiCategory: point-of-interest category (e.g. "Restaurant", "MovieTheater")
208
+ poiCategory: point-of-interest category (e.g. Restaurant, MovieTheater).
209
+ Unknown values from Apple fail validation so we notice and add support.
199
210
  """
200
211
 
201
- poiCategory: str | None = None
212
+ poiCategory: PoiCategory | None = None
202
213
 
203
214
 
204
215
  class PlaceResults(BaseModel):
@@ -210,15 +221,31 @@ class PlaceResults(BaseModel):
210
221
  results: list[Place]
211
222
 
212
223
 
224
+ class PaginationInfo(BaseModel):
225
+ """Pagination metadata from /v1/search when enablePagination is true.
226
+
227
+ nextPageToken / prevPageToken: opaque tokens for pageToken on the next request
228
+ totalPageCount: total pages available
229
+ totalResults: total matching results
230
+ """
231
+
232
+ nextPageToken: str | None = None
233
+ prevPageToken: str | None = None
234
+ totalPageCount: int | float | None = None
235
+ totalResults: int | float | None = None
236
+
237
+
213
238
  class SearchResponse(BaseModel):
214
239
  """Response from /v1/search.
215
240
 
216
241
  results: list of SearchPlace objects with optional POI category
217
242
  displayMapRegion: suggested map viewport encompassing all results
243
+ paginationInfo: present when the request set enablePagination
218
244
  """
219
245
 
220
246
  results: list[SearchPlace]
221
247
  displayMapRegion: MapRegion | None = None
248
+ paginationInfo: PaginationInfo | None = None
222
249
 
223
250
 
224
251
  class AutocompleteResult(BaseModel):
File without changes
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "apple-maps-api"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  description = "A modern Python client for the Apple Maps Server API with automatic JWT management and type safety"
5
5
  keywords = ["apple-maps", "geocoding", "search", "geolocation"]
6
6
  readme = "README.md"