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 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__} (+https://github.com/asparagusbeef/bookalimo-python)"
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
- DEFAULT_PLACE_LIST_FIELDS,
22
- Fields,
23
- PlaceListFields,
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
- if client:
88
- self.client = client
89
- else:
90
- api_key = api_key or getenv("GOOGLE_PLACES_API_KEY")
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.client.transport.close()
78
+ await self.transport.close()
112
79
  finally:
113
80
  await self.http_client.aclose()
114
81
 
115
82
  async def autocomplete(
116
- self, request: models.AutocompletePlacesRequest, **kwargs: Any
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.client.autocomplete_places(
130
- request=request.model_dump(), **kwargs
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
- fields: PlaceListFields = DEFAULT_PLACE_LIST_FIELDS,
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: The text query to search for.
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
- metadata = mask_header(fields)
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.client.search_text(
171
- request={"text_query": query, **kwargs},
150
+ protos = await self.transport.search_text(
151
+ request=request_params,
172
152
  metadata=metadata,
173
153
  )
174
- pydantic_models = [
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: models.GetPlaceRequest,
167
+ place_id: Optional[str] = None,
211
168
  *,
212
- fields: Fields = DEFAULT_PLACE_FIELDS,
213
- **kwargs: Any,
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
- **kwargs: Additional parameters for the Get Place API.
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.client.get_place(
228
- request={"name": f"places/{place_id}", **kwargs},
188
+ proto = await self.transport.get_place(
189
+ request=request_params,
229
190
  metadata=metadata,
230
191
  )
231
- # Convert proto to GooglePlace first, then process like search
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
+ )