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,230 @@
1
+ """Sync 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 BaseTransport
27
+ from .retry import should_retry_exception, should_retry_status, sync_retry
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 SyncTransport(BaseTransport):
36
+ """Sync 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.Client] = 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.Client(timeout=timeouts)
60
+
61
+ if logger.isEnabledFor(logging.DEBUG):
62
+ logger.debug(
63
+ "SyncTransport initialized (base_url=%s, timeout=%s, user_agent=%s)",
64
+ self.base_url,
65
+ timeouts,
66
+ user_agent,
67
+ )
68
+
69
+ @overload
70
+ def post(self, path: str, model: BaseModel) -> Any: ...
71
+ @overload
72
+ def post(self, path: str, model: BaseModel, response_model: type[T]) -> T: ...
73
+
74
+ 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 = sync_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
+ def _make_request(self, url: str, data: dict[str, Any]) -> httpx.Response:
206
+ """Make the actual HTTP request."""
207
+ resp = self.client.post(url, json=data, headers=self.headers)
208
+ if should_retry_status(resp.status_code):
209
+ # Construct an HTTPStatusError so sync_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
+ def close(self) -> None:
218
+ """Close the HTTP client if we own it."""
219
+ if self._owns_client and not self.client.is_closed:
220
+ self.client.close()
221
+ if logger.isEnabledFor(logging.DEBUG):
222
+ logger.debug("SyncTransport HTTP client closed")
223
+
224
+ def __enter__(self) -> "SyncTransport":
225
+ """Context manager entry."""
226
+ return self
227
+
228
+ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
229
+ """Context manager exit."""
230
+ self.close()
@@ -0,0 +1,102 @@
1
+ """Retry logic and backoff utilities."""
2
+
3
+ import asyncio
4
+ import random
5
+ from collections.abc import Awaitable
6
+ from typing import Callable, TypeVar, Union
7
+
8
+ import httpx
9
+
10
+ from ..config import DEFAULT_STATUS_FORCELIST
11
+
12
+ T = TypeVar("T")
13
+
14
+
15
+ def should_retry_status(
16
+ status_code: int, status_forcelist: tuple[int, ...] = DEFAULT_STATUS_FORCELIST
17
+ ) -> bool:
18
+ """Check if a status code should trigger a retry."""
19
+ return status_code in status_forcelist
20
+
21
+
22
+ def should_retry_exception(exc: Exception) -> bool:
23
+ """Check if an exception should trigger a retry."""
24
+ return isinstance(
25
+ exc,
26
+ (
27
+ httpx.TimeoutException,
28
+ httpx.ConnectError,
29
+ httpx.ReadTimeout,
30
+ httpx.ConnectError,
31
+ ConnectionError,
32
+ ),
33
+ )
34
+
35
+
36
+ def calculate_backoff(attempt: int, base_backoff: float) -> float:
37
+ """Calculate exponential backoff with jitter."""
38
+ # Exponential backoff: base * (2 ^ attempt)
39
+ backoff = base_backoff * (2**attempt)
40
+ # Add jitter: ±25% randomization
41
+ jitter = backoff * 0.25 * (random.random() * 2 - 1) # nosec B311: non-crypto jitter
42
+ return float(max(0.1, backoff + jitter)) # Minimum 100ms
43
+
44
+
45
+ async def async_retry(
46
+ func: Callable[[], Awaitable[T]],
47
+ retries: int,
48
+ backoff: float,
49
+ should_retry: Callable[[Exception], bool],
50
+ ) -> T:
51
+ """Retry an async function with exponential backoff."""
52
+ attempt = 0
53
+ last_exc: Union[Exception, None] = None
54
+
55
+ while attempt <= retries:
56
+ try:
57
+ return await func()
58
+ except Exception as e:
59
+ last_exc = e
60
+ if attempt >= retries or not should_retry(e):
61
+ break
62
+
63
+ wait_time = calculate_backoff(attempt, backoff)
64
+ await asyncio.sleep(wait_time)
65
+ attempt += 1
66
+
67
+ if last_exc is not None:
68
+ raise last_exc
69
+ else:
70
+ raise RuntimeError("Last exception is None")
71
+
72
+
73
+ def sync_retry(
74
+ func: Callable[[], T],
75
+ retries: int,
76
+ backoff: float,
77
+ should_retry: Callable[[Exception], bool],
78
+ ) -> T:
79
+ """Retry a sync function with exponential backoff."""
80
+ import time
81
+
82
+ attempt = 0
83
+ last_exc: Union[Exception, None] = None
84
+
85
+ while attempt <= retries:
86
+ try:
87
+ return func()
88
+ except Exception as e:
89
+ last_exc = e
90
+ if attempt >= retries or not should_retry(e):
91
+ break
92
+
93
+ wait_time = calculate_backoff(attempt, backoff)
94
+ time.sleep(wait_time)
95
+ attempt += 1
96
+
97
+ # Re-raise the last exception
98
+ if last_exc is not None:
99
+ raise last_exc
100
+ else:
101
+ raise RuntimeError("Last exception is None")
102
+ raise last_exc
@@ -0,0 +1,59 @@
1
+ import logging
2
+ from typing import Any, Optional
3
+
4
+ import httpx
5
+
6
+ from ..exceptions import BookalimoError, BookalimoHTTPError, BookalimoTimeout
7
+
8
+ logger = logging.getLogger("bookalimo.transport")
9
+
10
+
11
+ def handle_api_errors(
12
+ json_data: Any,
13
+ req_id: Optional[str],
14
+ path: str,
15
+ ) -> None:
16
+ """Handle API-level errors in the response."""
17
+ if not isinstance(json_data, dict):
18
+ return
19
+
20
+ if json_data.get("error"):
21
+ if logger.isEnabledFor(logging.DEBUG):
22
+ logger.warning("× [%s] %s API error", req_id or "-", path)
23
+ raise BookalimoError(f"API Error: {json_data['error']}")
24
+
25
+ if "success" in json_data and not json_data["success"]:
26
+ msg = json_data.get("error", "Unknown API error")
27
+ if logger.isEnabledFor(logging.DEBUG):
28
+ logger.warning("× [%s] %s API error", req_id or "-", path)
29
+ raise BookalimoError(f"API Error: {msg}")
30
+
31
+
32
+ def handle_http_error(
33
+ response: httpx.Response, req_id: Optional[str], path: str
34
+ ) -> None:
35
+ """Handle HTTP status errors."""
36
+ status = response.status_code
37
+
38
+ if status == 408:
39
+ raise BookalimoTimeout(f"HTTP {status}: Request timeout")
40
+ elif status in (502, 503, 504):
41
+ raise BookalimoHTTPError(
42
+ f"HTTP {status}: Service unavailable", status_code=status
43
+ )
44
+
45
+ # Try to parse error payload
46
+ try:
47
+ payload = response.json()
48
+ except Exception as e:
49
+ text_preview = (response.text or "")[:256] if hasattr(response, "text") else ""
50
+ raise BookalimoHTTPError(
51
+ f"HTTP {status}: {text_preview}",
52
+ status_code=status,
53
+ ) from e
54
+
55
+ raise BookalimoHTTPError(
56
+ f"HTTP {status}",
57
+ status_code=status,
58
+ payload=payload if isinstance(payload, dict) else {"raw": payload},
59
+ )