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.
@@ -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
- DEFAULT_PLACE_LIST_FIELDS,
22
- PlaceListFields,
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
- if client:
87
- self.client = client
88
- else:
89
- api_key = api_key or getenv("GOOGLE_PLACES_API_KEY")
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[BaseException],
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.client.transport.close()
78
+ self.transport.close()
111
79
  finally:
112
80
  self.http_client.close()
113
81
 
114
82
  def autocomplete(
115
- self, request: models.AutocompletePlacesRequest, **kwargs: Any
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.client.autocomplete_places(
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
- fields: PlaceListFields = DEFAULT_PLACE_LIST_FIELDS,
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: The text query to search for.
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
- metadata = mask_header(fields)
144
+ request_params = build_search_request_params(query, request, **kwargs)
145
+ metadata = mask_header(fields, prefix="places")
146
+
168
147
  try:
169
- protos = self.client.search_text(
170
- request={"text_query": query, **kwargs},
148
+ protos = self.transport.search_text(
149
+ request=request_params,
171
150
  metadata=metadata,
172
151
  )
173
- pydantic_models = [
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: models.GetPlaceRequest,
165
+ place_id: Optional[str] = None,
210
166
  *,
211
- fields: Sequence[str] | str = DEFAULT_PLACE_FIELDS,
212
- **kwargs: Any,
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
- **kwargs: Additional parameters for the Get Place API.
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.client.get_place(
227
- request={"name": f"places/{place_id}", **kwargs},
186
+ proto = self.transport.get_place(
187
+ request=request_params,
228
188
  metadata=metadata,
229
189
  )
230
- # Convert proto to GooglePlace first, then process like search
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
+ )