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.
- bookalimo/__init__.py +17 -24
- bookalimo/_version.py +9 -0
- bookalimo/client.py +310 -0
- bookalimo/config.py +16 -0
- bookalimo/exceptions.py +115 -5
- bookalimo/integrations/__init__.py +1 -0
- bookalimo/integrations/google_places/__init__.py +31 -0
- bookalimo/integrations/google_places/client_async.py +289 -0
- bookalimo/integrations/google_places/client_sync.py +287 -0
- bookalimo/integrations/google_places/common.py +231 -0
- bookalimo/integrations/google_places/proto_adapter.py +224 -0
- bookalimo/integrations/google_places/resolve_airport.py +397 -0
- bookalimo/integrations/google_places/transports.py +98 -0
- bookalimo/{_logging.py → logging.py} +45 -42
- bookalimo/schemas/__init__.py +103 -0
- bookalimo/schemas/base.py +56 -0
- bookalimo/{models.py → schemas/booking.py} +88 -100
- bookalimo/schemas/places/__init__.py +62 -0
- bookalimo/schemas/places/common.py +351 -0
- bookalimo/schemas/places/field_mask.py +221 -0
- bookalimo/schemas/places/google.py +883 -0
- bookalimo/schemas/places/place.py +334 -0
- bookalimo/services/__init__.py +11 -0
- bookalimo/services/pricing.py +191 -0
- bookalimo/services/reservations.py +227 -0
- bookalimo/transport/__init__.py +7 -0
- bookalimo/transport/auth.py +41 -0
- bookalimo/transport/base.py +44 -0
- bookalimo/transport/httpx_async.py +230 -0
- bookalimo/transport/httpx_sync.py +230 -0
- bookalimo/transport/retry.py +102 -0
- bookalimo/transport/utils.py +59 -0
- bookalimo-1.0.1.dist-info/METADATA +370 -0
- bookalimo-1.0.1.dist-info/RECORD +38 -0
- bookalimo-1.0.1.dist-info/licenses/LICENSE +21 -0
- bookalimo/_client.py +0 -420
- bookalimo/wrapper.py +0 -444
- bookalimo-0.1.5.dist-info/METADATA +0 -392
- bookalimo-0.1.5.dist-info/RECORD +0 -12
- bookalimo-0.1.5.dist-info/licenses/LICENSE +0 -0
- {bookalimo-0.1.5.dist-info → bookalimo-1.0.1.dist-info}/WHEEL +0 -0
- {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
|
+
)
|