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.
@@ -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 typing import Any, Optional, TypeVar, cast
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
- DEFAULT_PLACE_LIST_FIELDS,
22
- PlaceListFields,
23
- fmt_exc,
24
- mask_header,
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 .proto_adapter import validate_proto_to_model
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
- 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
- )
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[BaseException],
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.client.transport.close()
74
+ self.transport.close()
111
75
  finally:
112
76
  self.http_client.close()
113
77
 
114
78
  def autocomplete(
115
- self, request: models.AutocompletePlacesRequest, **kwargs: Any
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
- try:
128
- proto = self.client.autocomplete_places(
129
- request=request.model_dump(), **kwargs
130
- )
131
- return validate_proto_to_model(proto, models.AutocompletePlacesResponse)
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
- try:
139
- r = self.http_client.get(
140
- "https://maps.googleapis.com/maps/api/geocode/json",
141
- params=request.to_query_params(),
142
- )
143
- r.raise_for_status()
144
- return cast(dict[str, Any], r.json())
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
- fields: PlaceListFields = DEFAULT_PLACE_LIST_FIELDS,
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: The text query to search for.
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
- metadata = mask_header(fields)
168
- try:
169
- protos = self.client.search_text(
170
- request={"text_query": query, **kwargs},
171
- metadata=metadata,
172
- )
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
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: models.GetPlaceRequest,
143
+ place_id: Optional[str] = None,
210
144
  *,
211
- fields: Sequence[str] | str = DEFAULT_PLACE_FIELDS,
212
- **kwargs: Any,
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
- **kwargs: Additional parameters for the Get Place API.
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
- metadata = mask_header(fields)
225
- try:
226
- proto = self.client.get_place(
227
- request={"name": f"places/{place_id}", **kwargs},
228
- metadata=metadata,
229
- )
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 ""
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
- 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,
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
- except gexc.NotFound:
249
- return None
250
- except gexc.InvalidArgument as e:
251
- msg = f"Google Places Get Place invalid argument: {fmt_exc(e)}"
252
- logger.error(msg)
253
- raise BookalimoError(msg) from e
254
- except gexc.GoogleAPICallError as e:
255
- msg = f"Google Places Get Place failed: {fmt_exc(e)}"
256
- logger.error(msg)
257
- raise BookalimoError(msg) from e
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
+ )