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.
bookalimo/client.py CHANGED
@@ -59,7 +59,7 @@ class AsyncBookalimo:
59
59
  google_places_api_key="your-google-api-key"
60
60
  ) as client:
61
61
  # Find locations using Google Places
62
- places = await client.places.search_text("Empire State Building")
62
+ places = await client.places.search("Empire State Building")
63
63
 
64
64
  # Use in booking
65
65
  quote = await client.pricing.quote(
@@ -93,8 +93,7 @@ class AsyncBookalimo:
93
93
  transport: Custom transport instance (optional)
94
94
  google_places_api_key: Google Places API key for location services (optional)
95
95
  """
96
-
97
- transport_credentials = transport.credentials if transport else None
96
+ transport_credentials = transport._credentials if transport else None
98
97
 
99
98
  both_provided = all([transport_credentials, credentials])
100
99
  both_missing = not any([transport_credentials, credentials])
@@ -113,18 +112,21 @@ class AsyncBookalimo:
113
112
  stacklevel=2,
114
113
  )
115
114
 
116
- # Use whichever exists when we need to build a transport ourselves
117
115
  effective_credentials = (
118
- credentials if credentials is not None else transport_credentials
116
+ transport_credentials if transport_credentials is not None else credentials
119
117
  )
118
+
120
119
  if transport:
121
- transport.credentials = effective_credentials
122
- self._transport = transport or AsyncTransport(
123
- base_url=base_url,
124
- timeouts=timeouts,
125
- user_agent=user_agent,
126
- credentials=effective_credentials,
127
- )
120
+ if transport_credentials is None and credentials is not None:
121
+ transport.credentials = credentials
122
+ self._transport = transport
123
+ else:
124
+ self._transport = AsyncTransport(
125
+ base_url=base_url,
126
+ timeouts=timeouts,
127
+ user_agent=user_agent,
128
+ credentials=effective_credentials,
129
+ )
128
130
 
129
131
  # Initialize service instances
130
132
  self.reservations = AsyncReservationsService(self._transport)
@@ -149,7 +151,7 @@ class AsyncBookalimo:
149
151
  Auth priority is as follows:
150
152
  - provided api key in constructor
151
153
  - GOOGLE_PLACES_API_KEY environment variable
152
- - Google ADC - Except for Geocoding API.
154
+ - Google ADC - Not yet implemented.
153
155
  """
154
156
  if not _GOOGLE_PLACES_AVAILABLE:
155
157
  raise ImportError(
@@ -207,7 +209,7 @@ class Bookalimo:
207
209
  google_places_api_key="your-google-api-key"
208
210
  ) as client:
209
211
  # Find locations using Google Places
210
- places = client.places.search_text("Empire State Building")
212
+ places = client.places.search("Empire State Building")
211
213
 
212
214
  # Use in booking
213
215
  quote = client.pricing.quote(
@@ -241,20 +243,41 @@ class Bookalimo:
241
243
  transport: Custom transport instance (optional)
242
244
  google_places_api_key: Google Places API key for location services (optional)
243
245
  """
244
- if transport and transport.credentials is not None and credentials is not None:
246
+ transport_credentials = transport.credentials if transport else None
247
+
248
+ both_provided = all([transport_credentials, credentials])
249
+ both_missing = not any([transport_credentials, credentials])
250
+
251
+ if both_provided:
245
252
  warnings.warn(
246
253
  "Credentials provided in both transport and constructor. "
247
254
  "The transport credentials will be used.",
248
- UserWarning,
255
+ DuplicateCredentialsWarning,
249
256
  stacklevel=2,
250
257
  )
251
- self._transport = transport or SyncTransport(
252
- base_url=base_url,
253
- timeouts=timeouts,
254
- user_agent=user_agent,
255
- credentials=credentials,
258
+ elif both_missing:
259
+ warnings.warn(
260
+ "No credentials provided in transport or constructor; proceeding unauthenticated.",
261
+ MissingCredentialsWarning,
262
+ stacklevel=2,
263
+ )
264
+
265
+ effective_credentials = (
266
+ transport_credentials if transport_credentials is not None else credentials
256
267
  )
257
268
 
269
+ if transport:
270
+ if transport_credentials is None and credentials is not None:
271
+ transport.credentials = credentials
272
+ self._transport = transport
273
+ else:
274
+ self._transport = SyncTransport(
275
+ base_url=base_url,
276
+ timeouts=timeouts,
277
+ user_agent=user_agent,
278
+ credentials=effective_credentials,
279
+ )
280
+
258
281
  # Initialize service instances
259
282
  self.reservations = ReservationsService(self._transport)
260
283
  self.pricing = PricingService(self._transport)
@@ -278,7 +301,7 @@ class Bookalimo:
278
301
  Auth priority is as follows:
279
302
  - provided api key in constructor
280
303
  - GOOGLE_PLACES_API_KEY environment variable
281
- - Google ADC - Except for Geocoding API.
304
+ - Google ADC - Not yet implemented.
282
305
  """
283
306
  if not _GOOGLE_PLACES_AVAILABLE:
284
307
  raise ImportError(
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,29 @@
1
1
  from __future__ import annotations
2
2
 
3
- import re
4
3
  from os import getenv
5
4
  from types import TracebackType
6
- from typing import Any, Optional, TypeVar, cast
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 PlacesAsyncClient
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
- Fields,
23
- PlaceListFields,
24
- fmt_exc,
25
- mask_header,
16
+ create_search_text_request,
17
+ handle_autocomplete_impl_async,
18
+ handle_geocode_response,
19
+ handle_get_place_impl_async,
20
+ handle_resolve_airport_postprocessing,
21
+ handle_resolve_airport_preprocessing,
22
+ handle_search_impl_async,
23
+ prepare_geocode_params,
24
+ validate_autocomplete_inputs,
26
25
  )
27
- from .proto_adapter import validate_proto_to_model
26
+ from .transports import GoogleAsyncTransport
28
27
 
29
28
  logger = get_logger("places")
30
29
 
@@ -32,40 +31,9 @@ P = ParamSpec("P")
32
31
  R = TypeVar("R")
33
32
 
34
33
 
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
34
  class AsyncGooglePlaces:
67
35
  """
68
- Google Places API client for address validation, geocoding, and autocomplete.
36
+ Google Places API asynchronous client for address validation, geocoding, and autocomplete.
69
37
  Provides location resolution services that integrate seamlessly with
70
38
  Book-A-Limo location factory functions.
71
39
  """
@@ -84,15 +52,10 @@ class AsyncGooglePlaces:
84
52
  http_client: Optional `httpx.AsyncClient` instance.
85
53
  """
86
54
  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
- )
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 = GoogleAsyncTransport(self.api_key, client)
96
59
 
97
60
  async def __aenter__(self) -> AsyncGooglePlaces:
98
61
  return self
@@ -108,151 +71,173 @@ class AsyncGooglePlaces:
108
71
  async def aclose(self) -> None:
109
72
  """Close underlying transports safely."""
110
73
  try:
111
- await self.client.transport.close()
74
+ await self.transport.close()
112
75
  finally:
113
76
  await self.http_client.aclose()
114
77
 
115
78
  async def autocomplete(
116
- self, request: models.AutocompletePlacesRequest, **kwargs: Any
79
+ self,
80
+ input: Optional[str] = None,
81
+ *,
82
+ request: Optional[models.AutocompletePlacesRequest] = None,
117
83
  ) -> models.AutocompletePlacesResponse:
118
84
  """
119
85
  Get autocomplete suggestions for a location query.
120
86
  Args:
87
+ input: The text string on which to search.
121
88
  request: AutocompletePlacesRequest object.
122
- **kwargs: Additional parameters for the Google Places Autocomplete API.
123
89
  Returns:
124
90
  `AutocompletePlacesResponse` object.
91
+ Note:
92
+ If both input and request are provided, request will be used.
125
93
  Raises:
94
+ ValueError: If neither input nor request is provided, or if both are provided.
126
95
  BookalimoError: If the API request fails.
127
96
  """
128
- try:
129
- proto = await self.client.autocomplete_places(
130
- request=request.model_dump(), **kwargs
131
- )
132
- return validate_proto_to_model(proto, models.AutocompletePlacesResponse)
133
- except gexc.GoogleAPICallError as e:
134
- msg = f"Google Places Autocomplete failed: {fmt_exc(e)}"
135
- logger.error(msg)
136
- raise BookalimoError(msg) from e
97
+ request = validate_autocomplete_inputs(input, request)
98
+ return await handle_autocomplete_impl_async(
99
+ lambda req: self.transport.autocomplete_places(request=req),
100
+ request,
101
+ )
137
102
 
138
103
  async def geocode(self, request: models.GeocodingRequest) -> dict[str, Any]:
139
- try:
140
- r = await self.http_client.get(
141
- "https://maps.googleapis.com/maps/api/geocode/json",
142
- params=request.to_query_params(),
143
- )
144
- r.raise_for_status()
145
- return cast(dict[str, Any], r.json())
146
- except httpx.HTTPError as e:
147
- msg = f"HTTP geocoding failed: {fmt_exc(e)}"
148
- logger.error(msg)
149
- 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 = await self.http_client.get(
107
+ "https://maps.googleapis.com/maps/api/geocode/json",
108
+ params=params,
109
+ )
110
+ return handle_geocode_response(r)
150
111
 
151
112
  async def search(
152
113
  self,
153
- query: str,
114
+ query: Optional[str] = None,
154
115
  *,
155
- fields: PlaceListFields = DEFAULT_PLACE_LIST_FIELDS,
116
+ request: Optional[models.SearchTextRequest] = None,
117
+ fields: FieldMaskInput = DEFAULT_PLACE_FIELDS,
156
118
  **kwargs: Any,
157
119
  ) -> list[models.Place]:
158
120
  """
159
- Search for places using a text query.
121
+ Search for places using a text query or SearchTextRequest.
160
122
  Args:
161
- 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.
162
126
  **kwargs: Additional parameters for the Text Search API.
163
127
  Returns:
164
128
  list[models.Place]
165
129
  Raises:
166
130
  BookalimoError: If the API request fails.
131
+ ValueError: If neither query nor request is provided, or if both are provided.
167
132
  """
168
- metadata = mask_header(fields)
169
- try:
170
- protos = await self.client.search_text(
171
- request={"text_query": query, **kwargs},
172
- metadata=metadata,
173
- )
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
198
- except gexc.InvalidArgument as e:
199
- # Often caused by missing/invalid field mask
200
- msg = f"Google Places Text Search invalid argument: {fmt_exc(e)}"
201
- logger.error(msg)
202
- raise BookalimoError(msg) from e
203
- except gexc.GoogleAPICallError as e:
204
- msg = f"Google Places Text Search failed: {fmt_exc(e)}"
205
- logger.error(msg)
206
- raise BookalimoError(msg) from e
133
+ return await handle_search_impl_async(
134
+ lambda req, meta: self.transport.search_text(request=req, metadata=meta),
135
+ query,
136
+ request,
137
+ fields,
138
+ **kwargs,
139
+ )
207
140
 
208
141
  async def get(
209
142
  self,
210
- place_id: models.GetPlaceRequest,
143
+ place_id: Optional[str] = None,
211
144
  *,
212
- fields: Fields = DEFAULT_PLACE_FIELDS,
213
- **kwargs: Any,
145
+ request: Optional[models.GetPlaceRequest] = None,
146
+ fields: FieldMaskInput = DEFAULT_PLACE_FIELDS,
214
147
  ) -> Optional[models.Place]:
215
148
  """
216
149
  Get details for a specific place.
217
150
  Args:
218
151
  place_id: The ID of the place to retrieve details for.
219
- **kwargs: Additional parameters for the Get Place API.
152
+ request: GetPlaceRequest object with place resource name.
153
+ fields: Optional field mask for response data.
220
154
  Returns:
221
155
  A models.Place object or None if not found.
222
156
  Raises:
157
+ ValueError: If neither place_id nor request is provided.
223
158
  BookalimoError: If the API request fails.
224
159
  """
225
- metadata = mask_header(fields)
226
- try:
227
- proto = await self.client.get_place(
228
- request={"name": f"places/{place_id}", **kwargs},
229
- metadata=metadata,
230
- )
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 ""
160
+ return await handle_get_place_impl_async(
161
+ lambda req, meta: self.transport.get_place(request=req, metadata=meta),
162
+ place_id,
163
+ request,
164
+ fields,
165
+ )
166
+
167
+ async 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
239
214
  )
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,
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 = await 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 = await self.search(
227
+ request=create_search_text_request(
228
+ query=str(preprocessed_query).strip(),
229
+ region_code=country_code,
230
+ )
248
231
  )
249
- except gexc.NotFound:
250
- return None
251
- except gexc.InvalidArgument as e:
252
- msg = f"Google Places Get Place invalid argument: {fmt_exc(e)}"
253
- logger.error(msg)
254
- raise BookalimoError(msg) from e
255
- except gexc.GoogleAPICallError as e:
256
- msg = f"Google Places Get Place failed: {fmt_exc(e)}"
257
- logger.error(msg)
258
- 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
+ )