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,29 +1,29 @@
|
|
1
1
|
from __future__ import annotations
|
2
2
|
|
3
|
-
import re
|
4
|
-
from collections.abc import Sequence
|
5
3
|
from os import getenv
|
6
|
-
from
|
4
|
+
from types import TracebackType
|
5
|
+
from typing import Any, Optional, TypeVar
|
7
6
|
|
8
7
|
import httpx
|
9
|
-
from google.api_core import exceptions as gexc
|
10
|
-
from google.api_core.client_options import ClientOptions
|
11
8
|
from google.maps.places_v1 import PlacesClient
|
12
9
|
from typing_extensions import ParamSpec
|
13
10
|
|
14
|
-
from ...exceptions import BookalimoError
|
15
11
|
from ...logging import get_logger
|
12
|
+
from ...schemas.places import FieldMaskInput
|
16
13
|
from ...schemas.places import google as models
|
17
|
-
from ...schemas.places.place import Place as GooglePlace
|
18
14
|
from .common import (
|
19
|
-
ADDRESS_TYPES,
|
20
15
|
DEFAULT_PLACE_FIELDS,
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
16
|
+
create_search_text_request,
|
17
|
+
handle_autocomplete_impl,
|
18
|
+
handle_geocode_response,
|
19
|
+
handle_get_place_impl,
|
20
|
+
handle_resolve_airport_postprocessing,
|
21
|
+
handle_resolve_airport_preprocessing,
|
22
|
+
handle_search_impl,
|
23
|
+
prepare_geocode_params,
|
24
|
+
validate_autocomplete_inputs,
|
25
25
|
)
|
26
|
-
from .
|
26
|
+
from .transports import GoogleSyncTransport
|
27
27
|
|
28
28
|
logger = get_logger("places")
|
29
29
|
|
@@ -31,37 +31,6 @@ P = ParamSpec("P")
|
|
31
31
|
R = TypeVar("R")
|
32
32
|
|
33
33
|
|
34
|
-
def _strip_html(s: str) -> str:
|
35
|
-
# Simple fallback for adr_format_address (which is HTML)
|
36
|
-
return re.sub(r"<[^>]+>", "", s) if s else s
|
37
|
-
|
38
|
-
|
39
|
-
def _infer_place_type(m: GooglePlace) -> str:
|
40
|
-
# 1) Airport wins outright
|
41
|
-
tset = set(m.types or [])
|
42
|
-
ptype = (m.primary_type or "").lower() if getattr(m, "primary_type", None) else ""
|
43
|
-
if "airport" in tset or ptype == "airport":
|
44
|
-
return "airport"
|
45
|
-
# 2) Anything that looks like a geocoded address
|
46
|
-
if tset & ADDRESS_TYPES:
|
47
|
-
return "address"
|
48
|
-
# 3) Otherwise treat as a point of interest
|
49
|
-
return "poi"
|
50
|
-
|
51
|
-
|
52
|
-
def _get_lat_lng(model: GooglePlace) -> tuple[float, float]:
|
53
|
-
if model.location:
|
54
|
-
lat = model.location.latitude
|
55
|
-
lng = model.location.longitude
|
56
|
-
elif model.viewport:
|
57
|
-
lat = model.viewport.low.latitude
|
58
|
-
lng = model.viewport.low.longitude
|
59
|
-
else:
|
60
|
-
lat = 0
|
61
|
-
lng = 0
|
62
|
-
return lat, lng
|
63
|
-
|
64
|
-
|
65
34
|
class GooglePlaces:
|
66
35
|
"""
|
67
36
|
Google Places API synchronous client for address validation, geocoding, and autocomplete.
|
@@ -83,15 +52,10 @@ class GooglePlaces:
|
|
83
52
|
http_client: Optional `httpx.Client` instance.
|
84
53
|
"""
|
85
54
|
self.http_client = http_client or httpx.Client()
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
if not api_key:
|
91
|
-
raise ValueError("Google Places API key is required.")
|
92
|
-
self.client = PlacesClient(
|
93
|
-
client_options=ClientOptions(api_key=api_key),
|
94
|
-
)
|
55
|
+
self.api_key = api_key or getenv("GOOGLE_PLACES_API_KEY")
|
56
|
+
if not self.api_key:
|
57
|
+
raise ValueError("Google Places API key is required.")
|
58
|
+
self.transport = GoogleSyncTransport(self.api_key, client)
|
95
59
|
|
96
60
|
def __enter__(self) -> GooglePlaces:
|
97
61
|
return self
|
@@ -100,158 +64,180 @@ class GooglePlaces:
|
|
100
64
|
self,
|
101
65
|
exc_type: Optional[type[BaseException]],
|
102
66
|
exc_val: Optional[BaseException],
|
103
|
-
exc_tb: Optional[
|
67
|
+
exc_tb: Optional[TracebackType],
|
104
68
|
) -> None:
|
105
69
|
self.close()
|
106
70
|
|
107
71
|
def close(self) -> None:
|
108
72
|
"""Close underlying transports safely."""
|
109
73
|
try:
|
110
|
-
self.
|
74
|
+
self.transport.close()
|
111
75
|
finally:
|
112
76
|
self.http_client.close()
|
113
77
|
|
114
78
|
def autocomplete(
|
115
|
-
self,
|
79
|
+
self,
|
80
|
+
input: Optional[str] = None,
|
81
|
+
*,
|
82
|
+
request: Optional[models.AutocompletePlacesRequest] = None,
|
116
83
|
) -> models.AutocompletePlacesResponse:
|
117
84
|
"""
|
118
85
|
Get autocomplete suggestions for a location query.
|
119
86
|
Args:
|
87
|
+
input: The text string on which to search.
|
120
88
|
request: AutocompletePlacesRequest object.
|
121
|
-
**kwargs: Additional parameters for the Google Places Autocomplete API.
|
122
89
|
Returns:
|
123
90
|
`AutocompletePlacesResponse` object.
|
91
|
+
Note:
|
92
|
+
If both input and request are provided, request will be used.
|
124
93
|
Raises:
|
94
|
+
ValueError: If neither input nor request is provided, or if both are provided.
|
125
95
|
BookalimoError: If the API request fails.
|
126
96
|
"""
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
|
132
|
-
except gexc.GoogleAPICallError as e:
|
133
|
-
msg = f"Google Places Autocomplete failed: {fmt_exc(e)}"
|
134
|
-
logger.error(msg)
|
135
|
-
raise BookalimoError(msg) from e
|
97
|
+
request = validate_autocomplete_inputs(input, request)
|
98
|
+
return handle_autocomplete_impl(
|
99
|
+
lambda req: self.transport.autocomplete_places(request=req),
|
100
|
+
request,
|
101
|
+
)
|
136
102
|
|
137
103
|
def geocode(self, request: models.GeocodingRequest) -> dict[str, Any]:
|
138
|
-
|
139
|
-
|
140
|
-
|
141
|
-
|
142
|
-
|
143
|
-
|
144
|
-
|
145
|
-
except httpx.HTTPError as e:
|
146
|
-
msg = f"HTTP geocoding failed: {fmt_exc(e)}"
|
147
|
-
logger.error(msg)
|
148
|
-
raise BookalimoError(msg) from e
|
104
|
+
assert self.api_key is not None # Validated in __init__
|
105
|
+
params = prepare_geocode_params(request, self.api_key)
|
106
|
+
r = self.http_client.get(
|
107
|
+
"https://maps.googleapis.com/maps/api/geocode/json",
|
108
|
+
params=params,
|
109
|
+
)
|
110
|
+
return handle_geocode_response(r)
|
149
111
|
|
150
112
|
def search(
|
151
113
|
self,
|
152
|
-
query: str,
|
114
|
+
query: Optional[str] = None,
|
153
115
|
*,
|
154
|
-
|
116
|
+
request: Optional[models.SearchTextRequest] = None,
|
117
|
+
fields: FieldMaskInput = DEFAULT_PLACE_FIELDS,
|
155
118
|
**kwargs: Any,
|
156
119
|
) -> list[models.Place]:
|
157
120
|
"""
|
158
|
-
Search for places using a text query.
|
121
|
+
Search for places using a text query or SearchTextRequest.
|
159
122
|
Args:
|
160
|
-
query:
|
123
|
+
query: Simple text query to search for. Either query or request must be provided.
|
124
|
+
request: SearchTextRequest object with advanced search parameters. Either query or request must be provided.
|
125
|
+
fields: Field mask for response data.
|
161
126
|
**kwargs: Additional parameters for the Text Search API.
|
162
127
|
Returns:
|
163
128
|
list[models.Place]
|
164
129
|
Raises:
|
165
130
|
BookalimoError: If the API request fails.
|
131
|
+
ValueError: If neither query nor request is provided, or if both are provided.
|
166
132
|
"""
|
167
|
-
|
168
|
-
|
169
|
-
|
170
|
-
|
171
|
-
|
172
|
-
|
173
|
-
|
174
|
-
validate_proto_to_model(proto, GooglePlace) for proto in protos.places
|
175
|
-
]
|
176
|
-
place_models: list[models.Place] = []
|
177
|
-
for model in pydantic_models:
|
178
|
-
adr_format = getattr(model, "adr_format_address", None)
|
179
|
-
addr = (
|
180
|
-
getattr(model, "formatted_address", None)
|
181
|
-
or getattr(model, "short_formatted_address", None)
|
182
|
-
or _strip_html(adr_format or "")
|
183
|
-
or ""
|
184
|
-
)
|
185
|
-
lat, lng = _get_lat_lng(model)
|
186
|
-
place_models.append(
|
187
|
-
models.Place(
|
188
|
-
formatted_address=addr,
|
189
|
-
lat=lat,
|
190
|
-
lng=lng,
|
191
|
-
place_type=_infer_place_type(model),
|
192
|
-
iata_code=None,
|
193
|
-
google_place=model,
|
194
|
-
)
|
195
|
-
)
|
196
|
-
return place_models
|
197
|
-
except gexc.InvalidArgument as e:
|
198
|
-
# Often caused by missing/invalid field mask
|
199
|
-
msg = f"Google Places Text Search invalid argument: {fmt_exc(e)}"
|
200
|
-
logger.error(msg)
|
201
|
-
raise BookalimoError(msg) from e
|
202
|
-
except gexc.GoogleAPICallError as e:
|
203
|
-
msg = f"Google Places Text Search failed: {fmt_exc(e)}"
|
204
|
-
logger.error(msg)
|
205
|
-
raise BookalimoError(msg) from e
|
133
|
+
return handle_search_impl(
|
134
|
+
lambda req, meta: self.transport.search_text(request=req, metadata=meta),
|
135
|
+
query,
|
136
|
+
request,
|
137
|
+
fields,
|
138
|
+
**kwargs,
|
139
|
+
)
|
206
140
|
|
207
141
|
def get(
|
208
142
|
self,
|
209
|
-
place_id:
|
143
|
+
place_id: Optional[str] = None,
|
210
144
|
*,
|
211
|
-
|
212
|
-
|
145
|
+
request: Optional[models.GetPlaceRequest] = None,
|
146
|
+
fields: FieldMaskInput = DEFAULT_PLACE_FIELDS,
|
213
147
|
) -> Optional[models.Place]:
|
214
148
|
"""
|
215
149
|
Get details for a specific place.
|
216
150
|
Args:
|
217
151
|
place_id: The ID of the place to retrieve details for.
|
218
|
-
|
152
|
+
request: GetPlaceRequest object with place resource name.
|
153
|
+
fields: Optional field mask for response data.
|
219
154
|
Returns:
|
220
155
|
A models.Place object or None if not found.
|
221
156
|
Raises:
|
157
|
+
ValueError: If neither place_id nor request is provided.
|
222
158
|
BookalimoError: If the API request fails.
|
223
159
|
"""
|
224
|
-
|
225
|
-
|
226
|
-
|
227
|
-
|
228
|
-
|
229
|
-
|
230
|
-
|
231
|
-
|
232
|
-
|
233
|
-
|
234
|
-
|
235
|
-
|
236
|
-
|
237
|
-
|
160
|
+
return handle_get_place_impl(
|
161
|
+
lambda req, meta: self.transport.get_place(request=req, metadata=meta),
|
162
|
+
place_id,
|
163
|
+
request,
|
164
|
+
fields,
|
165
|
+
)
|
166
|
+
|
167
|
+
def resolve_airport(
|
168
|
+
self,
|
169
|
+
query: Optional[str] = None,
|
170
|
+
place_id: Optional[str] = None,
|
171
|
+
places: Optional[list[models.Place]] = None,
|
172
|
+
country_code: Optional[str] = None,
|
173
|
+
max_distance_km: Optional[float] = 100,
|
174
|
+
max_results: Optional[int] = 5,
|
175
|
+
confidence_threshold: Optional[float] = 0.5,
|
176
|
+
text_weight: float = 0.5,
|
177
|
+
) -> list[models.ResolvedAirport]:
|
178
|
+
"""
|
179
|
+
Resolve airport candidates given either a natural language text query, a place_id, or a list of Places.
|
180
|
+
|
181
|
+
Args:
|
182
|
+
query: Text query for airport search (optional)
|
183
|
+
place_id: Google place ID for proximity matching (optional)
|
184
|
+
places: List of existing Place objects for proximity matching (optional)
|
185
|
+
country_code: Country code for proximity matching (optional)
|
186
|
+
max_distance_km: Maximum distance for proximity matching (default: 100km)
|
187
|
+
max_results: Maximum number of results to return (default: 5)
|
188
|
+
confidence_threshold: Minimum confidence threshold (default: 0.5)
|
189
|
+
text_weight: Weight for text search (default: 0.5) If 0.0, only proximity will be used. If 1.0, only text will be used.
|
190
|
+
|
191
|
+
Rules:
|
192
|
+
- Provide at most one of {place_id, places}. (query may accompany either.)
|
193
|
+
- If nothing but query is given, search for places from the query.
|
194
|
+
- If place_id is given:
|
195
|
+
* Fetch the place.
|
196
|
+
* If no explicit query, derive it from the place's display name.
|
197
|
+
- If places is given:
|
198
|
+
* If len(places) == 0 and no query, error.
|
199
|
+
* If len(places) == 1 and no query, derive query from that place's display name.
|
200
|
+
* If len(places) > 1 and no query, error (need query to disambiguate).
|
201
|
+
- If nothing is provided, error.
|
202
|
+
- If max_distance_km is provided, it must be > 0.
|
203
|
+
|
204
|
+
Returns:
|
205
|
+
list[models.ResolvedAirport]
|
206
|
+
Raises:
|
207
|
+
ValueError on invalid inputs.
|
208
|
+
BookalimoError if underlying API requests fail.
|
209
|
+
"""
|
210
|
+
# Handle preprocessing that doesn't depend on sync/async
|
211
|
+
preprocessed_query, preprocessed_places, needs_call = (
|
212
|
+
handle_resolve_airport_preprocessing(
|
213
|
+
query, place_id, places, max_distance_km
|
238
214
|
)
|
239
|
-
|
240
|
-
|
241
|
-
|
242
|
-
|
243
|
-
|
244
|
-
|
245
|
-
|
246
|
-
|
215
|
+
)
|
216
|
+
|
217
|
+
# Handle the calls that do depend on sync/async
|
218
|
+
effective_places: list[models.Place]
|
219
|
+
if place_id is not None:
|
220
|
+
place = self.get(place_id=place_id)
|
221
|
+
if place is None:
|
222
|
+
raise ValueError(f"Place with id {place_id!r} was not found.")
|
223
|
+
effective_places = [place]
|
224
|
+
elif needs_call and preprocessed_places == []:
|
225
|
+
# Need to perform search
|
226
|
+
effective_places = self.search(
|
227
|
+
request=create_search_text_request(
|
228
|
+
query=str(preprocessed_query).strip(),
|
229
|
+
region_code=country_code,
|
230
|
+
)
|
247
231
|
)
|
248
|
-
|
249
|
-
|
250
|
-
|
251
|
-
|
252
|
-
|
253
|
-
|
254
|
-
|
255
|
-
|
256
|
-
|
257
|
-
|
232
|
+
else:
|
233
|
+
effective_places = preprocessed_places
|
234
|
+
|
235
|
+
# Handle postprocessing
|
236
|
+
return handle_resolve_airport_postprocessing(
|
237
|
+
preprocessed_query,
|
238
|
+
effective_places,
|
239
|
+
max_distance_km,
|
240
|
+
max_results,
|
241
|
+
confidence_threshold,
|
242
|
+
text_weight,
|
243
|
+
)
|