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
@@ -1,29 +1,33 @@
|
|
1
1
|
from __future__ import annotations
|
2
2
|
|
3
|
-
import re
|
4
|
-
from collections.abc import Sequence
|
5
3
|
from os import getenv
|
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 PlacesClient
|
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
|
-
|
18
|
+
build_get_place_request,
|
19
|
+
build_search_request_params,
|
20
|
+
derive_effective_query,
|
23
21
|
fmt_exc,
|
24
22
|
mask_header,
|
23
|
+
normalize_place_from_proto,
|
24
|
+
normalize_search_results,
|
25
|
+
validate_autocomplete_inputs,
|
26
|
+
validate_resolve_airport_inputs,
|
25
27
|
)
|
26
28
|
from .proto_adapter import validate_proto_to_model
|
29
|
+
from .resolve_airport import resolve_airport
|
30
|
+
from .transports import GoogleSyncTransport
|
27
31
|
|
28
32
|
logger = get_logger("places")
|
29
33
|
|
@@ -31,37 +35,6 @@ P = ParamSpec("P")
|
|
31
35
|
R = TypeVar("R")
|
32
36
|
|
33
37
|
|
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
38
|
class GooglePlaces:
|
66
39
|
"""
|
67
40
|
Google Places API synchronous client for address validation, geocoding, and autocomplete.
|
@@ -83,15 +56,10 @@ class GooglePlaces:
|
|
83
56
|
http_client: Optional `httpx.Client` instance.
|
84
57
|
"""
|
85
58
|
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
|
-
)
|
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 = GoogleSyncTransport(api_key, client)
|
95
63
|
|
96
64
|
def __enter__(self) -> GooglePlaces:
|
97
65
|
return self
|
@@ -100,34 +68,39 @@ class GooglePlaces:
|
|
100
68
|
self,
|
101
69
|
exc_type: Optional[type[BaseException]],
|
102
70
|
exc_val: Optional[BaseException],
|
103
|
-
exc_tb: Optional[
|
71
|
+
exc_tb: Optional[TracebackType],
|
104
72
|
) -> None:
|
105
73
|
self.close()
|
106
74
|
|
107
75
|
def close(self) -> None:
|
108
76
|
"""Close underlying transports safely."""
|
109
77
|
try:
|
110
|
-
self.
|
78
|
+
self.transport.close()
|
111
79
|
finally:
|
112
80
|
self.http_client.close()
|
113
81
|
|
114
82
|
def autocomplete(
|
115
|
-
self,
|
83
|
+
self,
|
84
|
+
input: Optional[str] = None,
|
85
|
+
*,
|
86
|
+
request: Optional[models.AutocompletePlacesRequest] = None,
|
116
87
|
) -> models.AutocompletePlacesResponse:
|
117
88
|
"""
|
118
89
|
Get autocomplete suggestions for a location query.
|
119
90
|
Args:
|
91
|
+
input: The text string on which to search.
|
120
92
|
request: AutocompletePlacesRequest object.
|
121
|
-
**kwargs: Additional parameters for the Google Places Autocomplete API.
|
122
93
|
Returns:
|
123
94
|
`AutocompletePlacesResponse` object.
|
95
|
+
Note:
|
96
|
+
If both input and request are provided, request will be used.
|
124
97
|
Raises:
|
98
|
+
ValueError: If neither input nor request is provided, or if both are provided.
|
125
99
|
BookalimoError: If the API request fails.
|
126
100
|
"""
|
101
|
+
request = validate_autocomplete_inputs(input, request)
|
127
102
|
try:
|
128
|
-
proto = self.
|
129
|
-
request=request.model_dump(), **kwargs
|
130
|
-
)
|
103
|
+
proto = self.transport.autocomplete_places(request=request.model_dump())
|
131
104
|
return validate_proto_to_model(proto, models.AutocompletePlacesResponse)
|
132
105
|
except gexc.GoogleAPICallError as e:
|
133
106
|
msg = f"Google Places Autocomplete failed: {fmt_exc(e)}"
|
@@ -149,51 +122,34 @@ class GooglePlaces:
|
|
149
122
|
|
150
123
|
def search(
|
151
124
|
self,
|
152
|
-
query: str,
|
125
|
+
query: Optional[str] = None,
|
153
126
|
*,
|
154
|
-
|
127
|
+
request: Optional[models.SearchTextRequest] = None,
|
128
|
+
fields: FieldMaskInput = DEFAULT_PLACE_FIELDS,
|
155
129
|
**kwargs: Any,
|
156
130
|
) -> list[models.Place]:
|
157
131
|
"""
|
158
|
-
Search for places using a text query.
|
132
|
+
Search for places using a text query or SearchTextRequest.
|
159
133
|
Args:
|
160
|
-
query:
|
134
|
+
query: Simple text query to search for. Either query or request must be provided.
|
135
|
+
request: SearchTextRequest object with advanced search parameters. Either query or request must be provided.
|
136
|
+
fields: Field mask for response data.
|
161
137
|
**kwargs: Additional parameters for the Text Search API.
|
162
138
|
Returns:
|
163
139
|
list[models.Place]
|
164
140
|
Raises:
|
165
141
|
BookalimoError: If the API request fails.
|
142
|
+
ValueError: If neither query nor request is provided, or if both are provided.
|
166
143
|
"""
|
167
|
-
|
144
|
+
request_params = build_search_request_params(query, request, **kwargs)
|
145
|
+
metadata = mask_header(fields, prefix="places")
|
146
|
+
|
168
147
|
try:
|
169
|
-
protos = self.
|
170
|
-
request=
|
148
|
+
protos = self.transport.search_text(
|
149
|
+
request=request_params,
|
171
150
|
metadata=metadata,
|
172
151
|
)
|
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
|
152
|
+
return normalize_search_results(protos)
|
197
153
|
except gexc.InvalidArgument as e:
|
198
154
|
# Often caused by missing/invalid field mask
|
199
155
|
msg = f"Google Places Text Search invalid argument: {fmt_exc(e)}"
|
@@ -206,45 +162,32 @@ class GooglePlaces:
|
|
206
162
|
|
207
163
|
def get(
|
208
164
|
self,
|
209
|
-
place_id:
|
165
|
+
place_id: Optional[str] = None,
|
210
166
|
*,
|
211
|
-
|
212
|
-
|
167
|
+
request: Optional[models.GetPlaceRequest] = None,
|
168
|
+
fields: FieldMaskInput = DEFAULT_PLACE_FIELDS,
|
213
169
|
) -> Optional[models.Place]:
|
214
170
|
"""
|
215
171
|
Get details for a specific place.
|
216
172
|
Args:
|
217
173
|
place_id: The ID of the place to retrieve details for.
|
218
|
-
|
174
|
+
request: GetPlaceRequest object with place resource name.
|
175
|
+
fields: Optional field mask for response data.
|
219
176
|
Returns:
|
220
177
|
A models.Place object or None if not found.
|
221
178
|
Raises:
|
179
|
+
ValueError: If neither place_id nor request is provided.
|
222
180
|
BookalimoError: If the API request fails.
|
223
181
|
"""
|
182
|
+
request_params = build_get_place_request(place_id, request)
|
224
183
|
metadata = mask_header(fields)
|
184
|
+
|
225
185
|
try:
|
226
|
-
proto = self.
|
227
|
-
request=
|
186
|
+
proto = self.transport.get_place(
|
187
|
+
request=request_params,
|
228
188
|
metadata=metadata,
|
229
189
|
)
|
230
|
-
|
231
|
-
model = validate_proto_to_model(proto, GooglePlace)
|
232
|
-
adr_format = getattr(model, "adr_format_address", None)
|
233
|
-
addr = (
|
234
|
-
getattr(model, "formatted_address", None)
|
235
|
-
or getattr(model, "short_formatted_address", None)
|
236
|
-
or _strip_html(adr_format or "")
|
237
|
-
or ""
|
238
|
-
)
|
239
|
-
lat, lng = _get_lat_lng(model)
|
240
|
-
return models.Place(
|
241
|
-
formatted_address=addr,
|
242
|
-
lat=lat,
|
243
|
-
lng=lng,
|
244
|
-
place_type=_infer_place_type(model),
|
245
|
-
iata_code=None,
|
246
|
-
google_place=model,
|
247
|
-
)
|
190
|
+
return normalize_place_from_proto(proto)
|
248
191
|
except gexc.NotFound:
|
249
192
|
return None
|
250
193
|
except gexc.InvalidArgument as e:
|
@@ -255,3 +198,90 @@ class GooglePlaces:
|
|
255
198
|
msg = f"Google Places Get Place failed: {fmt_exc(e)}"
|
256
199
|
logger.error(msg)
|
257
200
|
raise BookalimoError(msg) from e
|
201
|
+
|
202
|
+
def resolve_airport(
|
203
|
+
self,
|
204
|
+
query: Optional[str] = None,
|
205
|
+
place_id: Optional[str] = None,
|
206
|
+
places: Optional[list[models.Place]] = None,
|
207
|
+
max_distance_km: Optional[float] = 100,
|
208
|
+
max_results: Optional[int] = 5,
|
209
|
+
confidence_threshold: Optional[float] = 0.5,
|
210
|
+
text_weight: float = 0.5,
|
211
|
+
) -> list[models.ResolvedAirport]:
|
212
|
+
"""
|
213
|
+
Resolve airport candidates given either a natural language text query, a place_id, or a list of Places.
|
214
|
+
|
215
|
+
Args:
|
216
|
+
query: Text query for airport search (optional)
|
217
|
+
place_id: Google place ID for proximity matching (optional)
|
218
|
+
places: List of existing Place objects for proximity matching (optional)
|
219
|
+
max_distance_km: Maximum distance for proximity matching (default: 100km)
|
220
|
+
max_results: Maximum number of results to return (default: 5)
|
221
|
+
confidence_threshold: Minimum confidence threshold (default: 0.5)
|
222
|
+
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.
|
223
|
+
|
224
|
+
Rules:
|
225
|
+
- Provide at most one of {place_id, places}. (query may accompany either.)
|
226
|
+
- If nothing but query is given, search for places from the query.
|
227
|
+
- If place_id is given:
|
228
|
+
* Fetch the place.
|
229
|
+
* If no explicit query, derive it from the place's display name.
|
230
|
+
- If places is given:
|
231
|
+
* If len(places) == 0 and no query, error.
|
232
|
+
* If len(places) == 1 and no query, derive query from that place's display name.
|
233
|
+
* If len(places) > 1 and no query, error (need query to disambiguate).
|
234
|
+
- If nothing is provided, error.
|
235
|
+
- If max_distance_km is provided, it must be > 0.
|
236
|
+
|
237
|
+
Returns:
|
238
|
+
list[models.ResolvedAirport]
|
239
|
+
Raises:
|
240
|
+
ValueError on invalid inputs.
|
241
|
+
BookalimoError if underlying API requests fail.
|
242
|
+
"""
|
243
|
+
# Validate inputs
|
244
|
+
validate_resolve_airport_inputs(place_id, places, max_distance_km)
|
245
|
+
|
246
|
+
# Establish the authoritative places list
|
247
|
+
effective_places: list[models.Place]
|
248
|
+
|
249
|
+
if place_id is not None:
|
250
|
+
place = self.get(place_id=place_id)
|
251
|
+
if place is None:
|
252
|
+
raise ValueError(f"Place with id {place_id!r} was not found.")
|
253
|
+
effective_places = [place]
|
254
|
+
|
255
|
+
elif places is not None:
|
256
|
+
if len(places) == 0 and (query is None or not str(query).strip()):
|
257
|
+
raise ValueError(
|
258
|
+
"Empty 'places' and no 'query' provided; nothing to resolve."
|
259
|
+
)
|
260
|
+
effective_places = places
|
261
|
+
|
262
|
+
else:
|
263
|
+
# Neither place_id nor places: fall back to query-driven search
|
264
|
+
if query is None or not str(query).strip():
|
265
|
+
raise ValueError("Either place_id, places, or query must be provided.")
|
266
|
+
effective_places = self.search(
|
267
|
+
request=models.SearchTextRequest(
|
268
|
+
text_query=str(query).strip(),
|
269
|
+
max_result_count=5,
|
270
|
+
)
|
271
|
+
)
|
272
|
+
|
273
|
+
# Derive effective query
|
274
|
+
effective_query = derive_effective_query(query, effective_places)
|
275
|
+
|
276
|
+
google_places = [
|
277
|
+
p.google_place for p in effective_places if p.google_place is not None
|
278
|
+
]
|
279
|
+
|
280
|
+
return resolve_airport(
|
281
|
+
effective_query,
|
282
|
+
google_places,
|
283
|
+
max_distance_km,
|
284
|
+
max_results,
|
285
|
+
confidence_threshold,
|
286
|
+
text_weight,
|
287
|
+
)
|