bookalimo 1.0.0__py3-none-any.whl → 1.0.2__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/client.py +45 -22
- bookalimo/config.py +1 -1
- bookalimo/integrations/google_places/client_async.py +141 -156
- bookalimo/integrations/google_places/client_sync.py +142 -156
- bookalimo/integrations/google_places/common.py +464 -200
- bookalimo/integrations/google_places/resolve_airport.py +426 -0
- bookalimo/integrations/google_places/transports.py +105 -0
- bookalimo/logging.py +103 -0
- bookalimo/schemas/__init__.py +126 -34
- bookalimo/schemas/base.py +74 -14
- bookalimo/schemas/places/__init__.py +27 -0
- bookalimo/schemas/places/common.py +155 -2
- bookalimo/schemas/places/field_mask.py +212 -0
- bookalimo/schemas/places/google.py +458 -16
- bookalimo/schemas/places/place.py +25 -28
- bookalimo/schemas/requests.py +214 -0
- bookalimo/schemas/responses.py +196 -0
- bookalimo/schemas/{booking.py → shared.py} +55 -218
- bookalimo/services/pricing.py +9 -129
- bookalimo/services/reservations.py +10 -100
- bookalimo/transport/auth.py +2 -2
- bookalimo/transport/httpx_async.py +41 -125
- bookalimo/transport/httpx_sync.py +30 -109
- bookalimo/transport/utils.py +204 -3
- bookalimo-1.0.2.dist-info/METADATA +245 -0
- bookalimo-1.0.2.dist-info/RECORD +40 -0
- bookalimo-1.0.2.dist-info/licenses/LICENSE +21 -0
- bookalimo-1.0.0.dist-info/METADATA +0 -307
- bookalimo-1.0.0.dist-info/RECORD +0 -35
- bookalimo-1.0.0.dist-info/licenses/LICENSE +0 -0
- {bookalimo-1.0.0.dist-info → bookalimo-1.0.2.dist-info}/WHEEL +0 -0
- {bookalimo-1.0.0.dist-info → bookalimo-1.0.2.dist-info}/top_level.txt +0 -0
@@ -1,4 +1,15 @@
|
|
1
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
|
+
|
2
13
|
from typing import Any, Optional, cast
|
3
14
|
|
4
15
|
import pycountry
|
@@ -24,7 +35,7 @@ from .common import (
|
|
24
35
|
LatLng,
|
25
36
|
Viewport,
|
26
37
|
)
|
27
|
-
from .place import
|
38
|
+
from .place import GooglePlace, PriceLevel
|
28
39
|
|
29
40
|
# ---------- Constants & Enums ----------
|
30
41
|
|
@@ -33,7 +44,7 @@ COUNTRY_CODES = {
|
|
33
44
|
}
|
34
45
|
|
35
46
|
|
36
|
-
class PlaceType:
|
47
|
+
class PlaceType(StrEnum):
|
37
48
|
"""Place type constants."""
|
38
49
|
|
39
50
|
ADDRESS = "address"
|
@@ -41,6 +52,31 @@ class PlaceType:
|
|
41
52
|
POI = "poi" # Point of Interest
|
42
53
|
|
43
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
|
+
|
44
80
|
# ---------- Text Primitives ----------
|
45
81
|
class StringRange(BaseModel):
|
46
82
|
"""Identifies a substring within a given text."""
|
@@ -90,8 +126,8 @@ class StructuredFormat(BaseModel):
|
|
90
126
|
|
91
127
|
model_config = ConfigDict(extra="forbid")
|
92
128
|
|
93
|
-
main_text: FormattableText
|
94
|
-
secondary_text: FormattableText
|
129
|
+
main_text: Optional[FormattableText] = None
|
130
|
+
secondary_text: Optional[FormattableText] = None
|
95
131
|
|
96
132
|
|
97
133
|
# ---------- Geometry Primitives ----------
|
@@ -148,6 +184,82 @@ class LocationRestriction(BaseModel):
|
|
148
184
|
return self
|
149
185
|
|
150
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
|
+
|
151
263
|
# ---------- Responses ----------
|
152
264
|
|
153
265
|
|
@@ -157,10 +269,7 @@ class Place(BaseModel):
|
|
157
269
|
formatted_address: str = Field(..., description="Full formatted address")
|
158
270
|
lat: float = Field(..., description="Latitude")
|
159
271
|
lng: float = Field(..., description="Longitude")
|
160
|
-
place_type:
|
161
|
-
iata_code: Optional[str] = Field(
|
162
|
-
None, description="IATA airport code if applicable"
|
163
|
-
)
|
272
|
+
place_type: PlaceType = Field(..., description="Type: address, airport, or poi")
|
164
273
|
google_place: Optional[GooglePlace] = Field(
|
165
274
|
None, description="Raw Google Places API response"
|
166
275
|
)
|
@@ -542,37 +651,173 @@ class AutocompletePlacesRequest(BaseModel):
|
|
542
651
|
return self
|
543
652
|
|
544
653
|
|
654
|
+
class ExtraComputations(StrEnum):
|
655
|
+
"""Extra computations for Geocoding API requests."""
|
656
|
+
|
657
|
+
ADDRESS_DESCRIPTORS = "ADDRESS_DESCRIPTORS"
|
658
|
+
BUILDING_AND_ENTRANCES = "BUILDING_AND_ENTRANCES"
|
659
|
+
|
660
|
+
|
545
661
|
class GeocodingRequest(BaseModel):
|
546
662
|
"""
|
547
663
|
Pydantic model for validating and building Geocoding API query parameters.
|
664
|
+
Supports both forward geocoding (address -> coordinates) and reverse geocoding (coordinates -> address).
|
548
665
|
This model is not for a JSON request body, but for constructing a URL.
|
549
666
|
"""
|
550
667
|
|
668
|
+
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
|
669
|
+
|
670
|
+
# Forward geocoding parameters
|
551
671
|
address: Optional[str] = Field(
|
552
672
|
default=None,
|
553
673
|
description="The street address or plus code that you want to geocode.",
|
554
674
|
)
|
675
|
+
components: Optional[str] = Field(
|
676
|
+
default=None,
|
677
|
+
description="Components filter with pipe-separated component:value pairs (e.g., 'country:US|locality:Mountain View').",
|
678
|
+
)
|
679
|
+
|
680
|
+
# Reverse geocoding parameters
|
681
|
+
latlng: Optional[str] = Field(
|
682
|
+
default=None,
|
683
|
+
description="Latitude and longitude coordinates for reverse geocoding (e.g., '40.714224,-73.961452').",
|
684
|
+
)
|
555
685
|
place_id: Optional[str] = Field(
|
556
686
|
default=None,
|
557
687
|
description="The place ID of the place for which you wish to obtain the human-readable address.",
|
558
688
|
)
|
689
|
+
|
690
|
+
# Optional parameters for both forward and reverse geocoding
|
559
691
|
language: Optional[str] = Field(
|
560
|
-
default=None, description="The language in which to return results."
|
692
|
+
default=None, description="The language in which to return results (BCP-47)."
|
561
693
|
)
|
562
694
|
region: Optional[str] = Field(
|
563
695
|
default=None, description="The region code (ccTLD) to bias results."
|
564
696
|
)
|
697
|
+
extra_computations: list[ExtraComputations] = Field(
|
698
|
+
default_factory=list,
|
699
|
+
description="Additional features to include in the response.",
|
700
|
+
)
|
701
|
+
|
702
|
+
# Forward geocoding specific optional parameters
|
703
|
+
bounds: Optional[str] = Field(
|
704
|
+
default=None,
|
705
|
+
description="Bounding box for viewport biasing (format: 'southwest_lat,southwest_lng|northeast_lat,northeast_lng').",
|
706
|
+
)
|
707
|
+
|
708
|
+
# Reverse geocoding specific optional parameters
|
709
|
+
result_type: Optional[str] = Field(
|
710
|
+
default=None,
|
711
|
+
description="Filter for address types, pipe-separated (e.g., 'street_address|route').",
|
712
|
+
)
|
713
|
+
location_type: Optional[str] = Field(
|
714
|
+
default=None,
|
715
|
+
description="Filter for location types, pipe-separated (e.g., 'ROOFTOP|RANGE_INTERPOLATED').",
|
716
|
+
)
|
717
|
+
|
718
|
+
@field_validator("latlng")
|
719
|
+
@classmethod
|
720
|
+
def _validate_latlng(cls, v: Optional[str]) -> Optional[str]:
|
721
|
+
if v is None:
|
722
|
+
return v
|
723
|
+
try:
|
724
|
+
parts = v.split(",")
|
725
|
+
if len(parts) != 2:
|
726
|
+
raise ValueError("latlng must be in format 'latitude,longitude'")
|
727
|
+
lat, lng = float(parts[0]), float(parts[1])
|
728
|
+
if not (-90 <= lat <= 90):
|
729
|
+
raise ValueError("Latitude must be between -90 and 90")
|
730
|
+
if not (-180 <= lng <= 180):
|
731
|
+
raise ValueError("Longitude must be between -180 and 180")
|
732
|
+
except (ValueError, IndexError) as e:
|
733
|
+
if "could not convert" in str(e):
|
734
|
+
raise ValueError("latlng coordinates must be valid numbers") from None
|
735
|
+
raise
|
736
|
+
return v
|
737
|
+
|
738
|
+
@field_validator("bounds")
|
739
|
+
@classmethod
|
740
|
+
def _validate_bounds(cls, v: Optional[str]) -> Optional[str]:
|
741
|
+
if v is None:
|
742
|
+
return v
|
743
|
+
try:
|
744
|
+
parts = v.split("|")
|
745
|
+
if len(parts) != 2:
|
746
|
+
raise ValueError(
|
747
|
+
"bounds must be in format 'sw_lat,sw_lng|ne_lat,ne_lng'"
|
748
|
+
)
|
749
|
+
for part in parts:
|
750
|
+
coords = part.split(",")
|
751
|
+
if len(coords) != 2:
|
752
|
+
raise ValueError("Each bounds coordinate pair must be 'lat,lng'")
|
753
|
+
lat, lng = float(coords[0]), float(coords[1])
|
754
|
+
if not (-90 <= lat <= 90):
|
755
|
+
raise ValueError("Latitude must be between -90 and 90")
|
756
|
+
if not (-180 <= lng <= 180):
|
757
|
+
raise ValueError("Longitude must be between -180 and 180")
|
758
|
+
except (ValueError, IndexError) as e:
|
759
|
+
if "could not convert" in str(e):
|
760
|
+
raise ValueError("bounds coordinates must be valid numbers") from None
|
761
|
+
raise
|
762
|
+
return v
|
763
|
+
|
764
|
+
@field_validator("language")
|
765
|
+
@classmethod
|
766
|
+
def _validate_language_code(cls, v: Optional[str]) -> Optional[str]:
|
767
|
+
if v is None:
|
768
|
+
return v
|
769
|
+
if not BCP47.match(v):
|
770
|
+
raise ValueError(
|
771
|
+
"language must be a valid BCP-47 tag (e.g., 'en', 'en-US', 'zh-Hant')."
|
772
|
+
)
|
773
|
+
return v
|
774
|
+
|
775
|
+
@field_validator("region")
|
776
|
+
@classmethod
|
777
|
+
def _validate_region_code(cls, v: Optional[str]) -> Optional[str]:
|
778
|
+
if v is None:
|
779
|
+
return v
|
780
|
+
v2 = v.upper()
|
781
|
+
if not CLDR_REGION_2.fullmatch(v2):
|
782
|
+
raise ValueError(
|
783
|
+
"region must be a two-letter ccTLD region code (e.g., 'US', 'GB')."
|
784
|
+
)
|
785
|
+
return v2
|
786
|
+
|
787
|
+
@field_validator("extra_computations")
|
788
|
+
@classmethod
|
789
|
+
def _validate_extra_computations(
|
790
|
+
cls, v: list[ExtraComputations]
|
791
|
+
) -> list[ExtraComputations]:
|
792
|
+
# Remove duplicates while preserving order
|
793
|
+
seen = set()
|
794
|
+
cleaned = []
|
795
|
+
for computation in v:
|
796
|
+
if computation not in seen:
|
797
|
+
cleaned.append(computation)
|
798
|
+
seen.add(computation)
|
799
|
+
return cleaned
|
565
800
|
|
566
801
|
@model_validator(mode="before")
|
567
802
|
@classmethod
|
568
803
|
def check_required_params(cls, data: Any) -> Any:
|
569
|
-
"""
|
804
|
+
"""Validates that proper parameters are provided for forward or reverse geocoding."""
|
570
805
|
if isinstance(data, dict):
|
571
|
-
|
572
|
-
|
573
|
-
)
|
806
|
+
has_forward = any([data.get("address"), data.get("components")])
|
807
|
+
has_reverse = data.get("latlng") is not None
|
808
|
+
has_place_id = data.get("place_id") is not None
|
809
|
+
|
810
|
+
if not (has_forward or has_reverse or has_place_id):
|
574
811
|
raise ValueError(
|
575
|
-
"
|
812
|
+
"For forward geocoding: specify 'address' and/or 'components'. "
|
813
|
+
"For reverse geocoding: specify 'latlng'. "
|
814
|
+
"For place ID lookup: specify 'place_id'."
|
815
|
+
)
|
816
|
+
|
817
|
+
# Can't mix forward and reverse geocoding parameters
|
818
|
+
if has_reverse and (has_forward or has_place_id):
|
819
|
+
raise ValueError(
|
820
|
+
"Cannot mix reverse geocoding (latlng) with forward geocoding (address/components) or place_id lookup."
|
576
821
|
)
|
577
822
|
return data
|
578
823
|
|
@@ -581,16 +826,213 @@ class GeocodingRequest(BaseModel):
|
|
581
826
|
Serializes the model fields into a dictionary suitable for URL query parameters.
|
582
827
|
"""
|
583
828
|
params = QueryParams()
|
829
|
+
|
830
|
+
# Forward geocoding parameters
|
584
831
|
if self.address:
|
585
832
|
params = params.add("address", self.address)
|
586
|
-
|
833
|
+
if self.components:
|
834
|
+
params = params.add("components", self.components)
|
835
|
+
if self.bounds:
|
836
|
+
params = params.add("bounds", self.bounds)
|
837
|
+
|
838
|
+
# Reverse geocoding parameters
|
839
|
+
if self.latlng:
|
840
|
+
params = params.add("latlng", self.latlng)
|
841
|
+
if self.result_type:
|
842
|
+
params = params.add("result_type", self.result_type)
|
843
|
+
if self.location_type:
|
844
|
+
params = params.add("location_type", self.location_type)
|
845
|
+
|
846
|
+
# Place ID lookup
|
587
847
|
if self.place_id:
|
588
848
|
params = params.add("place_id", self.place_id)
|
589
849
|
|
850
|
+
# Common optional parameters
|
590
851
|
if self.language:
|
591
852
|
params = params.add("language", self.language)
|
592
|
-
|
593
853
|
if self.region:
|
594
854
|
params = params.add("region", self.region)
|
595
855
|
|
856
|
+
# Extra computations (can appear multiple times)
|
857
|
+
for computation in self.extra_computations:
|
858
|
+
params = params.add("extra_computations", computation.value)
|
859
|
+
|
596
860
|
return params
|
861
|
+
|
862
|
+
|
863
|
+
class SearchTextRequest(BaseModel):
|
864
|
+
"""
|
865
|
+
Pydantic model for SearchTextRequest with rich validations.
|
866
|
+
"""
|
867
|
+
|
868
|
+
model_config = ConfigDict(
|
869
|
+
extra="forbid", str_strip_whitespace=True, populate_by_name=True
|
870
|
+
)
|
871
|
+
|
872
|
+
# Required field
|
873
|
+
text_query: str = Field(
|
874
|
+
..., min_length=1, description="Required. The text query for textual search."
|
875
|
+
)
|
876
|
+
|
877
|
+
# Localization
|
878
|
+
language_code: Optional[str] = Field(
|
879
|
+
default=None,
|
880
|
+
description="Place details will be displayed with the preferred language if available.",
|
881
|
+
)
|
882
|
+
region_code: Optional[str] = Field(
|
883
|
+
default=None,
|
884
|
+
description="The Unicode country/region code (CLDR) of the location.",
|
885
|
+
)
|
886
|
+
|
887
|
+
# Ranking and type filtering
|
888
|
+
rank_preference: Optional[RankPreference] = Field(
|
889
|
+
default=None, description="How results will be ranked in the response."
|
890
|
+
)
|
891
|
+
included_type: Optional[str] = Field(
|
892
|
+
default=None,
|
893
|
+
description="The requested place type. Only support one included type.",
|
894
|
+
)
|
895
|
+
strict_type_filtering: bool = Field(
|
896
|
+
default=False,
|
897
|
+
description="Used to set strict type filtering for included_type.",
|
898
|
+
)
|
899
|
+
|
900
|
+
# Filters
|
901
|
+
open_now: bool = Field(
|
902
|
+
default=False,
|
903
|
+
description="Used to restrict the search to places that are currently open.",
|
904
|
+
)
|
905
|
+
min_rating: Optional[float] = Field(
|
906
|
+
default=None,
|
907
|
+
ge=0,
|
908
|
+
le=5,
|
909
|
+
description="Filter out results whose average user rating is strictly less than this limit.",
|
910
|
+
)
|
911
|
+
max_result_count: Optional[int] = Field(
|
912
|
+
default=None,
|
913
|
+
ge=1,
|
914
|
+
le=20,
|
915
|
+
description="Maximum number of results to return. Must be between 1 and 20.",
|
916
|
+
)
|
917
|
+
price_levels: list[PriceLevel] = Field(
|
918
|
+
default_factory=list,
|
919
|
+
description="Used to restrict the search to places that are marked as certain price levels.",
|
920
|
+
)
|
921
|
+
|
922
|
+
# Location constraints (mutually exclusive)
|
923
|
+
location_bias: Optional[LocationBias] = Field(
|
924
|
+
default=None,
|
925
|
+
description="The region to search. This location serves as a bias.",
|
926
|
+
)
|
927
|
+
location_restriction: Optional[SearchTextLocationRestriction] = Field(
|
928
|
+
default=None,
|
929
|
+
description="The region to search. This location serves as a restriction.",
|
930
|
+
)
|
931
|
+
|
932
|
+
# Advanced options
|
933
|
+
ev_options: Optional[EVOptions] = Field(
|
934
|
+
default=None,
|
935
|
+
description="Set the searchable EV options of a place search request.",
|
936
|
+
)
|
937
|
+
routing_parameters: Optional[RoutingParameters] = Field(
|
938
|
+
default=None, description="Additional parameters for routing to results."
|
939
|
+
)
|
940
|
+
search_along_route_parameters: Optional[SearchAlongRouteParameters] = Field(
|
941
|
+
default=None, description="Additional parameters for searching along a route."
|
942
|
+
)
|
943
|
+
include_pure_service_area_businesses: bool = Field(
|
944
|
+
default=False,
|
945
|
+
description="Include pure service area businesses if the field is set to true.",
|
946
|
+
)
|
947
|
+
|
948
|
+
# Field validators
|
949
|
+
@field_validator("language_code")
|
950
|
+
@classmethod
|
951
|
+
def _validate_language_code(cls, v: Optional[str]) -> Optional[str]:
|
952
|
+
if v is None:
|
953
|
+
return v
|
954
|
+
if not BCP47.match(v):
|
955
|
+
raise ValueError(
|
956
|
+
"language_code must be a valid BCP-47 tag (e.g., 'en', 'en-US', 'zh-Hant')."
|
957
|
+
)
|
958
|
+
return v
|
959
|
+
|
960
|
+
@field_validator("region_code")
|
961
|
+
@classmethod
|
962
|
+
def _validate_region_code(cls, v: Optional[str]) -> Optional[str]:
|
963
|
+
if v is None:
|
964
|
+
return v
|
965
|
+
v2 = v.upper()
|
966
|
+
if not CLDR_REGION_2.fullmatch(v2):
|
967
|
+
raise ValueError(
|
968
|
+
"region_code must be a two-letter CLDR region code (e.g., 'US', 'GB')."
|
969
|
+
)
|
970
|
+
return v2
|
971
|
+
|
972
|
+
@field_validator("included_type")
|
973
|
+
@classmethod
|
974
|
+
def _validate_included_type(cls, v: Optional[str]) -> Optional[str]:
|
975
|
+
if v is None:
|
976
|
+
return v
|
977
|
+
if not PLACE_TYPE.match(v):
|
978
|
+
raise ValueError(
|
979
|
+
f"Invalid place type '{v}'. Use lowercase letters, digits, and underscores."
|
980
|
+
)
|
981
|
+
return v
|
982
|
+
|
983
|
+
@field_validator("min_rating")
|
984
|
+
@classmethod
|
985
|
+
def _validate_min_rating(cls, v: Optional[float]) -> Optional[float]:
|
986
|
+
if v is None:
|
987
|
+
return v
|
988
|
+
# Round up to nearest 0.5 as per Google's specification
|
989
|
+
import math
|
990
|
+
|
991
|
+
return math.ceil(v * 2) / 2
|
992
|
+
|
993
|
+
@field_validator("price_levels")
|
994
|
+
@classmethod
|
995
|
+
def _validate_price_levels(cls, v: list[PriceLevel]) -> list[PriceLevel]:
|
996
|
+
# Remove duplicates while preserving order
|
997
|
+
seen = set()
|
998
|
+
cleaned = []
|
999
|
+
for level in v:
|
1000
|
+
if level not in seen:
|
1001
|
+
cleaned.append(level)
|
1002
|
+
seen.add(level)
|
1003
|
+
return cleaned
|
1004
|
+
|
1005
|
+
# Cross-field validation
|
1006
|
+
@model_validator(mode="after")
|
1007
|
+
def _validate_cross_fields(self) -> Self:
|
1008
|
+
# Mutually exclusive location constraints
|
1009
|
+
if self.location_bias is not None and self.location_restriction is not None:
|
1010
|
+
raise ValueError(
|
1011
|
+
"Cannot set both location_bias and location_restriction. Choose one."
|
1012
|
+
)
|
1013
|
+
return self
|
1014
|
+
|
1015
|
+
|
1016
|
+
class SearchTextResponse(BaseModel):
|
1017
|
+
"""Response proto for SearchText."""
|
1018
|
+
|
1019
|
+
model_config = ConfigDict(extra="forbid")
|
1020
|
+
|
1021
|
+
places: list[GooglePlace] = Field(
|
1022
|
+
default_factory=list,
|
1023
|
+
description="A list of places that meet the user's text search criteria.",
|
1024
|
+
)
|
1025
|
+
|
1026
|
+
|
1027
|
+
class ResolvedAirport(BaseModel):
|
1028
|
+
"""Airport result from the resolve_airport() method."""
|
1029
|
+
|
1030
|
+
name: str = Field(..., description="Name of the airport")
|
1031
|
+
city: str = Field(..., description="City of the airport")
|
1032
|
+
iata_code: Optional[str] = Field(
|
1033
|
+
None, description="IATA airport code if applicable"
|
1034
|
+
)
|
1035
|
+
icao_code: Optional[str] = Field(
|
1036
|
+
None, description="ICAO airport code if applicable"
|
1037
|
+
)
|
1038
|
+
confidence: float = Field(..., description="Search result confidence score")
|