bookalimo 1.0.0__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/config.py +1 -1
- bookalimo/integrations/google_places/client_async.py +139 -108
- bookalimo/integrations/google_places/client_sync.py +139 -109
- bookalimo/integrations/google_places/common.py +186 -200
- bookalimo/integrations/google_places/resolve_airport.py +397 -0
- bookalimo/integrations/google_places/transports.py +98 -0
- bookalimo/schemas/__init__.py +6 -0
- bookalimo/schemas/places/__init__.py +25 -0
- bookalimo/schemas/places/common.py +155 -2
- bookalimo/schemas/places/field_mask.py +221 -0
- bookalimo/schemas/places/google.py +293 -6
- bookalimo/schemas/places/place.py +25 -28
- {bookalimo-1.0.0.dist-info → bookalimo-1.0.1.dist-info}/METADATA +132 -69
- {bookalimo-1.0.0.dist-info → bookalimo-1.0.1.dist-info}/RECORD +17 -14
- bookalimo-1.0.1.dist-info/licenses/LICENSE +21 -0
- bookalimo-1.0.0.dist-info/licenses/LICENSE +0 -0
- {bookalimo-1.0.0.dist-info → bookalimo-1.0.1.dist-info}/WHEEL +0 -0
- {bookalimo-1.0.0.dist-info → bookalimo-1.0.1.dist-info}/top_level.txt +0 -0
bookalimo/config.py
CHANGED
@@ -5,7 +5,7 @@ from ._version import __version__
|
|
5
5
|
# Default API configuration
|
6
6
|
DEFAULT_BASE_URL = "https://www.bookalimo.com/web/api"
|
7
7
|
DEFAULT_TIMEOUT = 5.0
|
8
|
-
DEFAULT_USER_AGENT = f"bookalimo-python/{__version__}
|
8
|
+
DEFAULT_USER_AGENT = f"bookalimo-python/{__version__}"
|
9
9
|
|
10
10
|
# Default retry configuration
|
11
11
|
DEFAULT_RETRIES = 2
|
@@ -1,30 +1,33 @@
|
|
1
1
|
from __future__ import annotations
|
2
2
|
|
3
|
-
import re
|
4
3
|
from os import getenv
|
5
4
|
from types import TracebackType
|
6
5
|
from typing import Any, Optional, TypeVar, cast
|
7
6
|
|
8
7
|
import httpx
|
9
8
|
from google.api_core import exceptions as gexc
|
10
|
-
from google.api_core.client_options import ClientOptions
|
11
9
|
from google.maps.places_v1 import PlacesAsyncClient
|
12
10
|
from typing_extensions import ParamSpec
|
13
11
|
|
14
12
|
from ...exceptions import BookalimoError
|
15
13
|
from ...logging import get_logger
|
14
|
+
from ...schemas.places import FieldMaskInput
|
16
15
|
from ...schemas.places import google as models
|
17
|
-
from ...schemas.places.place import Place as GooglePlace
|
18
16
|
from .common import (
|
19
|
-
ADDRESS_TYPES,
|
20
17
|
DEFAULT_PLACE_FIELDS,
|
21
|
-
|
22
|
-
|
23
|
-
|
18
|
+
build_get_place_request,
|
19
|
+
build_search_request_params,
|
20
|
+
derive_effective_query,
|
24
21
|
fmt_exc,
|
25
22
|
mask_header,
|
23
|
+
normalize_place_from_proto,
|
24
|
+
normalize_search_results,
|
25
|
+
validate_autocomplete_inputs,
|
26
|
+
validate_resolve_airport_inputs,
|
26
27
|
)
|
27
28
|
from .proto_adapter import validate_proto_to_model
|
29
|
+
from .resolve_airport import resolve_airport
|
30
|
+
from .transports import GoogleAsyncTransport
|
28
31
|
|
29
32
|
logger = get_logger("places")
|
30
33
|
|
@@ -32,40 +35,9 @@ P = ParamSpec("P")
|
|
32
35
|
R = TypeVar("R")
|
33
36
|
|
34
37
|
|
35
|
-
def _strip_html(s: str) -> str:
|
36
|
-
# Simple fallback for adr_format_address (which is HTML)
|
37
|
-
return re.sub(r"<[^>]+>", "", s) if s else s
|
38
|
-
|
39
|
-
|
40
|
-
def _infer_place_type(m: GooglePlace) -> str:
|
41
|
-
# 1) Airport wins outright
|
42
|
-
tset = set(m.types or [])
|
43
|
-
ptype = (m.primary_type or "").lower() if getattr(m, "primary_type", None) else ""
|
44
|
-
if "airport" in tset or ptype == "airport":
|
45
|
-
return "airport"
|
46
|
-
# 2) Anything that looks like a geocoded address
|
47
|
-
if tset & ADDRESS_TYPES:
|
48
|
-
return "address"
|
49
|
-
# 3) Otherwise treat as a point of interest
|
50
|
-
return "poi"
|
51
|
-
|
52
|
-
|
53
|
-
def _get_lat_lng(model: GooglePlace) -> tuple[float, float]:
|
54
|
-
if model.location:
|
55
|
-
lat = model.location.latitude
|
56
|
-
lng = model.location.longitude
|
57
|
-
elif model.viewport:
|
58
|
-
lat = model.viewport.low.latitude
|
59
|
-
lng = model.viewport.low.longitude
|
60
|
-
else:
|
61
|
-
lat = 0
|
62
|
-
lng = 0
|
63
|
-
return lat, lng
|
64
|
-
|
65
|
-
|
66
38
|
class AsyncGooglePlaces:
|
67
39
|
"""
|
68
|
-
Google Places API client for address validation, geocoding, and autocomplete.
|
40
|
+
Google Places API asynchronous client for address validation, geocoding, and autocomplete.
|
69
41
|
Provides location resolution services that integrate seamlessly with
|
70
42
|
Book-A-Limo location factory functions.
|
71
43
|
"""
|
@@ -84,15 +56,10 @@ class AsyncGooglePlaces:
|
|
84
56
|
http_client: Optional `httpx.AsyncClient` instance.
|
85
57
|
"""
|
86
58
|
self.http_client = http_client or httpx.AsyncClient()
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
if not api_key:
|
92
|
-
raise ValueError("Google Places API key is required.")
|
93
|
-
self.client = PlacesAsyncClient(
|
94
|
-
client_options=ClientOptions(api_key=api_key),
|
95
|
-
)
|
59
|
+
api_key = api_key or getenv("GOOGLE_PLACES_API_KEY")
|
60
|
+
if not api_key:
|
61
|
+
raise ValueError("Google Places API key is required.")
|
62
|
+
self.transport = GoogleAsyncTransport(api_key, client)
|
96
63
|
|
97
64
|
async def __aenter__(self) -> AsyncGooglePlaces:
|
98
65
|
return self
|
@@ -108,26 +75,33 @@ class AsyncGooglePlaces:
|
|
108
75
|
async def aclose(self) -> None:
|
109
76
|
"""Close underlying transports safely."""
|
110
77
|
try:
|
111
|
-
await self.
|
78
|
+
await self.transport.close()
|
112
79
|
finally:
|
113
80
|
await self.http_client.aclose()
|
114
81
|
|
115
82
|
async def autocomplete(
|
116
|
-
self,
|
83
|
+
self,
|
84
|
+
input: Optional[str] = None,
|
85
|
+
*,
|
86
|
+
request: Optional[models.AutocompletePlacesRequest] = None,
|
117
87
|
) -> models.AutocompletePlacesResponse:
|
118
88
|
"""
|
119
89
|
Get autocomplete suggestions for a location query.
|
120
90
|
Args:
|
91
|
+
input: The text string on which to search.
|
121
92
|
request: AutocompletePlacesRequest object.
|
122
|
-
**kwargs: Additional parameters for the Google Places Autocomplete API.
|
123
93
|
Returns:
|
124
94
|
`AutocompletePlacesResponse` object.
|
95
|
+
Note:
|
96
|
+
If both input and request are provided, request will be used.
|
125
97
|
Raises:
|
98
|
+
ValueError: If neither input nor request is provided, or if both are provided.
|
126
99
|
BookalimoError: If the API request fails.
|
127
100
|
"""
|
101
|
+
request = validate_autocomplete_inputs(input, request)
|
128
102
|
try:
|
129
|
-
proto = await self.
|
130
|
-
request=request.model_dump()
|
103
|
+
proto = await self.transport.autocomplete_places(
|
104
|
+
request=request.model_dump()
|
131
105
|
)
|
132
106
|
return validate_proto_to_model(proto, models.AutocompletePlacesResponse)
|
133
107
|
except gexc.GoogleAPICallError as e:
|
@@ -150,51 +124,34 @@ class AsyncGooglePlaces:
|
|
150
124
|
|
151
125
|
async def search(
|
152
126
|
self,
|
153
|
-
query: str,
|
127
|
+
query: Optional[str] = None,
|
154
128
|
*,
|
155
|
-
|
129
|
+
request: Optional[models.SearchTextRequest] = None,
|
130
|
+
fields: FieldMaskInput = DEFAULT_PLACE_FIELDS,
|
156
131
|
**kwargs: Any,
|
157
132
|
) -> list[models.Place]:
|
158
133
|
"""
|
159
|
-
Search for places using a text query.
|
134
|
+
Search for places using a text query or SearchTextRequest.
|
160
135
|
Args:
|
161
|
-
query:
|
136
|
+
query: Simple text query to search for. Either query or request must be provided.
|
137
|
+
request: SearchTextRequest object with advanced search parameters. Either query or request must be provided.
|
138
|
+
fields: Field mask for response data.
|
162
139
|
**kwargs: Additional parameters for the Text Search API.
|
163
140
|
Returns:
|
164
141
|
list[models.Place]
|
165
142
|
Raises:
|
166
143
|
BookalimoError: If the API request fails.
|
144
|
+
ValueError: If neither query nor request is provided, or if both are provided.
|
167
145
|
"""
|
168
|
-
|
146
|
+
request_params = build_search_request_params(query, request, **kwargs)
|
147
|
+
metadata = mask_header(fields, prefix="places")
|
148
|
+
|
169
149
|
try:
|
170
|
-
protos = await self.
|
171
|
-
request=
|
150
|
+
protos = await self.transport.search_text(
|
151
|
+
request=request_params,
|
172
152
|
metadata=metadata,
|
173
153
|
)
|
174
|
-
|
175
|
-
validate_proto_to_model(proto, GooglePlace) for proto in protos.places
|
176
|
-
]
|
177
|
-
place_models: list[models.Place] = []
|
178
|
-
for model in pydantic_models:
|
179
|
-
adr_format = getattr(model, "adr_format_address", None)
|
180
|
-
addr = (
|
181
|
-
getattr(model, "formatted_address", None)
|
182
|
-
or getattr(model, "short_formatted_address", None)
|
183
|
-
or _strip_html(adr_format or "")
|
184
|
-
or ""
|
185
|
-
)
|
186
|
-
lat, lng = _get_lat_lng(model)
|
187
|
-
place_models.append(
|
188
|
-
models.Place(
|
189
|
-
formatted_address=addr,
|
190
|
-
lat=lat,
|
191
|
-
lng=lng,
|
192
|
-
place_type=_infer_place_type(model),
|
193
|
-
iata_code=None,
|
194
|
-
google_place=model,
|
195
|
-
)
|
196
|
-
)
|
197
|
-
return place_models
|
154
|
+
return normalize_search_results(protos)
|
198
155
|
except gexc.InvalidArgument as e:
|
199
156
|
# Often caused by missing/invalid field mask
|
200
157
|
msg = f"Google Places Text Search invalid argument: {fmt_exc(e)}"
|
@@ -207,45 +164,32 @@ class AsyncGooglePlaces:
|
|
207
164
|
|
208
165
|
async def get(
|
209
166
|
self,
|
210
|
-
place_id:
|
167
|
+
place_id: Optional[str] = None,
|
211
168
|
*,
|
212
|
-
|
213
|
-
|
169
|
+
request: Optional[models.GetPlaceRequest] = None,
|
170
|
+
fields: FieldMaskInput = DEFAULT_PLACE_FIELDS,
|
214
171
|
) -> Optional[models.Place]:
|
215
172
|
"""
|
216
173
|
Get details for a specific place.
|
217
174
|
Args:
|
218
175
|
place_id: The ID of the place to retrieve details for.
|
219
|
-
|
176
|
+
request: GetPlaceRequest object with place resource name.
|
177
|
+
fields: Optional field mask for response data.
|
220
178
|
Returns:
|
221
179
|
A models.Place object or None if not found.
|
222
180
|
Raises:
|
181
|
+
ValueError: If neither place_id nor request is provided.
|
223
182
|
BookalimoError: If the API request fails.
|
224
183
|
"""
|
184
|
+
request_params = build_get_place_request(place_id, request)
|
225
185
|
metadata = mask_header(fields)
|
186
|
+
|
226
187
|
try:
|
227
|
-
proto = await self.
|
228
|
-
request=
|
188
|
+
proto = await self.transport.get_place(
|
189
|
+
request=request_params,
|
229
190
|
metadata=metadata,
|
230
191
|
)
|
231
|
-
|
232
|
-
model = validate_proto_to_model(proto, GooglePlace)
|
233
|
-
adr_format = getattr(model, "adr_format_address", None)
|
234
|
-
addr = (
|
235
|
-
getattr(model, "formatted_address", None)
|
236
|
-
or getattr(model, "short_formatted_address", None)
|
237
|
-
or _strip_html(adr_format or "")
|
238
|
-
or ""
|
239
|
-
)
|
240
|
-
lat, lng = _get_lat_lng(model)
|
241
|
-
return models.Place(
|
242
|
-
formatted_address=addr,
|
243
|
-
lat=lat,
|
244
|
-
lng=lng,
|
245
|
-
place_type=_infer_place_type(model),
|
246
|
-
iata_code=None,
|
247
|
-
google_place=model,
|
248
|
-
)
|
192
|
+
return normalize_place_from_proto(proto)
|
249
193
|
except gexc.NotFound:
|
250
194
|
return None
|
251
195
|
except gexc.InvalidArgument as e:
|
@@ -256,3 +200,90 @@ class AsyncGooglePlaces:
|
|
256
200
|
msg = f"Google Places Get Place failed: {fmt_exc(e)}"
|
257
201
|
logger.error(msg)
|
258
202
|
raise BookalimoError(msg) from e
|
203
|
+
|
204
|
+
async def resolve_airport(
|
205
|
+
self,
|
206
|
+
query: Optional[str] = None,
|
207
|
+
place_id: Optional[str] = None,
|
208
|
+
places: Optional[list[models.Place]] = None,
|
209
|
+
max_distance_km: Optional[float] = 100,
|
210
|
+
max_results: Optional[int] = 5,
|
211
|
+
confidence_threshold: Optional[float] = 0.5,
|
212
|
+
text_weight: float = 0.5,
|
213
|
+
) -> list[models.ResolvedAirport]:
|
214
|
+
"""
|
215
|
+
Resolve airport candidates given either a natural language text query, a place_id, or a list of Places.
|
216
|
+
|
217
|
+
Args:
|
218
|
+
query: Text query for airport search (optional)
|
219
|
+
place_id: Google place ID for proximity matching (optional)
|
220
|
+
places: List of existing Place objects for proximity matching (optional)
|
221
|
+
max_distance_km: Maximum distance for proximity matching (default: 100km)
|
222
|
+
max_results: Maximum number of results to return (default: 5)
|
223
|
+
confidence_threshold: Minimum confidence threshold (default: 0.5)
|
224
|
+
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.
|
225
|
+
|
226
|
+
Rules:
|
227
|
+
- Provide at most one of {place_id, places}. (query may accompany either.)
|
228
|
+
- If nothing but query is given, search for places from the query.
|
229
|
+
- If place_id is given:
|
230
|
+
* Fetch the place.
|
231
|
+
* If no explicit query, derive it from the place's display name.
|
232
|
+
- If places is given:
|
233
|
+
* If len(places) == 0 and no query, error.
|
234
|
+
* If len(places) == 1 and no query, derive query from that place's display name.
|
235
|
+
* If len(places) > 1 and no query, error (need query to disambiguate).
|
236
|
+
- If nothing is provided, error.
|
237
|
+
- If max_distance_km is provided, it must be > 0.
|
238
|
+
|
239
|
+
Returns:
|
240
|
+
list[models.ResolvedAirport]
|
241
|
+
Raises:
|
242
|
+
ValueError on invalid inputs.
|
243
|
+
BookalimoError if underlying API requests fail.
|
244
|
+
"""
|
245
|
+
# Validate inputs
|
246
|
+
validate_resolve_airport_inputs(place_id, places, max_distance_km)
|
247
|
+
|
248
|
+
# Establish the authoritative places list
|
249
|
+
effective_places: list[models.Place]
|
250
|
+
|
251
|
+
if place_id is not None:
|
252
|
+
place = await self.get(place_id=place_id)
|
253
|
+
if place is None:
|
254
|
+
raise ValueError(f"Place with id {place_id!r} was not found.")
|
255
|
+
effective_places = [place]
|
256
|
+
|
257
|
+
elif places is not None:
|
258
|
+
if len(places) == 0 and (query is None or not str(query).strip()):
|
259
|
+
raise ValueError(
|
260
|
+
"Empty 'places' and no 'query' provided; nothing to resolve."
|
261
|
+
)
|
262
|
+
effective_places = places
|
263
|
+
|
264
|
+
else:
|
265
|
+
# Neither place_id nor places: fall back to query-driven search
|
266
|
+
if query is None or not str(query).strip():
|
267
|
+
raise ValueError("Either place_id, places, or query must be provided.")
|
268
|
+
effective_places = await self.search(
|
269
|
+
request=models.SearchTextRequest(
|
270
|
+
text_query=str(query).strip(),
|
271
|
+
max_result_count=5,
|
272
|
+
)
|
273
|
+
)
|
274
|
+
|
275
|
+
# Derive effective query
|
276
|
+
effective_query = derive_effective_query(query, effective_places)
|
277
|
+
|
278
|
+
google_places = [
|
279
|
+
p.google_place for p in effective_places if p.google_place is not None
|
280
|
+
]
|
281
|
+
|
282
|
+
return resolve_airport(
|
283
|
+
effective_query,
|
284
|
+
google_places,
|
285
|
+
max_distance_km,
|
286
|
+
max_results,
|
287
|
+
confidence_threshold,
|
288
|
+
text_weight,
|
289
|
+
)
|