bookalimo 0.1.5__py3-none-any.whl → 1.0.1__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.
- bookalimo/__init__.py +17 -24
- bookalimo/_version.py +9 -0
- bookalimo/client.py +310 -0
- bookalimo/config.py +16 -0
- bookalimo/exceptions.py +115 -5
- bookalimo/integrations/__init__.py +1 -0
- bookalimo/integrations/google_places/__init__.py +31 -0
- bookalimo/integrations/google_places/client_async.py +289 -0
- bookalimo/integrations/google_places/client_sync.py +287 -0
- bookalimo/integrations/google_places/common.py +231 -0
- bookalimo/integrations/google_places/proto_adapter.py +224 -0
- bookalimo/integrations/google_places/resolve_airport.py +397 -0
- bookalimo/integrations/google_places/transports.py +98 -0
- bookalimo/{_logging.py → logging.py} +45 -42
- bookalimo/schemas/__init__.py +103 -0
- bookalimo/schemas/base.py +56 -0
- bookalimo/{models.py → schemas/booking.py} +88 -100
- bookalimo/schemas/places/__init__.py +62 -0
- bookalimo/schemas/places/common.py +351 -0
- bookalimo/schemas/places/field_mask.py +221 -0
- bookalimo/schemas/places/google.py +883 -0
- bookalimo/schemas/places/place.py +334 -0
- bookalimo/services/__init__.py +11 -0
- bookalimo/services/pricing.py +191 -0
- bookalimo/services/reservations.py +227 -0
- bookalimo/transport/__init__.py +7 -0
- bookalimo/transport/auth.py +41 -0
- bookalimo/transport/base.py +44 -0
- bookalimo/transport/httpx_async.py +230 -0
- bookalimo/transport/httpx_sync.py +230 -0
- bookalimo/transport/retry.py +102 -0
- bookalimo/transport/utils.py +59 -0
- bookalimo-1.0.1.dist-info/METADATA +370 -0
- bookalimo-1.0.1.dist-info/RECORD +38 -0
- bookalimo-1.0.1.dist-info/licenses/LICENSE +21 -0
- bookalimo/_client.py +0 -420
- bookalimo/wrapper.py +0 -444
- bookalimo-0.1.5.dist-info/METADATA +0 -392
- bookalimo-0.1.5.dist-info/RECORD +0 -12
- bookalimo-0.1.5.dist-info/licenses/LICENSE +0 -0
- {bookalimo-0.1.5.dist-info → bookalimo-1.0.1.dist-info}/WHEEL +0 -0
- {bookalimo-0.1.5.dist-info → bookalimo-1.0.1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,883 @@
|
|
1
|
+
import re
|
2
|
+
import sys
|
3
|
+
|
4
|
+
if sys.version_info >= (3, 11):
|
5
|
+
from enum import StrEnum
|
6
|
+
else:
|
7
|
+
from enum import Enum
|
8
|
+
|
9
|
+
class StrEnum(str, Enum):
|
10
|
+
pass
|
11
|
+
|
12
|
+
|
13
|
+
from typing import Any, Optional, cast
|
14
|
+
|
15
|
+
import pycountry
|
16
|
+
from httpx import QueryParams
|
17
|
+
from pycountry.db import Country
|
18
|
+
from pydantic import (
|
19
|
+
BaseModel,
|
20
|
+
ConfigDict,
|
21
|
+
Field,
|
22
|
+
computed_field,
|
23
|
+
field_validator,
|
24
|
+
model_validator,
|
25
|
+
)
|
26
|
+
from typing_extensions import Self
|
27
|
+
|
28
|
+
from .common import (
|
29
|
+
BASE64URL_36,
|
30
|
+
BCP47,
|
31
|
+
CLDR_REGION_2,
|
32
|
+
PLACE_ID,
|
33
|
+
PLACE_RESOURCE,
|
34
|
+
PLACE_TYPE,
|
35
|
+
LatLng,
|
36
|
+
Viewport,
|
37
|
+
)
|
38
|
+
from .place import GooglePlace, PriceLevel
|
39
|
+
|
40
|
+
# ---------- Constants & Enums ----------
|
41
|
+
|
42
|
+
COUNTRY_CODES = {
|
43
|
+
country.alpha_2 for country in cast(list[Country], list(pycountry.countries))
|
44
|
+
}
|
45
|
+
|
46
|
+
|
47
|
+
class PlaceType(StrEnum):
|
48
|
+
"""Place type constants."""
|
49
|
+
|
50
|
+
ADDRESS = "address"
|
51
|
+
AIRPORT = "airport"
|
52
|
+
POI = "poi" # Point of Interest
|
53
|
+
|
54
|
+
|
55
|
+
class RankPreference(StrEnum):
|
56
|
+
"""Ranking preference constants for SearchTextRequest."""
|
57
|
+
|
58
|
+
RANK_PREFERENCE_UNSPECIFIED = "RANK_PREFERENCE_UNSPECIFIED"
|
59
|
+
DISTANCE = "DISTANCE"
|
60
|
+
RELEVANCE = "RELEVANCE"
|
61
|
+
|
62
|
+
|
63
|
+
class EVConnectorType(StrEnum):
|
64
|
+
"""EV connector type constants."""
|
65
|
+
|
66
|
+
EV_CONNECTOR_TYPE_UNSPECIFIED = "EV_CONNECTOR_TYPE_UNSPECIFIED"
|
67
|
+
EV_CONNECTOR_TYPE_OTHER = "EV_CONNECTOR_TYPE_OTHER"
|
68
|
+
EV_CONNECTOR_TYPE_J1772 = "EV_CONNECTOR_TYPE_J1772"
|
69
|
+
EV_CONNECTOR_TYPE_TYPE_2 = "EV_CONNECTOR_TYPE_TYPE_2"
|
70
|
+
EV_CONNECTOR_TYPE_CHADEMO = "EV_CONNECTOR_TYPE_CHADEMO"
|
71
|
+
EV_CONNECTOR_TYPE_CCS_COMBO_1 = "EV_CONNECTOR_TYPE_CCS_COMBO_1"
|
72
|
+
EV_CONNECTOR_TYPE_CCS_COMBO_2 = "EV_CONNECTOR_TYPE_CCS_COMBO_2"
|
73
|
+
EV_CONNECTOR_TYPE_TESLA = "EV_CONNECTOR_TYPE_TESLA"
|
74
|
+
EV_CONNECTOR_TYPE_UNSPECIFIED_GB_T = "EV_CONNECTOR_TYPE_UNSPECIFIED_GB_T"
|
75
|
+
EV_CONNECTOR_TYPE_UNSPECIFIED_WALL_OUTLET = (
|
76
|
+
"EV_CONNECTOR_TYPE_UNSPECIFIED_WALL_OUTLET"
|
77
|
+
)
|
78
|
+
|
79
|
+
|
80
|
+
# ---------- Text Primitives ----------
|
81
|
+
class StringRange(BaseModel):
|
82
|
+
"""Identifies a substring within a given text."""
|
83
|
+
|
84
|
+
model_config = ConfigDict(extra="forbid")
|
85
|
+
|
86
|
+
start_offset: int = Field(
|
87
|
+
default=0, ge=0, description="Zero-based start (inclusive)"
|
88
|
+
)
|
89
|
+
end_offset: int = Field(..., ge=0, description="Zero-based end (exclusive)")
|
90
|
+
|
91
|
+
@model_validator(mode="after")
|
92
|
+
def _validate_order(self) -> Self:
|
93
|
+
if self.start_offset >= self.end_offset:
|
94
|
+
raise ValueError("start_offset must be < end_offset")
|
95
|
+
return self
|
96
|
+
|
97
|
+
|
98
|
+
class FormattableText(BaseModel):
|
99
|
+
"""Text that can be highlighted via `matches` ranges."""
|
100
|
+
|
101
|
+
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
|
102
|
+
|
103
|
+
text: str = Field(..., min_length=1)
|
104
|
+
matches: list[StringRange] = Field(default_factory=list)
|
105
|
+
|
106
|
+
@model_validator(mode="after")
|
107
|
+
def _validate_matches(self) -> Self:
|
108
|
+
n = len(self.text)
|
109
|
+
prev_end = -1
|
110
|
+
for i, r in enumerate(self.matches):
|
111
|
+
if r.end_offset > n:
|
112
|
+
raise ValueError(f"matches[{i}] end_offset exceeds text length ({n})")
|
113
|
+
if r.start_offset < 0:
|
114
|
+
raise ValueError(f"matches[{i}] start_offset must be >= 0")
|
115
|
+
# Enforce strictly increasing, non-overlapping ranges (stronger than spec but safe)
|
116
|
+
if r.start_offset <= prev_end:
|
117
|
+
raise ValueError(
|
118
|
+
"matches must be ordered by increasing, non-overlapping offsets"
|
119
|
+
)
|
120
|
+
prev_end = r.end_offset
|
121
|
+
return self
|
122
|
+
|
123
|
+
|
124
|
+
class StructuredFormat(BaseModel):
|
125
|
+
"""Breakdown of a prediction into main and secondary text."""
|
126
|
+
|
127
|
+
model_config = ConfigDict(extra="forbid")
|
128
|
+
|
129
|
+
main_text: FormattableText
|
130
|
+
secondary_text: FormattableText
|
131
|
+
|
132
|
+
|
133
|
+
# ---------- Geometry Primitives ----------
|
134
|
+
|
135
|
+
|
136
|
+
class Circle(BaseModel):
|
137
|
+
"""google.maps.places_v1.types.Circle (center + radius)."""
|
138
|
+
|
139
|
+
model_config = ConfigDict(extra="forbid")
|
140
|
+
|
141
|
+
center: LatLng
|
142
|
+
radius_meters: float = Field(
|
143
|
+
..., gt=0, description="Strictly positive radius in meters"
|
144
|
+
)
|
145
|
+
|
146
|
+
|
147
|
+
class LocationBias(BaseModel):
|
148
|
+
"""
|
149
|
+
Oneof: exactly one of rectangle or circle must be set.
|
150
|
+
"""
|
151
|
+
|
152
|
+
model_config = ConfigDict(extra="forbid")
|
153
|
+
|
154
|
+
rectangle: Optional[Viewport] = None
|
155
|
+
circle: Optional[Circle] = None
|
156
|
+
|
157
|
+
@model_validator(mode="after")
|
158
|
+
def _validate_oneof(self) -> Self:
|
159
|
+
set_count = sum(x is not None for x in (self.rectangle, self.circle))
|
160
|
+
if set_count != 1:
|
161
|
+
raise ValueError(
|
162
|
+
"Exactly one of {rectangle, circle} must be set for LocationBias"
|
163
|
+
)
|
164
|
+
return self
|
165
|
+
|
166
|
+
|
167
|
+
class LocationRestriction(BaseModel):
|
168
|
+
"""
|
169
|
+
Oneof: exactly one of rectangle or circle must be set.
|
170
|
+
"""
|
171
|
+
|
172
|
+
model_config = ConfigDict(extra="forbid")
|
173
|
+
|
174
|
+
rectangle: Optional[Viewport] = None
|
175
|
+
circle: Optional[Circle] = None
|
176
|
+
|
177
|
+
@model_validator(mode="after")
|
178
|
+
def _validate_oneof(self) -> Self:
|
179
|
+
set_count = sum(x is not None for x in (self.rectangle, self.circle))
|
180
|
+
if set_count != 1:
|
181
|
+
raise ValueError(
|
182
|
+
"Exactly one of {rectangle, circle} must be set for LocationRestriction"
|
183
|
+
)
|
184
|
+
return self
|
185
|
+
|
186
|
+
|
187
|
+
# ---------- Search Text Supporting Models ----------
|
188
|
+
|
189
|
+
|
190
|
+
class Polyline(BaseModel):
|
191
|
+
"""Route polyline representation."""
|
192
|
+
|
193
|
+
model_config = ConfigDict(extra="forbid")
|
194
|
+
|
195
|
+
encoded_polyline: Optional[str] = Field(None, description="Encoded polyline string")
|
196
|
+
|
197
|
+
|
198
|
+
class RoutingParameters(BaseModel):
|
199
|
+
"""Parameters to configure routing calculations."""
|
200
|
+
|
201
|
+
model_config = ConfigDict(extra="forbid")
|
202
|
+
|
203
|
+
origin: Optional[LatLng] = Field(None, description="Explicit routing origin")
|
204
|
+
travel_mode: Optional[str] = Field(
|
205
|
+
None, description="Travel mode (DRIVE, WALK, BICYCLE, TRANSIT)"
|
206
|
+
)
|
207
|
+
routing_preference: Optional[str] = Field(
|
208
|
+
None, description="Routing preference (TRAFFIC_AWARE, etc.)"
|
209
|
+
)
|
210
|
+
|
211
|
+
|
212
|
+
class EVOptions(BaseModel):
|
213
|
+
"""Searchable EV options for place search."""
|
214
|
+
|
215
|
+
model_config = ConfigDict(extra="forbid")
|
216
|
+
|
217
|
+
minimum_charging_rate_kw: Optional[float] = Field(
|
218
|
+
None, gt=0, description="Minimum required charging rate in kilowatts"
|
219
|
+
)
|
220
|
+
connector_types: list[EVConnectorType] = Field(
|
221
|
+
default_factory=list, description="Preferred EV connector types"
|
222
|
+
)
|
223
|
+
|
224
|
+
@field_validator("connector_types")
|
225
|
+
@classmethod
|
226
|
+
def _validate_connector_types(
|
227
|
+
cls, v: list[EVConnectorType]
|
228
|
+
) -> list[EVConnectorType]:
|
229
|
+
# Remove duplicates while preserving order
|
230
|
+
seen = set()
|
231
|
+
cleaned = []
|
232
|
+
for connector_type in v:
|
233
|
+
if connector_type not in seen:
|
234
|
+
cleaned.append(connector_type)
|
235
|
+
seen.add(connector_type)
|
236
|
+
return cleaned
|
237
|
+
|
238
|
+
|
239
|
+
class SearchAlongRouteParameters(BaseModel):
|
240
|
+
"""Parameters for searching along a route."""
|
241
|
+
|
242
|
+
model_config = ConfigDict(extra="forbid")
|
243
|
+
|
244
|
+
polyline: Polyline = Field(..., description="Route polyline")
|
245
|
+
|
246
|
+
|
247
|
+
class SearchTextLocationRestriction(BaseModel):
|
248
|
+
"""
|
249
|
+
Location restriction for SearchTextRequest - allows only rectangle.
|
250
|
+
"""
|
251
|
+
|
252
|
+
model_config = ConfigDict(extra="forbid")
|
253
|
+
|
254
|
+
rectangle: Optional[Viewport] = None
|
255
|
+
|
256
|
+
@model_validator(mode="after")
|
257
|
+
def _validate_rectangle_required(self) -> Self:
|
258
|
+
if self.rectangle is None:
|
259
|
+
raise ValueError("rectangle is required for LocationRestriction")
|
260
|
+
return self
|
261
|
+
|
262
|
+
|
263
|
+
# ---------- Responses ----------
|
264
|
+
|
265
|
+
|
266
|
+
class Place(BaseModel):
|
267
|
+
"""Structured place result from the Google Places API."""
|
268
|
+
|
269
|
+
formatted_address: str = Field(..., description="Full formatted address")
|
270
|
+
lat: float = Field(..., description="Latitude")
|
271
|
+
lng: float = Field(..., description="Longitude")
|
272
|
+
place_type: PlaceType = Field(..., description="Type: address, airport, or poi")
|
273
|
+
google_place: Optional[GooglePlace] = Field(
|
274
|
+
None, description="Raw Google Places API response"
|
275
|
+
)
|
276
|
+
|
277
|
+
@computed_field
|
278
|
+
@property
|
279
|
+
def country_code(self) -> Optional[str]:
|
280
|
+
"""Return ISO 3166-1 alpha-2 country code from the Google Places API response."""
|
281
|
+
return self.extract_country_alpha2(self.google_place)
|
282
|
+
|
283
|
+
@model_validator(mode="after")
|
284
|
+
def validate_place_type(self) -> Self:
|
285
|
+
"""Validate place_type is one of the allowed values."""
|
286
|
+
if self.place_type not in [PlaceType.ADDRESS, PlaceType.AIRPORT, PlaceType.POI]:
|
287
|
+
raise ValueError(f"Invalid place_type: {self.place_type}")
|
288
|
+
return self
|
289
|
+
|
290
|
+
@staticmethod
|
291
|
+
def extract_country_alpha2(google_place: Optional[GooglePlace]) -> Optional[str]:
|
292
|
+
"""Return ISO 3166-1 alpha-2 country code from a Google Places result."""
|
293
|
+
if not google_place:
|
294
|
+
return None
|
295
|
+
|
296
|
+
for comp in google_place.address_components:
|
297
|
+
if "country" in comp.types:
|
298
|
+
code = (comp.short_text or "").upper()
|
299
|
+
return code if code in COUNTRY_CODES and len(code) == 2 else None
|
300
|
+
return None
|
301
|
+
|
302
|
+
|
303
|
+
class AutocompletePlacesResponse(BaseModel):
|
304
|
+
"""Response proto for AutocompletePlaces: ordered suggestions."""
|
305
|
+
|
306
|
+
model_config = ConfigDict(extra="forbid")
|
307
|
+
|
308
|
+
suggestions: list["Suggestion"] = Field(default_factory=list)
|
309
|
+
|
310
|
+
|
311
|
+
class PlacePrediction(BaseModel):
|
312
|
+
"""Prediction result representing a Place."""
|
313
|
+
|
314
|
+
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
|
315
|
+
|
316
|
+
place: str = Field(..., description="Resource name, e.g., 'places/PLACE_ID'")
|
317
|
+
place_id: str = Field(..., description="Place ID string")
|
318
|
+
text: Optional[FormattableText] = None
|
319
|
+
structured_format: Optional[StructuredFormat] = None
|
320
|
+
types: list[str] = Field(default_factory=list, description="Place types")
|
321
|
+
distance_meters: Optional[int] = Field(default=None, ge=0)
|
322
|
+
|
323
|
+
@field_validator("place")
|
324
|
+
@classmethod
|
325
|
+
def _valid_place_resource(cls, v: str) -> str:
|
326
|
+
if not PLACE_RESOURCE.fullmatch(v):
|
327
|
+
raise ValueError("place must match ^places/[A-Za-z0-9_-]{3,}$")
|
328
|
+
return v
|
329
|
+
|
330
|
+
@field_validator("place_id")
|
331
|
+
@classmethod
|
332
|
+
def _valid_place_id(cls, v: str) -> str:
|
333
|
+
if not PLACE_ID.fullmatch(v):
|
334
|
+
raise ValueError("place_id must be a base64url-like token of length >= 10")
|
335
|
+
return v
|
336
|
+
|
337
|
+
@field_validator("types")
|
338
|
+
@classmethod
|
339
|
+
def _validate_types(cls, v: list[str]) -> list[str]:
|
340
|
+
cleaned: list[str] = []
|
341
|
+
seen = set()
|
342
|
+
for raw in v:
|
343
|
+
t = raw.strip()
|
344
|
+
if not t:
|
345
|
+
raise ValueError("types cannot contain empty strings")
|
346
|
+
if not PLACE_TYPE.fullmatch(t):
|
347
|
+
raise ValueError(
|
348
|
+
f"Invalid place type '{t}'. Use lowercase letters, digits, and underscores."
|
349
|
+
)
|
350
|
+
if t not in seen:
|
351
|
+
cleaned.append(t)
|
352
|
+
seen.add(t)
|
353
|
+
return cleaned
|
354
|
+
|
355
|
+
@model_validator(mode="after")
|
356
|
+
def _at_least_one_text_representation(self) -> Self:
|
357
|
+
if self.text is None and self.structured_format is None:
|
358
|
+
raise ValueError(
|
359
|
+
"At least one of {text, structured_format} must be provided"
|
360
|
+
)
|
361
|
+
return self
|
362
|
+
|
363
|
+
|
364
|
+
class QueryPrediction(BaseModel):
|
365
|
+
"""Prediction result representing a query (not a Place)."""
|
366
|
+
|
367
|
+
model_config = ConfigDict(extra="forbid")
|
368
|
+
|
369
|
+
text: Optional[FormattableText] = None
|
370
|
+
structured_format: Optional[StructuredFormat] = None
|
371
|
+
|
372
|
+
@model_validator(mode="after")
|
373
|
+
def _at_least_one_text_representation(self) -> Self:
|
374
|
+
if self.text is None and self.structured_format is None:
|
375
|
+
raise ValueError(
|
376
|
+
"At least one of {text, structured_format} must be provided"
|
377
|
+
)
|
378
|
+
return self
|
379
|
+
|
380
|
+
|
381
|
+
class Suggestion(BaseModel):
|
382
|
+
"""
|
383
|
+
Oneof 'kind': exactly one of place_prediction or query_prediction must be set.
|
384
|
+
"""
|
385
|
+
|
386
|
+
model_config = ConfigDict(extra="forbid")
|
387
|
+
|
388
|
+
place_prediction: Optional[PlacePrediction] = None
|
389
|
+
query_prediction: Optional[QueryPrediction] = None
|
390
|
+
|
391
|
+
@model_validator(mode="after")
|
392
|
+
def _validate_oneof(self) -> Self:
|
393
|
+
count = sum(
|
394
|
+
x is not None for x in (self.place_prediction, self.query_prediction)
|
395
|
+
)
|
396
|
+
if count != 1:
|
397
|
+
raise ValueError(
|
398
|
+
"Exactly one of {place_prediction, query_prediction} must be set"
|
399
|
+
)
|
400
|
+
return self
|
401
|
+
|
402
|
+
|
403
|
+
# ---------- Requests ----------
|
404
|
+
|
405
|
+
|
406
|
+
class GetPlaceRequest(BaseModel):
|
407
|
+
"""
|
408
|
+
Request for fetching a Place by resource name 'places/{place_id}'.
|
409
|
+
|
410
|
+
- name: required, must match ^places/[A-Za-z0-9_-]{10,}$
|
411
|
+
- language_code: optional BCP-47 tag (e.g., 'en', 'en-US', 'zh-Hant')
|
412
|
+
- region_code: optional CLDR region code (2 letters, uppercase). 3-digit codes not supported.
|
413
|
+
- session_token: optional base64url (URL/filename-safe) up to 36 chars.
|
414
|
+
"""
|
415
|
+
|
416
|
+
model_config = ConfigDict(
|
417
|
+
extra="forbid", str_strip_whitespace=True, populate_by_name=True
|
418
|
+
)
|
419
|
+
|
420
|
+
name: str = Field(..., description="Resource name in the form 'places/{place_id}'.")
|
421
|
+
language_code: Optional[str] = Field(
|
422
|
+
default=None,
|
423
|
+
description="Preferred language (BCP-47). If unavailable, backend defaults apply.",
|
424
|
+
)
|
425
|
+
region_code: Optional[str] = Field(
|
426
|
+
default=None,
|
427
|
+
description="CLDR region code (2 letters, e.g., 'US'). 3-digit codes not supported.",
|
428
|
+
)
|
429
|
+
session_token: Optional[str] = Field(
|
430
|
+
default=None,
|
431
|
+
description="Base64url token (URL/filename-safe), length 1–36, for Autocomplete billing sessions.",
|
432
|
+
)
|
433
|
+
|
434
|
+
# ---- Field validators ----
|
435
|
+
@field_validator("name")
|
436
|
+
@classmethod
|
437
|
+
def _validate_name(cls, v: str) -> str:
|
438
|
+
if not PLACE_RESOURCE.fullmatch(v):
|
439
|
+
raise ValueError(
|
440
|
+
"name must be in the form 'places/{place_id}' with a base64url-like place_id (>=10 chars)."
|
441
|
+
)
|
442
|
+
return v
|
443
|
+
|
444
|
+
@field_validator("language_code")
|
445
|
+
@classmethod
|
446
|
+
def _validate_language_code(cls, v: Optional[str]) -> Optional[str]:
|
447
|
+
if v is None:
|
448
|
+
return v
|
449
|
+
if not BCP47.match(v):
|
450
|
+
raise ValueError(
|
451
|
+
"language_code must be a valid BCP-47 tag (e.g., 'en', 'en-US', 'zh-Hant')."
|
452
|
+
)
|
453
|
+
return v
|
454
|
+
|
455
|
+
@field_validator("region_code")
|
456
|
+
@classmethod
|
457
|
+
def _validate_region_code(cls, v: Optional[str]) -> Optional[str]:
|
458
|
+
if v is None:
|
459
|
+
return v
|
460
|
+
v2 = v.upper()
|
461
|
+
if not CLDR_REGION_2.fullmatch(v2):
|
462
|
+
raise ValueError(
|
463
|
+
"region_code must be a two-letter CLDR region code (e.g., 'US', 'GB')."
|
464
|
+
)
|
465
|
+
return v2
|
466
|
+
|
467
|
+
@field_validator("session_token")
|
468
|
+
@classmethod
|
469
|
+
def _validate_session_token(cls, v: Optional[str]) -> Optional[str]:
|
470
|
+
if v is None:
|
471
|
+
return v
|
472
|
+
if not BASE64URL_36.fullmatch(v):
|
473
|
+
raise ValueError(
|
474
|
+
"session_token must be base64url (A–Z a–z 0–9 _ -), length 1–36."
|
475
|
+
)
|
476
|
+
return v
|
477
|
+
|
478
|
+
# ---- Convenience (not part of the wire schema) ----
|
479
|
+
@property
|
480
|
+
def place_id(self) -> str:
|
481
|
+
"""Extract the {place_id} from 'places/{place_id}'."""
|
482
|
+
return self.name.split("/", 1)[1]
|
483
|
+
|
484
|
+
|
485
|
+
class AutocompletePlacesRequest(BaseModel):
|
486
|
+
"""
|
487
|
+
Pydantic v2 model for AutocompletePlacesRequest with rich validations.
|
488
|
+
"""
|
489
|
+
|
490
|
+
model_config = ConfigDict(
|
491
|
+
extra="forbid", str_strip_whitespace=True, populate_by_name=True
|
492
|
+
)
|
493
|
+
|
494
|
+
# Required search text
|
495
|
+
input: str = Field(
|
496
|
+
..., min_length=1, description="The text string on which to search."
|
497
|
+
)
|
498
|
+
|
499
|
+
# Mutually exclusive geospatial hints (top-level 'at most one' rule)
|
500
|
+
location_bias: Optional[LocationBias] = None
|
501
|
+
location_restriction: Optional[LocationRestriction] = None
|
502
|
+
|
503
|
+
# Place types (<= 5). Either a list of normal types OR exactly one of the special tokens.
|
504
|
+
included_primary_types: list[str] = Field(default_factory=list)
|
505
|
+
|
506
|
+
# Region filters (<= 15) as CLDR two-letter codes (uppercased, de-duplicated).
|
507
|
+
included_region_codes: list[str] = Field(default_factory=list)
|
508
|
+
|
509
|
+
# Localization
|
510
|
+
language_code: str = Field(
|
511
|
+
default="en-US", description="BCP-47 language tag, default en-US."
|
512
|
+
)
|
513
|
+
region_code: Optional[str] = Field(
|
514
|
+
default=None, description="CLDR region code (2 letters, e.g., 'US')."
|
515
|
+
)
|
516
|
+
|
517
|
+
# Distance origin (if present, distance is returned)
|
518
|
+
origin: Optional[LatLng] = None
|
519
|
+
|
520
|
+
# Cursor position in `input`; if omitted, defaults to len(input)
|
521
|
+
input_offset: Optional[int] = None
|
522
|
+
|
523
|
+
# Flags
|
524
|
+
include_query_predictions: bool = False
|
525
|
+
include_pure_service_area_businesses: bool = False
|
526
|
+
|
527
|
+
# Session token: URL/filename safe base64url, <= 36 ASCII chars
|
528
|
+
session_token: Optional[str] = None
|
529
|
+
|
530
|
+
# -------------------- Field-level validations & normalization --------------------
|
531
|
+
|
532
|
+
@field_validator("included_primary_types")
|
533
|
+
@classmethod
|
534
|
+
def _validate_primary_types(cls, v: list[str]) -> list[str]:
|
535
|
+
# Enforce <= 5, no empties, trim & dedupe (preserving order)
|
536
|
+
if len(v) > 5:
|
537
|
+
raise ValueError("included_primary_types can contain at most 5 values")
|
538
|
+
cleaned: list[str] = []
|
539
|
+
seen = set()
|
540
|
+
for raw in v:
|
541
|
+
t = raw.strip()
|
542
|
+
if not t:
|
543
|
+
raise ValueError("included_primary_types cannot contain empty strings")
|
544
|
+
if t not in seen:
|
545
|
+
cleaned.append(t)
|
546
|
+
seen.add(t)
|
547
|
+
|
548
|
+
# Special tokens constraint: allow exactly one item when using "(regions)" or "(cities)"
|
549
|
+
specials = {"(regions)", "(cities)"}
|
550
|
+
if any(t in specials for t in cleaned):
|
551
|
+
if len(cleaned) != 1 or cleaned[0] not in specials:
|
552
|
+
raise ValueError(
|
553
|
+
"When using special tokens, included_primary_types must be exactly one of '(regions)' or '(cities)'."
|
554
|
+
)
|
555
|
+
return cleaned
|
556
|
+
|
557
|
+
# Otherwise validate normal place-type tokens format (lowercase, digits, underscores)
|
558
|
+
for t in cleaned:
|
559
|
+
if not PLACE_TYPE.match(t):
|
560
|
+
raise ValueError(
|
561
|
+
f"Invalid place type '{t}'. Use lowercase letters, digits, and underscores (e.g., 'gas_station')."
|
562
|
+
)
|
563
|
+
return cleaned
|
564
|
+
|
565
|
+
@field_validator("included_region_codes")
|
566
|
+
@classmethod
|
567
|
+
def _validate_region_codes(cls, v: list[str]) -> list[str]:
|
568
|
+
if len(v) > 15:
|
569
|
+
raise ValueError("included_region_codes can contain at most 15 values")
|
570
|
+
out: list[str] = []
|
571
|
+
seen = set()
|
572
|
+
for raw in v:
|
573
|
+
code = raw.strip().upper()
|
574
|
+
if not re.fullmatch(r"[A-Z]{2}", code):
|
575
|
+
raise ValueError(
|
576
|
+
f"Region code '{raw}' must be a two-letter CLDR region code (e.g., 'US', 'GB')."
|
577
|
+
)
|
578
|
+
if code not in seen:
|
579
|
+
out.append(code)
|
580
|
+
seen.add(code)
|
581
|
+
return out
|
582
|
+
|
583
|
+
@field_validator("language_code")
|
584
|
+
@classmethod
|
585
|
+
def _validate_language_code(cls, v: str) -> str:
|
586
|
+
if not BCP47.match(v):
|
587
|
+
raise ValueError(
|
588
|
+
"language_code must be a valid BCP-47 tag (e.g., 'en', 'en-US', 'zh-Hant')."
|
589
|
+
)
|
590
|
+
return v
|
591
|
+
|
592
|
+
@field_validator("region_code")
|
593
|
+
@classmethod
|
594
|
+
def _validate_region_code(cls, v: Optional[str]) -> Optional[str]:
|
595
|
+
if v is None:
|
596
|
+
return v
|
597
|
+
v2 = v.strip().upper()
|
598
|
+
if not re.fullmatch(r"[A-Z]{2}", v2):
|
599
|
+
raise ValueError(
|
600
|
+
"region_code must be a two-letter CLDR region code (e.g., 'US', 'GB')."
|
601
|
+
)
|
602
|
+
return v2
|
603
|
+
|
604
|
+
@field_validator("session_token")
|
605
|
+
@classmethod
|
606
|
+
def _validate_session_token(cls, v: Optional[str]) -> Optional[str]:
|
607
|
+
if v is None:
|
608
|
+
return v
|
609
|
+
# URL/filename-safe base64 (base64url) up to 36 chars: A–Z a–z 0–9 _ -
|
610
|
+
if not BASE64URL_36.fullmatch(v):
|
611
|
+
raise ValueError(
|
612
|
+
"session_token must be URL/filename-safe base64 (base64url) of length 1–36 using [A-Za-z0-9_-]."
|
613
|
+
)
|
614
|
+
return v
|
615
|
+
|
616
|
+
@field_validator("input")
|
617
|
+
@classmethod
|
618
|
+
def _validate_input_nonempty(cls, v: str) -> str:
|
619
|
+
if len(v) == 0:
|
620
|
+
raise ValueError("input cannot be empty")
|
621
|
+
return v
|
622
|
+
|
623
|
+
@field_validator("input_offset")
|
624
|
+
@classmethod
|
625
|
+
def _validate_input_offset(cls, v: Optional[int], info: Any) -> Optional[int]:
|
626
|
+
# Can't compare to input length here (other fields not guaranteed yet),
|
627
|
+
# so we only enforce non-negative. Range is finished in the model_validator below.
|
628
|
+
if v is not None and v < 0:
|
629
|
+
raise ValueError("input_offset must be a non-negative integer")
|
630
|
+
return v
|
631
|
+
|
632
|
+
# -------------------- Cross-field validations --------------------
|
633
|
+
|
634
|
+
@model_validator(mode="after")
|
635
|
+
def _validate_cross_fields(self) -> Self:
|
636
|
+
# Top-level exclusivity: at most one of location_bias / location_restriction
|
637
|
+
if self.location_bias is not None and self.location_restriction is not None:
|
638
|
+
raise ValueError(
|
639
|
+
"At most one of {location_bias, location_restriction} may be set."
|
640
|
+
)
|
641
|
+
|
642
|
+
# Default input_offset to len(input) if omitted; else enforce range
|
643
|
+
if self.input_offset is None:
|
644
|
+
object.__setattr__(self, "input_offset", len(self.input))
|
645
|
+
else:
|
646
|
+
if self.input_offset > len(self.input):
|
647
|
+
raise ValueError(
|
648
|
+
"input_offset cannot be greater than the length of input"
|
649
|
+
)
|
650
|
+
|
651
|
+
return self
|
652
|
+
|
653
|
+
|
654
|
+
class GeocodingRequest(BaseModel):
|
655
|
+
"""
|
656
|
+
Pydantic model for validating and building Geocoding API query parameters.
|
657
|
+
This model is not for a JSON request body, but for constructing a URL.
|
658
|
+
"""
|
659
|
+
|
660
|
+
address: Optional[str] = Field(
|
661
|
+
default=None,
|
662
|
+
description="The street address or plus code that you want to geocode.",
|
663
|
+
)
|
664
|
+
place_id: Optional[str] = Field(
|
665
|
+
default=None,
|
666
|
+
description="The place ID of the place for which you wish to obtain the human-readable address.",
|
667
|
+
)
|
668
|
+
language: Optional[str] = Field(
|
669
|
+
default=None, description="The language in which to return results."
|
670
|
+
)
|
671
|
+
region: Optional[str] = Field(
|
672
|
+
default=None, description="The region code (ccTLD) to bias results."
|
673
|
+
)
|
674
|
+
|
675
|
+
@model_validator(mode="before")
|
676
|
+
@classmethod
|
677
|
+
def check_required_params(cls, data: Any) -> Any:
|
678
|
+
"""Ensures that either 'address', 'place_id', or 'components' is provided."""
|
679
|
+
if isinstance(data, dict):
|
680
|
+
if not any(
|
681
|
+
[data.get("address"), data.get("place_id"), data.get("components")]
|
682
|
+
):
|
683
|
+
raise ValueError(
|
684
|
+
"You must specify either 'address', 'place_id', or 'components'."
|
685
|
+
)
|
686
|
+
return data
|
687
|
+
|
688
|
+
def to_query_params(self) -> QueryParams:
|
689
|
+
"""
|
690
|
+
Serializes the model fields into a dictionary suitable for URL query parameters.
|
691
|
+
"""
|
692
|
+
params = QueryParams()
|
693
|
+
if self.address:
|
694
|
+
params = params.add("address", self.address)
|
695
|
+
|
696
|
+
if self.place_id:
|
697
|
+
params = params.add("place_id", self.place_id)
|
698
|
+
|
699
|
+
if self.language:
|
700
|
+
params = params.add("language", self.language)
|
701
|
+
|
702
|
+
if self.region:
|
703
|
+
params = params.add("region", self.region)
|
704
|
+
|
705
|
+
return params
|
706
|
+
|
707
|
+
|
708
|
+
class SearchTextRequest(BaseModel):
|
709
|
+
"""
|
710
|
+
Pydantic model for SearchTextRequest with rich validations.
|
711
|
+
"""
|
712
|
+
|
713
|
+
model_config = ConfigDict(
|
714
|
+
extra="forbid", str_strip_whitespace=True, populate_by_name=True
|
715
|
+
)
|
716
|
+
|
717
|
+
# Required field
|
718
|
+
text_query: str = Field(
|
719
|
+
..., min_length=1, description="Required. The text query for textual search."
|
720
|
+
)
|
721
|
+
|
722
|
+
# Localization
|
723
|
+
language_code: Optional[str] = Field(
|
724
|
+
default=None,
|
725
|
+
description="Place details will be displayed with the preferred language if available.",
|
726
|
+
)
|
727
|
+
region_code: Optional[str] = Field(
|
728
|
+
default=None,
|
729
|
+
description="The Unicode country/region code (CLDR) of the location.",
|
730
|
+
)
|
731
|
+
|
732
|
+
# Ranking and type filtering
|
733
|
+
rank_preference: Optional[RankPreference] = Field(
|
734
|
+
default=None, description="How results will be ranked in the response."
|
735
|
+
)
|
736
|
+
included_type: Optional[str] = Field(
|
737
|
+
default=None,
|
738
|
+
description="The requested place type. Only support one included type.",
|
739
|
+
)
|
740
|
+
strict_type_filtering: bool = Field(
|
741
|
+
default=False,
|
742
|
+
description="Used to set strict type filtering for included_type.",
|
743
|
+
)
|
744
|
+
|
745
|
+
# Filters
|
746
|
+
open_now: bool = Field(
|
747
|
+
default=False,
|
748
|
+
description="Used to restrict the search to places that are currently open.",
|
749
|
+
)
|
750
|
+
min_rating: Optional[float] = Field(
|
751
|
+
default=None,
|
752
|
+
ge=0,
|
753
|
+
le=5,
|
754
|
+
description="Filter out results whose average user rating is strictly less than this limit.",
|
755
|
+
)
|
756
|
+
max_result_count: Optional[int] = Field(
|
757
|
+
default=None,
|
758
|
+
ge=1,
|
759
|
+
le=20,
|
760
|
+
description="Maximum number of results to return. Must be between 1 and 20.",
|
761
|
+
)
|
762
|
+
price_levels: list[PriceLevel] = Field(
|
763
|
+
default_factory=list,
|
764
|
+
description="Used to restrict the search to places that are marked as certain price levels.",
|
765
|
+
)
|
766
|
+
|
767
|
+
# Location constraints (mutually exclusive)
|
768
|
+
location_bias: Optional[LocationBias] = Field(
|
769
|
+
default=None,
|
770
|
+
description="The region to search. This location serves as a bias.",
|
771
|
+
)
|
772
|
+
location_restriction: Optional[SearchTextLocationRestriction] = Field(
|
773
|
+
default=None,
|
774
|
+
description="The region to search. This location serves as a restriction.",
|
775
|
+
)
|
776
|
+
|
777
|
+
# Advanced options
|
778
|
+
ev_options: Optional[EVOptions] = Field(
|
779
|
+
default=None,
|
780
|
+
description="Set the searchable EV options of a place search request.",
|
781
|
+
)
|
782
|
+
routing_parameters: Optional[RoutingParameters] = Field(
|
783
|
+
default=None, description="Additional parameters for routing to results."
|
784
|
+
)
|
785
|
+
search_along_route_parameters: Optional[SearchAlongRouteParameters] = Field(
|
786
|
+
default=None, description="Additional parameters for searching along a route."
|
787
|
+
)
|
788
|
+
include_pure_service_area_businesses: bool = Field(
|
789
|
+
default=False,
|
790
|
+
description="Include pure service area businesses if the field is set to true.",
|
791
|
+
)
|
792
|
+
|
793
|
+
# Field validators
|
794
|
+
@field_validator("language_code")
|
795
|
+
@classmethod
|
796
|
+
def _validate_language_code(cls, v: Optional[str]) -> Optional[str]:
|
797
|
+
if v is None:
|
798
|
+
return v
|
799
|
+
if not BCP47.match(v):
|
800
|
+
raise ValueError(
|
801
|
+
"language_code must be a valid BCP-47 tag (e.g., 'en', 'en-US', 'zh-Hant')."
|
802
|
+
)
|
803
|
+
return v
|
804
|
+
|
805
|
+
@field_validator("region_code")
|
806
|
+
@classmethod
|
807
|
+
def _validate_region_code(cls, v: Optional[str]) -> Optional[str]:
|
808
|
+
if v is None:
|
809
|
+
return v
|
810
|
+
v2 = v.upper()
|
811
|
+
if not CLDR_REGION_2.fullmatch(v2):
|
812
|
+
raise ValueError(
|
813
|
+
"region_code must be a two-letter CLDR region code (e.g., 'US', 'GB')."
|
814
|
+
)
|
815
|
+
return v2
|
816
|
+
|
817
|
+
@field_validator("included_type")
|
818
|
+
@classmethod
|
819
|
+
def _validate_included_type(cls, v: Optional[str]) -> Optional[str]:
|
820
|
+
if v is None:
|
821
|
+
return v
|
822
|
+
if not PLACE_TYPE.match(v):
|
823
|
+
raise ValueError(
|
824
|
+
f"Invalid place type '{v}'. Use lowercase letters, digits, and underscores."
|
825
|
+
)
|
826
|
+
return v
|
827
|
+
|
828
|
+
@field_validator("min_rating")
|
829
|
+
@classmethod
|
830
|
+
def _validate_min_rating(cls, v: Optional[float]) -> Optional[float]:
|
831
|
+
if v is None:
|
832
|
+
return v
|
833
|
+
# Round up to nearest 0.5 as per Google's specification
|
834
|
+
import math
|
835
|
+
|
836
|
+
return math.ceil(v * 2) / 2
|
837
|
+
|
838
|
+
@field_validator("price_levels")
|
839
|
+
@classmethod
|
840
|
+
def _validate_price_levels(cls, v: list[PriceLevel]) -> list[PriceLevel]:
|
841
|
+
# Remove duplicates while preserving order
|
842
|
+
seen = set()
|
843
|
+
cleaned = []
|
844
|
+
for level in v:
|
845
|
+
if level not in seen:
|
846
|
+
cleaned.append(level)
|
847
|
+
seen.add(level)
|
848
|
+
return cleaned
|
849
|
+
|
850
|
+
# Cross-field validation
|
851
|
+
@model_validator(mode="after")
|
852
|
+
def _validate_cross_fields(self) -> Self:
|
853
|
+
# Mutually exclusive location constraints
|
854
|
+
if self.location_bias is not None and self.location_restriction is not None:
|
855
|
+
raise ValueError(
|
856
|
+
"Cannot set both location_bias and location_restriction. Choose one."
|
857
|
+
)
|
858
|
+
return self
|
859
|
+
|
860
|
+
|
861
|
+
class SearchTextResponse(BaseModel):
|
862
|
+
"""Response proto for SearchText."""
|
863
|
+
|
864
|
+
model_config = ConfigDict(extra="forbid")
|
865
|
+
|
866
|
+
places: list[GooglePlace] = Field(
|
867
|
+
default_factory=list,
|
868
|
+
description="A list of places that meet the user's text search criteria.",
|
869
|
+
)
|
870
|
+
|
871
|
+
|
872
|
+
class ResolvedAirport(BaseModel):
|
873
|
+
"""Airport result from the resolve_airport() method."""
|
874
|
+
|
875
|
+
name: str = Field(..., description="Name of the airport")
|
876
|
+
city: str = Field(..., description="City of the airport")
|
877
|
+
iata_code: Optional[str] = Field(
|
878
|
+
None, description="IATA airport code if applicable"
|
879
|
+
)
|
880
|
+
icao_code: Optional[str] = Field(
|
881
|
+
None, description="ICAO airport code if applicable"
|
882
|
+
)
|
883
|
+
confidence: float = Field(..., description="Search result confidence score")
|