bookalimo 0.1.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 ADDED
@@ -0,0 +1,28 @@
1
+ """
2
+ Book-A-Limo API Wrapper Package.
3
+ Provides a clean, typed interface to the Book-A-Limo API.
4
+ """
5
+
6
+ import importlib.metadata
7
+
8
+ from .wrapper import (
9
+ BookALimo,
10
+ create_address_location,
11
+ create_airport_location,
12
+ create_credentials,
13
+ create_credit_card,
14
+ create_passenger,
15
+ create_stop,
16
+ )
17
+
18
+ __all__ = [
19
+ "BookALimo",
20
+ "create_credentials",
21
+ "create_address_location",
22
+ "create_airport_location",
23
+ "create_stop",
24
+ "create_passenger",
25
+ "create_credit_card",
26
+ ]
27
+
28
+ __version__ = importlib.metadata.version(__package__ or __name__)
bookalimo/_client.py ADDED
@@ -0,0 +1,350 @@
1
+ """
2
+ Base HTTP client for Book-A-Limo API.
3
+ Handles authentication, headers, and common request/response patterns.
4
+ """
5
+
6
+ from enum import Enum
7
+ from functools import lru_cache
8
+ from typing import Any, Optional, cast
9
+
10
+ import httpx
11
+ from pydantic import BaseModel
12
+
13
+ from .models import (
14
+ BookRequest,
15
+ BookResponse,
16
+ Credentials,
17
+ DetailsRequest,
18
+ DetailsResponse,
19
+ EditableReservationRequest,
20
+ EditableReservationRequestAuthenticated,
21
+ EditReservationResponse,
22
+ GetReservationRequest,
23
+ GetReservationResponse,
24
+ ListReservationsRequest,
25
+ ListReservationsResponse,
26
+ PriceRequest,
27
+ PriceRequestAuthenticated,
28
+ PriceResponse,
29
+ )
30
+
31
+
32
+ @lru_cache(maxsize=1)
33
+ def get_version() -> str:
34
+ """Get the version of the BookALimo client."""
35
+ from bookalimo import __version__
36
+
37
+ return __version__
38
+
39
+
40
+ class BookALimoError(Exception):
41
+ """Base exception for Book-A-Limo API errors."""
42
+
43
+ def __init__(
44
+ self,
45
+ message: str,
46
+ status_code: Optional[int] = None,
47
+ response_data: Optional[dict[str, Any]] = None,
48
+ ):
49
+ super().__init__(message)
50
+ self.status_code = status_code
51
+ self.response_data = response_data or {}
52
+
53
+
54
+ class BookALimoClient:
55
+ """
56
+ Base HTTP client for Book-A-Limo API.
57
+ Centralizes headers, auth, request helpers, and error handling.
58
+ """
59
+
60
+ def __init__(
61
+ self,
62
+ client: httpx.AsyncClient,
63
+ credentials: Credentials,
64
+ user_agent: str = "bookalimo-python",
65
+ version: Optional[str] = None,
66
+ sandbox: bool = False,
67
+ base_url: str = "https://api.bookalimo.com",
68
+ base_url_sandbox: str = "https://sandbox.bookalimo.com",
69
+ http_timeout: float = 5.0,
70
+ ):
71
+ """Initialize the client with an HTTP client."""
72
+ self.client = client
73
+ version = version or get_version()
74
+ self.credentials = credentials
75
+ self.headers = {
76
+ "content-type": "application/json",
77
+ "user-agent": f"{user_agent}/{version}",
78
+ }
79
+ self.sandbox = sandbox
80
+ self.base_url = base_url
81
+ self.base_url_sandbox = base_url_sandbox
82
+ self.http_timeout = http_timeout
83
+
84
+ def _convert_model_to_api_dict(self, data: dict[str, Any]) -> dict[str, Any]:
85
+ """
86
+ Convert Python model field names to API field names.
87
+ Handles snake_case to camelCase and other API-specific naming.
88
+ """
89
+ converted: dict[str, Any] = {}
90
+
91
+ for key, value in data.items():
92
+ # Convert snake_case to camelCase for API
93
+ api_key = self._to_camel_case(key)
94
+
95
+ # Handle nested objects
96
+ if isinstance(value, dict):
97
+ converted[api_key] = self._convert_model_to_api_dict(value)
98
+ elif isinstance(value, list):
99
+ converted[api_key] = [
100
+ (
101
+ self._convert_model_to_api_dict(item)
102
+ if isinstance(item, dict)
103
+ else item
104
+ )
105
+ for item in value
106
+ ]
107
+ else:
108
+ converted[api_key] = value
109
+
110
+ return converted
111
+
112
+ def _to_camel_case(self, snake_str: str) -> str:
113
+ """Convert snake_case to camelCase."""
114
+ if "_" not in snake_str:
115
+ return snake_str
116
+
117
+ components = snake_str.split("_")
118
+ return components[0] + "".join(word.capitalize() for word in components[1:])
119
+
120
+ def _convert_api_to_model_dict(self, data: dict[str, Any]) -> dict[str, Any]:
121
+ """
122
+ Convert API response field names to Python model field names.
123
+ Handles camelCase to snake_case conversion.
124
+ """
125
+ converted: dict[str, Any] = {}
126
+
127
+ for key, value in data.items():
128
+ # Convert camelCase to snake_case
129
+ model_key = self._to_snake_case(key)
130
+
131
+ # Handle nested objects
132
+ if isinstance(value, dict):
133
+ converted[model_key] = self._convert_api_to_model_dict(value)
134
+ elif isinstance(value, list):
135
+ converted[model_key] = [
136
+ (
137
+ self._convert_api_to_model_dict(item)
138
+ if isinstance(item, dict)
139
+ else item
140
+ )
141
+ for item in value
142
+ ]
143
+ else:
144
+ converted[model_key] = value
145
+
146
+ return converted
147
+
148
+ def handle_enums(self, obj: Any) -> Any:
149
+ """
150
+ Simple utility to convert enums to their values for JSON serialization.
151
+
152
+ Args:
153
+ obj: Any object that might contain enums
154
+
155
+ Returns:
156
+ Object with enums converted to their values
157
+ """
158
+ if isinstance(obj, Enum):
159
+ return obj.value
160
+ elif isinstance(obj, dict):
161
+ return {k: self.handle_enums(v) for k, v in obj.items()}
162
+ elif isinstance(obj, (list, tuple)):
163
+ return type(obj)(self.handle_enums(item) for item in obj)
164
+ elif isinstance(obj, set):
165
+ return {self.handle_enums(item) for item in obj}
166
+ else:
167
+ return obj
168
+
169
+ def _to_snake_case(self, camel_str: str) -> str:
170
+ """Convert camelCase to snake_case."""
171
+ result = []
172
+ for i, char in enumerate(camel_str):
173
+ if char.isupper() and i > 0:
174
+ result.append("_")
175
+ result.append(char.lower())
176
+ return "".join(result)
177
+
178
+ def get_base_url(self) -> str:
179
+ """Get the base URL for the API."""
180
+ return self.base_url_sandbox if self.sandbox else self.base_url
181
+
182
+ async def _make_request(
183
+ self,
184
+ endpoint: str,
185
+ data: BaseModel,
186
+ model: type[BaseModel],
187
+ timeout: Optional[float] = None,
188
+ ) -> BaseModel:
189
+ """
190
+ Make a POST request to the API with proper error handling.
191
+
192
+ Args:
193
+ endpoint: API endpoint (e.g., "/booking/reservation/list/")
194
+ data: Request payload as dict or Pydantic model
195
+ model: Pydantic model to parse the response into
196
+ timeout: Request timeout in seconds
197
+
198
+ Returns:
199
+ Parsed JSON response as pydantic model
200
+
201
+ Raises:
202
+ BookALimoError: On API errors or HTTP errors
203
+ """
204
+ url = f"{self.get_base_url()}{endpoint}"
205
+
206
+ # Convert model data to API format
207
+ api_data = self._convert_model_to_api_dict(data.model_dump())
208
+
209
+ # Remove None values to avoid API issues
210
+ api_data = self._remove_none_values(api_data)
211
+ api_data = self.handle_enums(api_data)
212
+
213
+ try:
214
+ response = await self.client.post(
215
+ url,
216
+ json=api_data,
217
+ headers=self.headers,
218
+ timeout=timeout or self.http_timeout,
219
+ )
220
+ response.raise_for_status()
221
+
222
+ # Handle different HTTP status codes
223
+ if response.status_code >= 400:
224
+ error_msg = f"HTTP {response.status_code}: {response.text}"
225
+ raise BookALimoError(
226
+ error_msg,
227
+ status_code=response.status_code,
228
+ response_data={"raw_response": response.text},
229
+ )
230
+
231
+ try:
232
+ json_data = response.json()
233
+ except ValueError as e:
234
+ raise BookALimoError(f"Invalid JSON response: {str(e)}") from e
235
+
236
+ # Check for API-level errors
237
+ if isinstance(json_data, dict):
238
+ if "error" in json_data and json_data["error"]:
239
+ raise BookALimoError(
240
+ f"API Error: {json_data['error']}", response_data=json_data
241
+ )
242
+
243
+ # Check success flag if present
244
+ if "success" in json_data and not json_data["success"]:
245
+ error_msg = json_data.get("error", "Unknown API error")
246
+ raise BookALimoError(
247
+ f"API Error: {error_msg}", response_data=json_data
248
+ )
249
+
250
+ # Convert response back to model format
251
+ return model.model_validate(self._convert_api_to_model_dict(json_data))
252
+
253
+ except httpx.TimeoutException:
254
+ raise BookALimoError(
255
+ f"Request timeout after {timeout or self.http_timeout}s"
256
+ ) from None
257
+ except httpx.ConnectError:
258
+ raise BookALimoError(
259
+ "Connection error - unable to reach Book-A-Limo API"
260
+ ) from None
261
+ except httpx.HTTPError as e:
262
+ raise BookALimoError(f"HTTP Error: {str(e)}") from e
263
+ except BookALimoError:
264
+ # Re-raise our custom errors
265
+ raise
266
+ except Exception as e:
267
+ raise BookALimoError(f"Unexpected error: {str(e)}") from e
268
+
269
+ def _remove_none_values(self, data: Any) -> Any:
270
+ """Recursively remove None values from data structure."""
271
+ if isinstance(data, dict):
272
+ return {
273
+ k: self._remove_none_values(v) for k, v in data.items() if v is not None
274
+ }
275
+ elif isinstance(data, list):
276
+ return [self._remove_none_values(item) for item in data]
277
+ else:
278
+ return data
279
+
280
+ async def list_reservations(
281
+ self, is_archive: bool = False
282
+ ) -> ListReservationsResponse:
283
+ """List reservations for the given credentials."""
284
+ data = ListReservationsRequest(
285
+ credentials=self.credentials, is_archive=is_archive
286
+ )
287
+ return cast(
288
+ ListReservationsResponse,
289
+ await self._make_request(
290
+ "/booking/reservation/list/", data, model=ListReservationsResponse
291
+ ),
292
+ )
293
+
294
+ async def get_reservation(self, confirmation: str) -> GetReservationResponse:
295
+ """Get detailed reservation information."""
296
+ data = GetReservationRequest(
297
+ credentials=self.credentials, confirmation=confirmation
298
+ )
299
+ return cast(
300
+ GetReservationResponse,
301
+ await self._make_request(
302
+ "/booking/reservation/get/", data, model=GetReservationResponse
303
+ ),
304
+ )
305
+
306
+ async def get_prices(self, price_request: PriceRequest) -> PriceResponse:
307
+ """Get pricing for a trip."""
308
+ price_request_authenticated = PriceRequestAuthenticated(
309
+ credentials=self.credentials, **price_request.model_dump()
310
+ )
311
+ return cast(
312
+ PriceResponse,
313
+ await self._make_request(
314
+ "/booking/price/", price_request_authenticated, model=PriceResponse
315
+ ),
316
+ )
317
+
318
+ async def set_details(self, details_request: DetailsRequest) -> DetailsResponse:
319
+ """Set reservation details and get updated pricing."""
320
+ return cast(
321
+ DetailsResponse,
322
+ await self._make_request(
323
+ "/booking/details/", details_request, model=DetailsResponse
324
+ ),
325
+ )
326
+
327
+ async def book_reservation(self, book_request: BookRequest) -> BookResponse:
328
+ """Book a reservation."""
329
+ return cast(
330
+ BookResponse,
331
+ await self._make_request(
332
+ "/booking/book/", book_request, model=BookResponse
333
+ ),
334
+ )
335
+
336
+ async def edit_reservation(
337
+ self, edit_request: EditableReservationRequest
338
+ ) -> EditReservationResponse:
339
+ """Edit or cancel a reservation."""
340
+ edit_request_authenticated = EditableReservationRequestAuthenticated(
341
+ credentials=self.credentials, **edit_request.model_dump()
342
+ )
343
+ return cast(
344
+ EditReservationResponse,
345
+ await self._make_request(
346
+ "/booking/edit/",
347
+ edit_request_authenticated,
348
+ model=EditReservationResponse,
349
+ ),
350
+ )