bookalimo 0.1.4__py3-none-any.whl → 1.0.0__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.
Files changed (38) hide show
  1. bookalimo/__init__.py +17 -24
  2. bookalimo/_version.py +9 -0
  3. bookalimo/client.py +310 -0
  4. bookalimo/config.py +16 -0
  5. bookalimo/exceptions.py +115 -5
  6. bookalimo/integrations/__init__.py +1 -0
  7. bookalimo/integrations/google_places/__init__.py +31 -0
  8. bookalimo/integrations/google_places/client_async.py +258 -0
  9. bookalimo/integrations/google_places/client_sync.py +257 -0
  10. bookalimo/integrations/google_places/common.py +245 -0
  11. bookalimo/integrations/google_places/proto_adapter.py +224 -0
  12. bookalimo/{_logging.py → logging.py} +59 -62
  13. bookalimo/schemas/__init__.py +97 -0
  14. bookalimo/schemas/base.py +56 -0
  15. bookalimo/{models.py → schemas/booking.py} +88 -100
  16. bookalimo/schemas/places/__init__.py +37 -0
  17. bookalimo/schemas/places/common.py +198 -0
  18. bookalimo/schemas/places/google.py +596 -0
  19. bookalimo/schemas/places/place.py +337 -0
  20. bookalimo/services/__init__.py +11 -0
  21. bookalimo/services/pricing.py +191 -0
  22. bookalimo/services/reservations.py +227 -0
  23. bookalimo/transport/__init__.py +7 -0
  24. bookalimo/transport/auth.py +41 -0
  25. bookalimo/transport/base.py +44 -0
  26. bookalimo/transport/httpx_async.py +230 -0
  27. bookalimo/transport/httpx_sync.py +230 -0
  28. bookalimo/transport/retry.py +102 -0
  29. bookalimo/transport/utils.py +59 -0
  30. bookalimo-1.0.0.dist-info/METADATA +307 -0
  31. bookalimo-1.0.0.dist-info/RECORD +35 -0
  32. bookalimo/_client.py +0 -420
  33. bookalimo/wrapper.py +0 -444
  34. bookalimo-0.1.4.dist-info/METADATA +0 -392
  35. bookalimo-0.1.4.dist-info/RECORD +0 -12
  36. {bookalimo-0.1.4.dist-info → bookalimo-1.0.0.dist-info}/WHEEL +0 -0
  37. {bookalimo-0.1.4.dist-info → bookalimo-1.0.0.dist-info}/licenses/LICENSE +0 -0
  38. {bookalimo-0.1.4.dist-info → bookalimo-1.0.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,258 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from os import getenv
5
+ from types import TracebackType
6
+ from typing import Any, Optional, TypeVar, cast
7
+
8
+ import httpx
9
+ from google.api_core import exceptions as gexc
10
+ from google.api_core.client_options import ClientOptions
11
+ from google.maps.places_v1 import PlacesAsyncClient
12
+ from typing_extensions import ParamSpec
13
+
14
+ from ...exceptions import BookalimoError
15
+ from ...logging import get_logger
16
+ from ...schemas.places import google as models
17
+ from ...schemas.places.place import Place as GooglePlace
18
+ from .common import (
19
+ ADDRESS_TYPES,
20
+ DEFAULT_PLACE_FIELDS,
21
+ DEFAULT_PLACE_LIST_FIELDS,
22
+ Fields,
23
+ PlaceListFields,
24
+ fmt_exc,
25
+ mask_header,
26
+ )
27
+ from .proto_adapter import validate_proto_to_model
28
+
29
+ logger = get_logger("places")
30
+
31
+ P = ParamSpec("P")
32
+ R = TypeVar("R")
33
+
34
+
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
+ class AsyncGooglePlaces:
67
+ """
68
+ Google Places API client for address validation, geocoding, and autocomplete.
69
+ Provides location resolution services that integrate seamlessly with
70
+ Book-A-Limo location factory functions.
71
+ """
72
+
73
+ def __init__(
74
+ self,
75
+ api_key: Optional[str] = None,
76
+ client: Optional[PlacesAsyncClient] = None,
77
+ http_client: Optional[httpx.AsyncClient] = None,
78
+ ):
79
+ """
80
+ Initialize Google Places client.
81
+ Args:
82
+ api_key: Google Places API key. If not provided, it will be read from the GOOGLE_PLACES_API_KEY environment variable.
83
+ client: Optional `PlacesAsyncClient` instance.
84
+ http_client: Optional `httpx.AsyncClient` instance.
85
+ """
86
+ 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
+ )
96
+
97
+ async def __aenter__(self) -> AsyncGooglePlaces:
98
+ return self
99
+
100
+ async def __aexit__(
101
+ self,
102
+ exc_type: Optional[type[BaseException]],
103
+ exc_val: Optional[BaseException],
104
+ exc_tb: Optional[TracebackType],
105
+ ) -> None:
106
+ await self.aclose()
107
+
108
+ async def aclose(self) -> None:
109
+ """Close underlying transports safely."""
110
+ try:
111
+ await self.client.transport.close()
112
+ finally:
113
+ await self.http_client.aclose()
114
+
115
+ async def autocomplete(
116
+ self, request: models.AutocompletePlacesRequest, **kwargs: Any
117
+ ) -> models.AutocompletePlacesResponse:
118
+ """
119
+ Get autocomplete suggestions for a location query.
120
+ Args:
121
+ request: AutocompletePlacesRequest object.
122
+ **kwargs: Additional parameters for the Google Places Autocomplete API.
123
+ Returns:
124
+ `AutocompletePlacesResponse` object.
125
+ Raises:
126
+ BookalimoError: If the API request fails.
127
+ """
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
137
+
138
+ 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
150
+
151
+ async def search(
152
+ self,
153
+ query: str,
154
+ *,
155
+ fields: PlaceListFields = DEFAULT_PLACE_LIST_FIELDS,
156
+ **kwargs: Any,
157
+ ) -> list[models.Place]:
158
+ """
159
+ Search for places using a text query.
160
+ Args:
161
+ query: The text query to search for.
162
+ **kwargs: Additional parameters for the Text Search API.
163
+ Returns:
164
+ list[models.Place]
165
+ Raises:
166
+ BookalimoError: If the API request fails.
167
+ """
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
207
+
208
+ async def get(
209
+ self,
210
+ place_id: models.GetPlaceRequest,
211
+ *,
212
+ fields: Fields = DEFAULT_PLACE_FIELDS,
213
+ **kwargs: Any,
214
+ ) -> Optional[models.Place]:
215
+ """
216
+ Get details for a specific place.
217
+ Args:
218
+ place_id: The ID of the place to retrieve details for.
219
+ **kwargs: Additional parameters for the Get Place API.
220
+ Returns:
221
+ A models.Place object or None if not found.
222
+ Raises:
223
+ BookalimoError: If the API request fails.
224
+ """
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 ""
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
+ )
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
@@ -0,0 +1,257 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from collections.abc import Sequence
5
+ from os import getenv
6
+ from typing import Any, Optional, TypeVar, cast
7
+
8
+ import httpx
9
+ from google.api_core import exceptions as gexc
10
+ from google.api_core.client_options import ClientOptions
11
+ from google.maps.places_v1 import PlacesClient
12
+ from typing_extensions import ParamSpec
13
+
14
+ from ...exceptions import BookalimoError
15
+ from ...logging import get_logger
16
+ from ...schemas.places import google as models
17
+ from ...schemas.places.place import Place as GooglePlace
18
+ from .common import (
19
+ ADDRESS_TYPES,
20
+ DEFAULT_PLACE_FIELDS,
21
+ DEFAULT_PLACE_LIST_FIELDS,
22
+ PlaceListFields,
23
+ fmt_exc,
24
+ mask_header,
25
+ )
26
+ from .proto_adapter import validate_proto_to_model
27
+
28
+ logger = get_logger("places")
29
+
30
+ P = ParamSpec("P")
31
+ R = TypeVar("R")
32
+
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
+ class GooglePlaces:
66
+ """
67
+ Google Places API synchronous client for address validation, geocoding, and autocomplete.
68
+ Provides location resolution services that integrate seamlessly with
69
+ Book-A-Limo location factory functions.
70
+ """
71
+
72
+ def __init__(
73
+ self,
74
+ api_key: Optional[str] = None,
75
+ client: Optional[PlacesClient] = None,
76
+ http_client: Optional[httpx.Client] = None,
77
+ ):
78
+ """
79
+ Initialize Google Places client.
80
+ Args:
81
+ api_key: Google Places API key. If not provided, it will be read from the GOOGLE_PLACES_API_KEY environment variable.
82
+ client: Optional `PlacesClient` instance.
83
+ http_client: Optional `httpx.Client` instance.
84
+ """
85
+ 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
+ )
95
+
96
+ def __enter__(self) -> GooglePlaces:
97
+ return self
98
+
99
+ def __exit__(
100
+ self,
101
+ exc_type: Optional[type[BaseException]],
102
+ exc_val: Optional[BaseException],
103
+ exc_tb: Optional[BaseException],
104
+ ) -> None:
105
+ self.close()
106
+
107
+ def close(self) -> None:
108
+ """Close underlying transports safely."""
109
+ try:
110
+ self.client.transport.close()
111
+ finally:
112
+ self.http_client.close()
113
+
114
+ def autocomplete(
115
+ self, request: models.AutocompletePlacesRequest, **kwargs: Any
116
+ ) -> models.AutocompletePlacesResponse:
117
+ """
118
+ Get autocomplete suggestions for a location query.
119
+ Args:
120
+ request: AutocompletePlacesRequest object.
121
+ **kwargs: Additional parameters for the Google Places Autocomplete API.
122
+ Returns:
123
+ `AutocompletePlacesResponse` object.
124
+ Raises:
125
+ BookalimoError: If the API request fails.
126
+ """
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
136
+
137
+ 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
149
+
150
+ def search(
151
+ self,
152
+ query: str,
153
+ *,
154
+ fields: PlaceListFields = DEFAULT_PLACE_LIST_FIELDS,
155
+ **kwargs: Any,
156
+ ) -> list[models.Place]:
157
+ """
158
+ Search for places using a text query.
159
+ Args:
160
+ query: The text query to search for.
161
+ **kwargs: Additional parameters for the Text Search API.
162
+ Returns:
163
+ list[models.Place]
164
+ Raises:
165
+ BookalimoError: If the API request fails.
166
+ """
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
206
+
207
+ def get(
208
+ self,
209
+ place_id: models.GetPlaceRequest,
210
+ *,
211
+ fields: Sequence[str] | str = DEFAULT_PLACE_FIELDS,
212
+ **kwargs: Any,
213
+ ) -> Optional[models.Place]:
214
+ """
215
+ Get details for a specific place.
216
+ Args:
217
+ place_id: The ID of the place to retrieve details for.
218
+ **kwargs: Additional parameters for the Get Place API.
219
+ Returns:
220
+ A models.Place object or None if not found.
221
+ Raises:
222
+ BookalimoError: If the API request fails.
223
+ """
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 ""
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
+ )
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