bookalimo 0.1.5__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.
Files changed (42) 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 +289 -0
  9. bookalimo/integrations/google_places/client_sync.py +287 -0
  10. bookalimo/integrations/google_places/common.py +231 -0
  11. bookalimo/integrations/google_places/proto_adapter.py +224 -0
  12. bookalimo/integrations/google_places/resolve_airport.py +397 -0
  13. bookalimo/integrations/google_places/transports.py +98 -0
  14. bookalimo/{_logging.py → logging.py} +45 -42
  15. bookalimo/schemas/__init__.py +103 -0
  16. bookalimo/schemas/base.py +56 -0
  17. bookalimo/{models.py → schemas/booking.py} +88 -100
  18. bookalimo/schemas/places/__init__.py +62 -0
  19. bookalimo/schemas/places/common.py +351 -0
  20. bookalimo/schemas/places/field_mask.py +221 -0
  21. bookalimo/schemas/places/google.py +883 -0
  22. bookalimo/schemas/places/place.py +334 -0
  23. bookalimo/services/__init__.py +11 -0
  24. bookalimo/services/pricing.py +191 -0
  25. bookalimo/services/reservations.py +227 -0
  26. bookalimo/transport/__init__.py +7 -0
  27. bookalimo/transport/auth.py +41 -0
  28. bookalimo/transport/base.py +44 -0
  29. bookalimo/transport/httpx_async.py +230 -0
  30. bookalimo/transport/httpx_sync.py +230 -0
  31. bookalimo/transport/retry.py +102 -0
  32. bookalimo/transport/utils.py +59 -0
  33. bookalimo-1.0.1.dist-info/METADATA +370 -0
  34. bookalimo-1.0.1.dist-info/RECORD +38 -0
  35. bookalimo-1.0.1.dist-info/licenses/LICENSE +21 -0
  36. bookalimo/_client.py +0 -420
  37. bookalimo/wrapper.py +0 -444
  38. bookalimo-0.1.5.dist-info/METADATA +0 -392
  39. bookalimo-0.1.5.dist-info/RECORD +0 -12
  40. bookalimo-0.1.5.dist-info/licenses/LICENSE +0 -0
  41. {bookalimo-0.1.5.dist-info → bookalimo-1.0.1.dist-info}/WHEEL +0 -0
  42. {bookalimo-0.1.5.dist-info → bookalimo-1.0.1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,227 @@
1
+ """Reservations service for listing, getting, editing, and booking reservations."""
2
+
3
+ from typing import Any, Optional
4
+
5
+ from ..exceptions import BookalimoRequestError
6
+ from ..schemas.booking import (
7
+ BookRequest,
8
+ BookResponse,
9
+ CreditCard,
10
+ EditableReservationRequest,
11
+ EditReservationResponse,
12
+ GetReservationRequest,
13
+ GetReservationResponse,
14
+ ListReservationsRequest,
15
+ ListReservationsResponse,
16
+ )
17
+ from ..transport.base import AsyncBaseTransport, BaseTransport
18
+
19
+
20
+ class AsyncReservationsService:
21
+ """Async reservations service."""
22
+
23
+ def __init__(self, transport: AsyncBaseTransport):
24
+ self._transport = transport
25
+
26
+ async def list(self, is_archive: bool = False) -> ListReservationsResponse:
27
+ """
28
+ List reservations for the user.
29
+
30
+ Args:
31
+ is_archive: If True, fetch archived reservations
32
+
33
+ Returns:
34
+ ListReservationsResponse with reservations list
35
+ """
36
+ request = ListReservationsRequest(is_archive=is_archive)
37
+ return await self._transport.post(
38
+ "/booking/reservation/list/", request, ListReservationsResponse
39
+ )
40
+
41
+ async def get(self, confirmation: str) -> GetReservationResponse:
42
+ """
43
+ Get detailed reservation information.
44
+
45
+ Args:
46
+ confirmation: Confirmation number
47
+
48
+ Returns:
49
+ GetReservationResponse with reservation details
50
+ """
51
+ request = GetReservationRequest(confirmation=confirmation)
52
+ return await self._transport.post(
53
+ "/booking/reservation/get/", request, GetReservationResponse
54
+ )
55
+
56
+ async def edit(
57
+ self, confirmation: str, *, is_cancel: bool = False, **changes: Any
58
+ ) -> EditReservationResponse:
59
+ """
60
+ Edit or cancel a reservation.
61
+
62
+ Args:
63
+ confirmation: Confirmation number
64
+ is_cancel: True to cancel the reservation
65
+ **changes: Fields to change (rate_type, pickup_date, pickup_time,
66
+ stops, passengers, luggage, pets, car_seats, boosters,
67
+ infants, other)
68
+
69
+ Returns:
70
+ EditReservationResponse
71
+ """
72
+ request_data = {
73
+ "confirmation": confirmation,
74
+ "is_cancel_request": is_cancel,
75
+ }
76
+
77
+ # Add changes if not canceling
78
+ if not is_cancel:
79
+ for key, value in changes.items():
80
+ if value is not None:
81
+ request_data[key] = value
82
+
83
+ request = EditableReservationRequest.model_validate(request_data)
84
+ return await self._transport.post(
85
+ "/booking/edit/", request, EditReservationResponse
86
+ )
87
+
88
+ async def book(
89
+ self,
90
+ token: str,
91
+ *,
92
+ method: Optional[str] = None,
93
+ credit_card: Optional[CreditCard] = None,
94
+ promo: Optional[str] = None,
95
+ ) -> BookResponse:
96
+ """
97
+ Book a reservation.
98
+
99
+ Args:
100
+ token: Session token from pricing.quote() or pricing.update_details()
101
+ method: 'charge' for charge accounts, None for credit card
102
+ credit_card: Credit card information (required if method is not 'charge')
103
+ promo: Optional promo code
104
+
105
+ Returns:
106
+ BookResponse with reservation_id
107
+ """
108
+ request_data: dict[str, Any] = {"token": token}
109
+
110
+ if promo:
111
+ request_data["promo"] = promo
112
+
113
+ if method == "charge":
114
+ request_data["method"] = "charge"
115
+ elif credit_card:
116
+ request_data["credit_card"] = credit_card
117
+ else:
118
+ raise BookalimoRequestError(
119
+ "Either method='charge' or credit_card must be provided"
120
+ )
121
+
122
+ request = BookRequest(**request_data)
123
+ return await self._transport.post("/booking/book/", request, BookResponse)
124
+
125
+
126
+ class ReservationsService:
127
+ """Sync reservations service."""
128
+
129
+ def __init__(self, transport: BaseTransport):
130
+ self._transport = transport
131
+
132
+ def list(self, is_archive: bool = False) -> ListReservationsResponse:
133
+ """
134
+ List reservations for the user.
135
+
136
+ Args:
137
+ is_archive: If True, fetch archived reservations
138
+
139
+ Returns:
140
+ ListReservationsResponse with reservations list
141
+ """
142
+ request = ListReservationsRequest(is_archive=is_archive)
143
+ return self._transport.post(
144
+ "/booking/reservation/list/", request, ListReservationsResponse
145
+ )
146
+
147
+ def get(self, confirmation: str) -> GetReservationResponse:
148
+ """
149
+ Get detailed reservation information.
150
+
151
+ Args:
152
+ confirmation: Confirmation number
153
+
154
+ Returns:
155
+ GetReservationResponse with reservation details
156
+ """
157
+ request = GetReservationRequest(confirmation=confirmation)
158
+ return self._transport.post(
159
+ "/booking/reservation/get/", request, GetReservationResponse
160
+ )
161
+
162
+ def edit(
163
+ self, confirmation: str, *, is_cancel: bool = False, **changes: Any
164
+ ) -> EditReservationResponse:
165
+ """
166
+ Edit or cancel a reservation.
167
+
168
+ Args:
169
+ confirmation: Confirmation number
170
+ is_cancel: True to cancel the reservation
171
+ **changes: Fields to change (rate_type, pickup_date, pickup_time,
172
+ stops, passengers, luggage, pets, car_seats, boosters,
173
+ infants, other)
174
+
175
+ Returns:
176
+ EditReservationResponse
177
+ """
178
+ request_data = {
179
+ "confirmation": confirmation,
180
+ "is_cancel_request": is_cancel,
181
+ }
182
+
183
+ # Add changes if not canceling
184
+ if not is_cancel:
185
+ for key, value in changes.items():
186
+ if value is not None:
187
+ request_data[key] = value
188
+
189
+ request = EditableReservationRequest.model_validate(request_data)
190
+ return self._transport.post("/booking/edit/", request, EditReservationResponse)
191
+
192
+ def book(
193
+ self,
194
+ token: str,
195
+ *,
196
+ method: Optional[str] = None,
197
+ credit_card: Optional[CreditCard] = None,
198
+ promo: Optional[str] = None,
199
+ ) -> BookResponse:
200
+ """
201
+ Book a reservation.
202
+
203
+ Args:
204
+ token: Session token from pricing.quote() or pricing.update_details()
205
+ method: 'charge' for charge accounts, None for credit card
206
+ credit_card: Credit card information (required if method is not 'charge')
207
+ promo: Optional promo code
208
+
209
+ Returns:
210
+ BookResponse with reservation_id
211
+ """
212
+ request_data: dict[str, Any] = {"token": token}
213
+
214
+ if promo:
215
+ request_data["promo"] = promo
216
+
217
+ if method == "charge":
218
+ request_data["method"] = "charge"
219
+ elif credit_card:
220
+ request_data["credit_card"] = credit_card
221
+ else:
222
+ raise BookalimoRequestError(
223
+ "Either method='charge' or credit_card must be provided"
224
+ )
225
+
226
+ request = BookRequest.model_validate(request_data)
227
+ return self._transport.post("/booking/book/", request, BookResponse)
@@ -0,0 +1,7 @@
1
+ """Transport layer for HTTP communication."""
2
+
3
+ from .base import BaseTransport
4
+ from .httpx_async import AsyncTransport
5
+ from .httpx_sync import SyncTransport
6
+
7
+ __all__ = ["BaseTransport", "AsyncTransport", "SyncTransport"]
@@ -0,0 +1,41 @@
1
+ """Authentication and credential handling."""
2
+
3
+ import hashlib
4
+ from typing import Any, Optional
5
+
6
+ from ..schemas.base import ApiModel
7
+
8
+
9
+ class Credentials(ApiModel):
10
+ """Authentication credentials for Book-A-Limo API."""
11
+
12
+ id: str
13
+ password_hash: str
14
+ is_customer: bool = False
15
+
16
+ @classmethod
17
+ def create_hash(cls, password: str, user_id: str) -> str:
18
+ """Create password hash as required by API: Sha256(Sha256(Password) + LowerCase(Id))"""
19
+ inner_hash = hashlib.sha256(password.encode()).hexdigest()
20
+ full_string = inner_hash + user_id.lower()
21
+ return hashlib.sha256(full_string.encode()).hexdigest()
22
+
23
+ @classmethod
24
+ def create(
25
+ cls, user_id: str, password: str, is_customer: bool = False
26
+ ) -> "Credentials":
27
+ """Create credentials with automatic password hashing."""
28
+ return cls(
29
+ id=user_id,
30
+ password_hash=cls.create_hash(password, user_id),
31
+ is_customer=is_customer,
32
+ )
33
+
34
+
35
+ def inject_credentials(
36
+ data: dict[str, Any], credentials: Optional[Credentials]
37
+ ) -> dict[str, Any]:
38
+ """Inject credentials into request data if provided."""
39
+ if credentials:
40
+ data["credentials"] = credentials.model_dump()
41
+ return data
@@ -0,0 +1,44 @@
1
+ """Base transport interface and shared utilities."""
2
+
3
+ from abc import ABC, abstractmethod
4
+ from typing import Any, TypeVar
5
+
6
+ from pydantic import BaseModel
7
+
8
+ T = TypeVar("T", bound=BaseModel)
9
+
10
+
11
+ class BaseTransport(ABC):
12
+ """Base transport interface for sync and async implementations."""
13
+
14
+ @abstractmethod
15
+ def post(self, path: str, model: BaseModel, response_model: type[T]) -> T:
16
+ """Make a POST request and return parsed response."""
17
+ ...
18
+
19
+ @abstractmethod
20
+ def close(self) -> None:
21
+ """Close the transport and clean up resources."""
22
+ ...
23
+
24
+ def prepare_data(self, data: BaseModel) -> dict[str, Any]:
25
+ """Prepare data for API requests by converting it to the appropriate format."""
26
+ return data.model_dump(mode="json", exclude_none=True)
27
+
28
+
29
+ class AsyncBaseTransport(ABC):
30
+ """Base async transport interface."""
31
+
32
+ @abstractmethod
33
+ async def post(self, path: str, model: BaseModel, response_model: type[T]) -> T:
34
+ """Make a POST request and return parsed response."""
35
+ ...
36
+
37
+ @abstractmethod
38
+ async def aclose(self) -> None:
39
+ """Close the transport and clean up resources."""
40
+ ...
41
+
42
+ def prepare_data(self, data: BaseModel) -> dict[str, Any]:
43
+ """Prepare data for API requests by converting it to the appropriate format."""
44
+ return data.model_dump(mode="json", exclude_none=True)
@@ -0,0 +1,230 @@
1
+ """Async HTTP transport using httpx."""
2
+
3
+ import logging
4
+ from time import perf_counter
5
+ from typing import Any, Optional, TypeVar, Union, overload
6
+ from uuid import uuid4
7
+
8
+ import httpx
9
+ from pydantic import BaseModel
10
+
11
+ from ..config import (
12
+ DEFAULT_BACKOFF,
13
+ DEFAULT_BASE_URL,
14
+ DEFAULT_RETRIES,
15
+ DEFAULT_TIMEOUTS,
16
+ DEFAULT_USER_AGENT,
17
+ )
18
+ from ..exceptions import (
19
+ BookalimoConnectionError,
20
+ BookalimoError,
21
+ BookalimoHTTPError,
22
+ BookalimoRequestError,
23
+ BookalimoTimeout,
24
+ )
25
+ from .auth import Credentials, inject_credentials
26
+ from .base import AsyncBaseTransport
27
+ from .retry import async_retry, should_retry_exception, should_retry_status
28
+ from .utils import handle_api_errors, handle_http_error
29
+
30
+ logger = logging.getLogger("bookalimo.transport")
31
+
32
+ T = TypeVar("T", bound=BaseModel)
33
+
34
+
35
+ class AsyncTransport(AsyncBaseTransport):
36
+ """Async HTTP transport using httpx."""
37
+
38
+ def __init__(
39
+ self,
40
+ base_url: str = DEFAULT_BASE_URL,
41
+ timeouts: Any = DEFAULT_TIMEOUTS,
42
+ user_agent: str = DEFAULT_USER_AGENT,
43
+ credentials: Optional[Credentials] = None,
44
+ client: Optional[httpx.AsyncClient] = None,
45
+ retries: int = DEFAULT_RETRIES,
46
+ backoff: float = DEFAULT_BACKOFF,
47
+ ):
48
+ self.base_url = base_url.rstrip("/")
49
+ self.credentials = credentials
50
+ self.retries = retries
51
+ self.backoff = backoff
52
+ self.headers = {
53
+ "content-type": "application/json",
54
+ "user-agent": user_agent,
55
+ }
56
+
57
+ # Create client if not provided
58
+ self._owns_client = client is None
59
+ self.client = client or httpx.AsyncClient(timeout=timeouts)
60
+
61
+ if logger.isEnabledFor(logging.DEBUG):
62
+ logger.debug(
63
+ "AsyncTransport initialized (base_url=%s, timeout=%s, user_agent=%s)",
64
+ self.base_url,
65
+ timeouts,
66
+ user_agent,
67
+ )
68
+
69
+ @overload
70
+ async def post(self, path: str, model: BaseModel) -> Any: ...
71
+ @overload
72
+ async def post(self, path: str, model: BaseModel, response_model: type[T]) -> T: ...
73
+
74
+ async def post(
75
+ self, path: str, model: BaseModel, response_model: Optional[type[T]] = None
76
+ ) -> Union[T, Any]:
77
+ """Make a POST request and return parsed response."""
78
+ # Prepare URL
79
+ path = path if path.startswith("/") else f"/{path}"
80
+ url = f"{self.base_url}{path}"
81
+
82
+ # Prepare data and inject credentials
83
+ data = self.prepare_data(model)
84
+ data = inject_credentials(data, self.credentials)
85
+
86
+ # Debug logging
87
+ req_id = None
88
+ start = 0.0
89
+ if logger.isEnabledFor(logging.DEBUG):
90
+ req_id = uuid4().hex[:8]
91
+ start = perf_counter()
92
+ body_keys = sorted(k for k in data.keys() if k != "credentials")
93
+ logger.debug(
94
+ "→ [%s] POST %s body_keys=%s",
95
+ req_id,
96
+ path,
97
+ body_keys,
98
+ )
99
+
100
+ try:
101
+ # Make request with retry logic
102
+ response = await async_retry(
103
+ lambda: self._make_request(url, data),
104
+ retries=self.retries,
105
+ backoff=self.backoff,
106
+ should_retry=lambda e: should_retry_exception(e)
107
+ or (
108
+ isinstance(e, httpx.HTTPStatusError)
109
+ and should_retry_status(e.response.status_code)
110
+ ),
111
+ )
112
+
113
+ # Handle HTTP errors
114
+ if response.status_code >= 400:
115
+ handle_http_error(response, req_id, path)
116
+
117
+ # Parse JSON
118
+ try:
119
+ json_data = response.json()
120
+ except ValueError as e:
121
+ if logger.isEnabledFor(logging.DEBUG):
122
+ logger.warning("× [%s] %s invalid JSON", req_id or "-", path)
123
+ preview = (
124
+ (response.text or "")[:256] if hasattr(response, "text") else None
125
+ )
126
+ raise BookalimoError(
127
+ f"Invalid JSON response: {preview}",
128
+ ) from e
129
+
130
+ # Handle API-level errors
131
+ handle_api_errors(json_data, req_id, path)
132
+
133
+ # Debug logging for success
134
+ if logger.isEnabledFor(logging.DEBUG):
135
+ dur_ms = (perf_counter() - start) * 1000.0
136
+ reqid_hdr = response.headers.get(
137
+ "x-request-id"
138
+ ) or response.headers.get("request-id")
139
+ content_len = (
140
+ len(response.content) if hasattr(response, "content") else None
141
+ )
142
+ logger.debug(
143
+ "← [%s] %s %s in %.1f ms len=%s reqid=%s",
144
+ req_id,
145
+ response.status_code,
146
+ path,
147
+ dur_ms,
148
+ content_len,
149
+ reqid_hdr,
150
+ )
151
+
152
+ # Parse and return response
153
+ return (
154
+ response_model.model_validate(json_data)
155
+ if response_model
156
+ else json_data
157
+ )
158
+
159
+ except httpx.TimeoutException:
160
+ if logger.isEnabledFor(logging.DEBUG):
161
+ logger.warning("× [%s] %s timeout", req_id or "-", path)
162
+ raise BookalimoTimeout("Request timeout") from None
163
+
164
+ except httpx.ConnectError:
165
+ if logger.isEnabledFor(logging.DEBUG):
166
+ logger.warning("× [%s] %s connection error", req_id or "-", path)
167
+ raise BookalimoConnectionError(
168
+ "Connection error - unable to reach Book-A-Limo API"
169
+ ) from None
170
+ except httpx.RequestError as e:
171
+ if logger.isEnabledFor(logging.DEBUG):
172
+ logger.warning(
173
+ "× [%s] %s request error: %s",
174
+ req_id or "-",
175
+ path,
176
+ e.__class__.__name__,
177
+ )
178
+ raise BookalimoRequestError(f"Request Error: {e}") from e
179
+
180
+ except httpx.HTTPStatusError as e:
181
+ if logger.isEnabledFor(logging.DEBUG):
182
+ logger.warning(
183
+ "× [%s] %s HTTP error: %s",
184
+ req_id or "-",
185
+ path,
186
+ e.__class__.__name__,
187
+ )
188
+ status_code = getattr(getattr(e, "response", None), "status_code", None)
189
+ raise BookalimoHTTPError(f"HTTP Error: {e}", status_code=status_code) from e
190
+
191
+ except (BookalimoError, BookalimoHTTPError):
192
+ # Already handled above
193
+ raise
194
+
195
+ except Exception as e:
196
+ if logger.isEnabledFor(logging.DEBUG):
197
+ logger.warning(
198
+ "× [%s] %s unexpected error: %s",
199
+ req_id or "-",
200
+ path,
201
+ e.__class__.__name__,
202
+ )
203
+ raise BookalimoError(f"Unexpected error: {str(e)}") from e
204
+
205
+ async def _make_request(self, url: str, data: dict[str, Any]) -> httpx.Response:
206
+ """Make the actual HTTP request."""
207
+ resp = await self.client.post(url, json=data, headers=self.headers)
208
+ if should_retry_status(resp.status_code):
209
+ # Construct an HTTPStatusError so async_retry can catch & decide.
210
+ raise httpx.HTTPStatusError(
211
+ message=f"Retryable HTTP status: {resp.status_code}",
212
+ request=resp.request,
213
+ response=resp,
214
+ )
215
+ return resp
216
+
217
+ async def aclose(self) -> None:
218
+ """Close the HTTP client if we own it."""
219
+ if self._owns_client and not self.client.is_closed:
220
+ await self.client.aclose()
221
+ if logger.isEnabledFor(logging.DEBUG):
222
+ logger.debug("AsyncTransport HTTP client closed")
223
+
224
+ async def __aenter__(self) -> "AsyncTransport":
225
+ """Async context manager entry."""
226
+ return self
227
+
228
+ async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
229
+ """Async context manager exit."""
230
+ await self.aclose()