bookalimo 0.1.2__py3-none-any.whl → 0.1.4__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 CHANGED
@@ -5,6 +5,7 @@ Provides a clean, typed interface to the Book-A-Limo API.
5
5
 
6
6
  import importlib.metadata
7
7
 
8
+ from ._logging import disable_debug_logging, enable_debug_logging
8
9
  from .wrapper import (
9
10
  BookALimo,
10
11
  create_address_location,
@@ -23,6 +24,8 @@ __all__ = [
23
24
  "create_stop",
24
25
  "create_passenger",
25
26
  "create_credit_card",
27
+ "enable_debug_logging",
28
+ "disable_debug_logging",
26
29
  ]
27
30
 
28
31
  __version__ = importlib.metadata.version(__package__ or __name__)
bookalimo/_client.py CHANGED
@@ -3,13 +3,18 @@ Base HTTP client for Book-A-Limo API.
3
3
  Handles authentication, headers, and common request/response patterns.
4
4
  """
5
5
 
6
+ import logging
6
7
  from enum import Enum
7
8
  from functools import lru_cache
8
- from typing import Any, Optional, cast
9
+ from time import perf_counter
10
+ from typing import Any, Optional, TypeVar, cast, overload
11
+ from uuid import uuid4
9
12
 
10
13
  import httpx
11
14
  from pydantic import BaseModel
12
15
 
16
+ from ._logging import get_logger
17
+ from .exceptions import BookALimoError
13
18
  from .models import (
14
19
  BookRequest,
15
20
  BookResponse,
@@ -28,6 +33,10 @@ from .models import (
28
33
  PriceResponse,
29
34
  )
30
35
 
36
+ logger = get_logger("client")
37
+
38
+ T = TypeVar("T")
39
+
31
40
 
32
41
  @lru_cache(maxsize=1)
33
42
  def get_version() -> str:
@@ -37,20 +46,6 @@ def get_version() -> str:
37
46
  return __version__
38
47
 
39
48
 
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
49
  class BookALimoClient:
55
50
  """
56
51
  Base HTTP client for Book-A-Limo API.
@@ -76,6 +71,13 @@ class BookALimoClient:
76
71
  }
77
72
  self.base_url = base_url
78
73
  self.http_timeout = http_timeout
74
+ if logger.isEnabledFor(logging.DEBUG):
75
+ logger.debug(
76
+ "Client initialized (base_url=%s, timeout=%s, user_agent=%s)",
77
+ self.base_url,
78
+ self.http_timeout,
79
+ self.headers.get("user-agent"),
80
+ )
79
81
 
80
82
  def _convert_model_to_api_dict(self, data: dict[str, Any]) -> dict[str, Any]:
81
83
  """
@@ -141,6 +143,19 @@ class BookALimoClient:
141
143
 
142
144
  return converted
143
145
 
146
+ @overload
147
+ def handle_enums(self, obj: Enum) -> Any: ...
148
+ @overload
149
+ def handle_enums(self, obj: dict[str, Any]) -> dict[str, Any]: ...
150
+ @overload
151
+ def handle_enums(self, obj: list[Any]) -> list[Any]: ...
152
+ @overload
153
+ def handle_enums(self, obj: tuple[Any, ...]) -> tuple[Any, ...]: ...
154
+ @overload
155
+ def handle_enums(self, obj: set[Any]) -> set[Any]: ...
156
+ @overload
157
+ def handle_enums(self, obj: Any) -> Any: ...
158
+
144
159
  def handle_enums(self, obj: Any) -> Any:
145
160
  """
146
161
  Simple utility to convert enums to their values for JSON serialization.
@@ -171,6 +186,21 @@ class BookALimoClient:
171
186
  result.append(char.lower())
172
187
  return "".join(result)
173
188
 
189
+ def prepare_data(self, data: BaseModel) -> dict[str, Any]:
190
+ """
191
+ Prepare data for API requests by converting it to the appropriate format.
192
+
193
+ Args:
194
+ data: The data to prepare, as a Pydantic model instance.
195
+
196
+ Returns:
197
+ A dictionary representation of the data, ready for API consumption.
198
+ """
199
+ api_data = self._convert_model_to_api_dict(data.model_dump())
200
+ api_data = self._remove_none_values(api_data)
201
+ api_data = self.handle_enums(api_data)
202
+ return cast(dict[str, Any], api_data)
203
+
174
204
  async def _make_request(
175
205
  self,
176
206
  endpoint: str,
@@ -178,29 +208,24 @@ class BookALimoClient:
178
208
  model: type[BaseModel],
179
209
  timeout: Optional[float] = None,
180
210
  ) -> BaseModel:
181
- """
182
- Make a POST request to the API with proper error handling.
183
-
184
- Args:
185
- endpoint: API endpoint (e.g., "/booking/reservation/list/")
186
- data: Request payload as dict or Pydantic model
187
- model: Pydantic model to parse the response into
188
- timeout: Request timeout in seconds
189
-
190
- Returns:
191
- Parsed JSON response as pydantic model
192
-
193
- Raises:
194
- BookALimoError: On API errors or HTTP errors
195
- """
196
211
  url = f"{self.base_url}{endpoint}"
197
212
 
198
213
  # Convert model data to API format
199
- api_data = self._convert_model_to_api_dict(data.model_dump())
200
-
201
- # Remove None values to avoid API issues
202
- api_data = self._remove_none_values(api_data)
203
- api_data = self.handle_enums(api_data)
214
+ api_data = self.prepare_data(data)
215
+
216
+ debug_on = logger.isEnabledFor(logging.DEBUG)
217
+ req_id = None
218
+ if debug_on:
219
+ req_id = uuid4().hex[:8]
220
+ start = perf_counter()
221
+ body_keys = sorted(k for k in api_data.keys() if k != "credentials")
222
+ logger.debug(
223
+ "→ [%s] POST %s timeout=%s body_keys=%s",
224
+ req_id,
225
+ endpoint,
226
+ timeout or self.http_timeout,
227
+ body_keys,
228
+ )
204
229
 
205
230
  try:
206
231
  response = await self.client.post(
@@ -211,11 +236,13 @@ class BookALimoClient:
211
236
  )
212
237
  response.raise_for_status()
213
238
 
214
- # Handle different HTTP status codes
239
+ # HTTP 4xx/5xx already raise in httpx, but keep defensive check:
215
240
  if response.status_code >= 400:
216
- error_msg = f"HTTP {response.status_code}: {response.text}"
241
+ error_msg = f"HTTP {response.status_code}"
242
+ if debug_on:
243
+ logger.warning("× [%s] %s %s", req_id or "-", endpoint, error_msg)
217
244
  raise BookALimoError(
218
- error_msg,
245
+ f"{error_msg}: {response.text}",
219
246
  status_code=response.status_code,
220
247
  response_data={"raw_response": response.text},
221
248
  )
@@ -223,41 +250,92 @@ class BookALimoClient:
223
250
  try:
224
251
  json_data = response.json()
225
252
  except ValueError as e:
253
+ if debug_on:
254
+ logger.warning("× [%s] %s invalid JSON", req_id or "-", endpoint)
226
255
  raise BookALimoError(f"Invalid JSON response: {str(e)}") from e
227
256
 
228
- # Check for API-level errors
257
+ # API-level errors
229
258
  if isinstance(json_data, dict):
230
- if "error" in json_data and json_data["error"]:
259
+ if json_data.get("error"):
260
+ if debug_on:
261
+ logger.warning("× [%s] %s API error", req_id or "-", endpoint)
231
262
  raise BookALimoError(
232
263
  f"API Error: {json_data['error']}", response_data=json_data
233
264
  )
234
-
235
- # Check success flag if present
236
265
  if "success" in json_data and not json_data["success"]:
237
- error_msg = json_data.get("error", "Unknown API error")
238
- raise BookALimoError(
239
- f"API Error: {error_msg}", response_data=json_data
240
- )
266
+ msg = json_data.get("error", "Unknown API error")
267
+ if debug_on:
268
+ logger.warning("× [%s] %s API error", req_id or "-", endpoint)
269
+ raise BookALimoError(f"API Error: {msg}", response_data=json_data)
270
+
271
+ if debug_on:
272
+ dur_ms = (perf_counter() - start) * 1000.0
273
+ reqid_hdr = response.headers.get(
274
+ "x-request-id"
275
+ ) or response.headers.get("request-id")
276
+ content_len = None
277
+ try:
278
+ content_len = len(response.content)
279
+ except Exception:
280
+ pass
281
+ logger.debug(
282
+ "← [%s] %s %s in %.1f ms len=%s reqid=%s",
283
+ req_id,
284
+ response.status_code,
285
+ endpoint,
286
+ dur_ms,
287
+ content_len,
288
+ reqid_hdr,
289
+ )
241
290
 
242
- # Convert response back to model format
243
291
  return model.model_validate(self._convert_api_to_model_dict(json_data))
244
292
 
245
293
  except httpx.TimeoutException:
294
+ if debug_on:
295
+ logger.warning(
296
+ "× [%s] %s timeout after %ss",
297
+ req_id or "-",
298
+ endpoint,
299
+ timeout or self.http_timeout,
300
+ )
246
301
  raise BookALimoError(
247
302
  f"Request timeout after {timeout or self.http_timeout}s"
248
303
  ) from None
249
304
  except httpx.ConnectError:
305
+ if debug_on:
306
+ logger.warning("× [%s] %s connection error", req_id or "-", endpoint)
250
307
  raise BookALimoError(
251
308
  "Connection error - unable to reach Book-A-Limo API"
252
309
  ) from None
253
310
  except httpx.HTTPError as e:
311
+ if debug_on:
312
+ logger.warning(
313
+ "× [%s] %s HTTP error: %s",
314
+ req_id or "-",
315
+ endpoint,
316
+ e.__class__.__name__,
317
+ )
254
318
  raise BookALimoError(f"HTTP Error: {str(e)}") from e
255
319
  except BookALimoError:
256
- # Re-raise our custom errors
320
+ # already logged above where relevant
257
321
  raise
258
322
  except Exception as e:
323
+ if debug_on:
324
+ logger.warning(
325
+ "× [%s] %s unexpected error: %s",
326
+ req_id or "-",
327
+ endpoint,
328
+ e.__class__.__name__,
329
+ )
259
330
  raise BookALimoError(f"Unexpected error: {str(e)}") from e
260
331
 
332
+ @overload
333
+ def _remove_none_values(self, data: dict[str, Any]) -> dict[str, Any]: ...
334
+ @overload
335
+ def _remove_none_values(self, data: list[Any]) -> list[Any]: ...
336
+ @overload
337
+ def _remove_none_values(self, data: Any) -> Any: ...
338
+
261
339
  def _remove_none_values(self, data: Any) -> Any:
262
340
  """Recursively remove None values from data structure."""
263
341
  if isinstance(data, dict):
bookalimo/_logging.py ADDED
@@ -0,0 +1,244 @@
1
+ """
2
+ Logging configuration for bookalimo package.
3
+ Public SDK-style logging with built-in redaction helpers.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import logging
9
+ import os
10
+ import re
11
+ from collections.abc import Iterable, Mapping
12
+ from time import perf_counter
13
+ from typing import Any, Callable
14
+
15
+ logger = logging.getLogger("bookalimo")
16
+ logger.addHandler(logging.NullHandler())
17
+ logger.setLevel(logging.WARNING)
18
+
19
+ REDACTED = "******"
20
+
21
+
22
+ def mask_token(s: Any, *, show_prefix: int = 6, show_suffix: int = 2) -> str:
23
+ if not isinstance(s, str) or not s:
24
+ return REDACTED
25
+ if len(s) <= show_prefix + show_suffix:
26
+ return REDACTED
27
+ return f"{s[:show_prefix]}…{s[-show_suffix:]}"
28
+
29
+
30
+ def mask_email(s: Any) -> str:
31
+ if not isinstance(s, str) or "@" not in s:
32
+ return REDACTED
33
+ name, domain = s.split("@", 1)
34
+ return f"{name[:1]}***@{domain}"
35
+
36
+
37
+ def mask_phone(s: Any) -> str:
38
+ if not isinstance(s, str):
39
+ return REDACTED
40
+ digits = re.sub(r"\D", "", s)
41
+ tail = digits[-4:] if digits else ""
42
+ return f"***-***-{tail}" if tail else REDACTED
43
+
44
+
45
+ def mask_card_number(s: Any) -> str:
46
+ if not isinstance(s, str) or len(s) < 4:
47
+ return REDACTED
48
+ return f"**** **** **** {s[-4:]}"
49
+
50
+
51
+ def _safe_str(x: Any) -> str:
52
+ # Avoid large/complex reprs when logging
53
+ try:
54
+ s = str(x)
55
+ except Exception:
56
+ s = object.__repr__(x)
57
+ # hard scrub for obvious long tokens
58
+ if len(s) > 256:
59
+ return s[:256] + "…"
60
+ return s
61
+
62
+
63
+ def summarize_card(card: Any) -> dict[str, Any]:
64
+ """
65
+ Produce a tiny, safe card summary from either a mapping or an object with attributes.
66
+ """
67
+
68
+ def get(obj: Any, key: str) -> Any:
69
+ if isinstance(obj, Mapping):
70
+ return obj.get(key)
71
+ return getattr(obj, key, None)
72
+
73
+ number = get(card, "number")
74
+ exp = get(card, "expiration")
75
+ holder_type = get(card, "holder_type")
76
+ zip_code = get(card, "zip") or get(card, "zip_code")
77
+
78
+ return {
79
+ "last4": number[-4:] if isinstance(number, str) and len(number) >= 4 else None,
80
+ "expiration": REDACTED if exp else None,
81
+ "holder_type": str(holder_type) if holder_type is not None else None,
82
+ "zip_present": bool(zip_code),
83
+ }
84
+
85
+
86
+ def summarize_mapping(
87
+ data: Mapping[str, Any], *, whitelist: Iterable[str] | None = None
88
+ ) -> dict[str, Any]:
89
+ """
90
+ Keep only whitelisted keys; for everything else just show presence (True/False).
91
+ Avoids logging raw contents of complex payloads.
92
+ """
93
+ out: dict[str, Any] = {}
94
+ allowed = set(whitelist or [])
95
+ for k, v in data.items():
96
+ if k in allowed:
97
+ out[k] = v
98
+ else:
99
+ out[k] = bool(v) # presence only
100
+ return out
101
+
102
+
103
+ def redact_param(name: str, value: Any) -> Any:
104
+ key = name.lower()
105
+ if key in {"password", "password_hash"}:
106
+ return REDACTED
107
+ if key in {"token", "authorization", "authorization_bearer", "api_key", "secret"}:
108
+ return mask_token(value)
109
+ if key in {"email"}:
110
+ return mask_email(value)
111
+ if key in {"phone"}:
112
+ return mask_phone(value)
113
+ if key in {"cvv", "cvc", "promo"}:
114
+ return REDACTED
115
+ if key in {"number", "card_number"}:
116
+ return mask_card_number(value)
117
+ if key in {"credit_card", "card"}:
118
+ return summarize_card(value)
119
+ if key in {"zip", "zipcode", "postal_code"}:
120
+ return REDACTED
121
+ if isinstance(value, (str, int, float, bool, type(None))):
122
+ return value
123
+ return _safe_str(value)
124
+
125
+
126
+ # ---- public API --------------------------------------------------------------
127
+
128
+
129
+ def get_logger(name: str | None = None) -> logging.Logger:
130
+ if name:
131
+ return logging.getLogger(f"bookalimo.{name}")
132
+ return logger
133
+
134
+
135
+ def enable_debug_logging(level: int | None = None) -> None:
136
+ level = level or _level_from_env() or logging.DEBUG
137
+ logger.setLevel(level)
138
+
139
+ has_real_handler = any(
140
+ not isinstance(h, logging.NullHandler) for h in logger.handlers
141
+ )
142
+ if not has_real_handler:
143
+ handler = logging.StreamHandler()
144
+ formatter = logging.Formatter(
145
+ "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
146
+ )
147
+ handler.setFormatter(formatter)
148
+ logger.addHandler(handler)
149
+
150
+ logger.info("bookalimo logging enabled at %s", logging.getLevelName(logger.level))
151
+
152
+
153
+ def disable_debug_logging() -> None:
154
+ logger.setLevel(logging.WARNING)
155
+ for handler in logger.handlers[:]:
156
+ if not isinstance(handler, logging.NullHandler):
157
+ logger.removeHandler(handler)
158
+
159
+
160
+ def _level_from_env() -> int | None:
161
+ lvl = os.getenv("BOOKALIMO_LOG_LEVEL")
162
+ if not lvl:
163
+ return None
164
+ try:
165
+ return int(lvl)
166
+ except ValueError:
167
+ try:
168
+ return logging._nameToLevel.get(lvl.upper(), None)
169
+ except Exception:
170
+ return None
171
+
172
+
173
+ # ---- decorator for async methods --------------------------------------------
174
+
175
+
176
+ def log_call(
177
+ *,
178
+ include_params: Iterable[str] | None = None,
179
+ transforms: Mapping[str, Callable[[Any], Any]] | None = None,
180
+ operation: str | None = None,
181
+ ) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
182
+ """
183
+ Decorator for async SDK methods.
184
+ - DEBUG: logs start/end with sanitized params + duration
185
+ - WARNING: logs errors (sanitized). No overhead when DEBUG is off.
186
+ """
187
+ include = set(include_params or [])
188
+ transforms = transforms or {}
189
+
190
+ def _decorate(fn: Callable[..., Any]) -> Callable[..., Any]:
191
+ async def _async_wrapper(self: Any, *args: Any, **kwargs: Any) -> Any:
192
+ log = get_logger("wrapper")
193
+ op = operation or fn.__name__
194
+
195
+ # Fast path: if debug disabled, skip param binding/redaction entirely
196
+ debug_on = log.isEnabledFor(logging.DEBUG)
197
+ if debug_on:
198
+ # Build a minimal, sanitized args snapshot
199
+ snapshot: dict[str, Any] = {}
200
+ # Map positional args to param names without inspect overhead by relying on kwargs only:
201
+ # we assume call sites are using kwargs in the wrapper (they do).
202
+ for k in include:
203
+ val = kwargs.get(k, None)
204
+ if k in transforms:
205
+ try:
206
+ val = transforms[k](val)
207
+ except Exception:
208
+ val = REDACTED
209
+ else:
210
+ val = redact_param(k, val)
211
+ snapshot[k] = val
212
+
213
+ start = perf_counter()
214
+ log.debug(
215
+ "→ %s(%s)",
216
+ op,
217
+ ", ".join(f"{k}={snapshot[k]}" for k in snapshot),
218
+ extra={"operation": op},
219
+ )
220
+
221
+ try:
222
+ result = await fn(self, *args, **kwargs)
223
+ if debug_on:
224
+ dur_ms = (perf_counter() - start) * 1000.0
225
+ # Keep result logging ultra-light
226
+ result_type = type(result).__name__
227
+ log.debug(
228
+ "← %s ok in %.1f ms (%s)",
229
+ op,
230
+ dur_ms,
231
+ result_type,
232
+ extra={"operation": op},
233
+ )
234
+ return result
235
+ except Exception as e:
236
+ # WARNING with sanitized error; no param dump on failures
237
+ log.warning(
238
+ "%s failed: %s", op, e.__class__.__name__, extra={"operation": op}
239
+ )
240
+ raise
241
+
242
+ return _async_wrapper
243
+
244
+ return _decorate
@@ -0,0 +1,15 @@
1
+ from typing import Any, Optional
2
+
3
+
4
+ class BookALimoError(Exception):
5
+ """Base exception for Book-A-Limo API errors."""
6
+
7
+ def __init__(
8
+ self,
9
+ message: str,
10
+ status_code: Optional[int] = None,
11
+ response_data: Optional[dict[str, Any]] = None,
12
+ ):
13
+ super().__init__(message)
14
+ self.status_code = status_code
15
+ self.response_data = response_data or {}
bookalimo/wrapper.py CHANGED
@@ -3,12 +3,15 @@ High-level API wrapper for Book-A-Limo operations.
3
3
  Provides clean, LLM-friendly functions that abstract API complexities.
4
4
  """
5
5
 
6
+ import logging
6
7
  from types import TracebackType
7
8
  from typing import Any, Optional
8
9
 
9
10
  from httpx import AsyncClient
10
11
 
11
- from ._client import BookALimoClient, BookALimoError
12
+ from ._client import BookALimoClient
13
+ from ._logging import get_logger, log_call
14
+ from .exceptions import BookALimoError
12
15
  from .models import (
13
16
  Address,
14
17
  Airport,
@@ -33,6 +36,8 @@ from .models import (
33
36
  Stop,
34
37
  )
35
38
 
39
+ logger = get_logger("wrapper")
40
+
36
41
 
37
42
  class BookALimo:
38
43
  """
@@ -44,7 +49,7 @@ class BookALimo:
44
49
  self,
45
50
  credentials: Credentials,
46
51
  http_client: Optional[AsyncClient] = None,
47
- base_url: str = "https://api.bookalimo.com",
52
+ base_url: str = "https://www.bookalimo.com/web/api",
48
53
  http_timeout: float = 5.0,
49
54
  **kwargs: Any,
50
55
  ):
@@ -65,11 +70,20 @@ class BookALimo:
65
70
  http_timeout=http_timeout,
66
71
  **kwargs,
67
72
  )
73
+ if logger.isEnabledFor(logging.DEBUG): # NEW: tiny, safe init log
74
+ logger.debug(
75
+ "BookALimo initialized (base_url=%s, timeout=%s, owns_http_client=%s)",
76
+ base_url,
77
+ http_timeout,
78
+ self._owns_http_client,
79
+ )
68
80
 
69
81
  async def aclose(self) -> None:
70
82
  """Close the HTTP client if we own it."""
71
83
  if self._owns_http_client and not self.http_client.is_closed:
72
84
  await self.http_client.aclose()
85
+ if logger.isEnabledFor(logging.DEBUG):
86
+ logger.debug("HTTP client closed")
73
87
 
74
88
  async def __aenter__(self) -> "BookALimo":
75
89
  """Async context manager entry."""
@@ -84,6 +98,7 @@ class BookALimo:
84
98
  """Async context manager exit."""
85
99
  await self.aclose()
86
100
 
101
+ @log_call(include_params=["is_archive"], operation="list_reservations")
87
102
  async def list_reservations(
88
103
  self, is_archive: bool = False
89
104
  ) -> ListReservationsResponse:
@@ -103,6 +118,7 @@ class BookALimo:
103
118
  except Exception as e:
104
119
  raise BookALimoError(f"Failed to list reservations: {str(e)}") from e
105
120
 
121
+ @log_call(include_params=["confirmation"], operation="get_reservation")
106
122
  async def get_reservation(self, confirmation: str) -> GetReservationResponse:
107
123
  """
108
124
  Get detailed reservation information.
@@ -120,6 +136,15 @@ class BookALimo:
120
136
  except Exception as e:
121
137
  raise BookALimoError(f"Failed to get reservation: {str(e)}") from e
122
138
 
139
+ @log_call(
140
+ include_params=[
141
+ "rate_type",
142
+ "date_time",
143
+ "passengers",
144
+ "luggage",
145
+ ],
146
+ operation="get_prices",
147
+ )
123
148
  async def get_prices(
124
149
  self,
125
150
  rate_type: RateType,
@@ -136,8 +161,8 @@ class BookALimo:
136
161
  Args:
137
162
  rate_type: 0=P2P, 1=Hourly (or string names)
138
163
  date_time: 'MM/dd/yyyy hh:mm tt' format
139
- pickup_location: Location dict
140
- dropoff_location: Location dict
164
+ pickup_location: Location
165
+ dropoff_location: Location
141
166
  passengers: Number of passengers
142
167
  luggage: Number of luggage pieces
143
168
  **kwargs: Optional fields like stops, account, car_class_code, etc.
@@ -183,6 +208,15 @@ class BookALimo:
183
208
  except Exception as e:
184
209
  raise BookALimoError(f"Failed to get prices: {str(e)}") from e
185
210
 
211
+ @log_call(
212
+ include_params=["token", "details"],
213
+ transforms={
214
+ "details": lambda d: sorted(
215
+ [k for k, v in (d or {}).items() if v is not None]
216
+ ),
217
+ },
218
+ operation="set_details",
219
+ )
186
220
  async def set_details(self, token: str, **details: Any) -> DetailsResponse:
187
221
  """
188
222
  Set reservation details and get updated pricing.
@@ -212,6 +246,10 @@ class BookALimo:
212
246
  except Exception as e:
213
247
  raise BookALimoError(f"Failed to set details: {str(e)}") from e
214
248
 
249
+ @log_call(
250
+ include_params=["token", "method", "promo", "credit_card"],
251
+ operation="book",
252
+ )
215
253
  async def book(
216
254
  self,
217
255
  token: str,
@@ -254,6 +292,15 @@ class BookALimo:
254
292
  except Exception as e:
255
293
  raise BookALimoError(f"Failed to book reservation: {str(e)}") from e
256
294
 
295
+ @log_call(
296
+ include_params=["confirmation", "is_cancel_request", "changes"],
297
+ transforms={
298
+ "changes": lambda d: sorted(
299
+ [k for k, v in (d or {}).items() if v is not None]
300
+ ),
301
+ },
302
+ operation="edit_reservation",
303
+ )
257
304
  async def edit_reservation(
258
305
  self, confirmation: str, is_cancel_request: bool = False, **changes: Any
259
306
  ) -> EditReservationResponse:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bookalimo
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: Python wrapper for the Book-A-Limo API
5
5
  Author-email: Jonathan Oren <jonathan@bookalimo.com>
6
6
  Maintainer-email: Jonathan Oren <jonathan@bookalimo.com>
@@ -63,13 +63,43 @@ Dynamic: license-file
63
63
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
64
64
  [![Code style: ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
65
65
 
66
- A modern, async Python wrapper for the Book-A-Limo API with full type support.
66
+ A modern, async Python wrapper for the Book-A-Limo API with full type support. Built on top of `httpx` and `pydantic`.
67
+
68
+ ## Table of Contents
69
+
70
+ - [Book-A-Limo Python SDK](#book-a-limo-python-sdk)
71
+ - [Table of Contents](#table-of-contents)
72
+ - [Features](#features)
73
+ - [Requirements](#requirements)
74
+ - [Installation](#installation)
75
+ - [Quick Start](#quick-start)
76
+ - [Authentication](#authentication)
77
+ - [Core Operations](#core-operations)
78
+ - [Get Pricing](#get-pricing)
79
+ - [Book a Reservation](#book-a-reservation)
80
+ - [Location Builders](#location-builders)
81
+ - [Airport Locations](#airport-locations)
82
+ - [Address Locations](#address-locations)
83
+ - [Stops](#stops)
84
+ - [Advanced](#advanced)
85
+ - [Using Account Info (Travel Agents)](#using-account-info-travel-agents)
86
+ - [Edit / Cancel a Reservation](#edit--cancel-a-reservation)
87
+ - [Error Handling](#error-handling)
88
+ - [Logging](#logging)
89
+ - [Enable Debug Logging](#enable-debug-logging)
90
+ - [Custom Logging](#custom-logging)
91
+ - [Security](#security)
92
+ - [Disable Logging](#disable-logging)
93
+ - [Development](#development)
94
+ - [Security Notes](#security-notes)
95
+ - [License](#license)
96
+ - [Changelog](#changelog)
67
97
 
68
98
  ## Features
69
99
 
70
- * **Async/await** (built on `httpx`)
71
- * **Typed Pydantic models** for requests & responses
72
- * **Input validation**
100
+ * **Asynchronous**
101
+ * **Fully Typed** for requests & responses
102
+ * **Input validation** including airports and addresses.
73
103
  * **Clean, minimal interface** for each API operation
74
104
  * **Custom exceptions & error handling**
75
105
  * **Tests and examples**
@@ -90,7 +120,6 @@ pip install bookalimo
90
120
 
91
121
  ```python
92
122
  import asyncio
93
- from httpx import AsyncClient
94
123
 
95
124
  from bookalimo import (
96
125
  BookALimo,
@@ -98,30 +127,29 @@ from bookalimo import (
98
127
  create_airport_location,
99
128
  create_address_location,
100
129
  )
101
- from bookalimo.models import RateType # enums/models come from bookalimo.models
130
+ from bookalimo.models import RateType
102
131
 
103
132
  async def main():
104
133
  # For Travel Agents (customers: pass is_customer=True)
105
134
  credentials = create_credentials("TA10007", "your_password")
106
135
 
107
- async with AsyncClient() as http_client:
108
- async with BookALimo(credentials, http_client=http_client) as client:
109
- # Build locations
110
- pickup = create_airport_location("JFK", "New York")
111
- dropoff = create_address_location("53 East 34th Street, Manhattan")
112
-
113
- prices = await client.get_prices(
114
- rate_type=RateType.P2P,
115
- date_time="09/05/2025 12:44 AM",
116
- pickup=pickup,
117
- dropoff=dropoff,
118
- passengers=2,
119
- luggage=3,
120
- )
121
-
122
- print(f"Available cars: {len(prices.prices)}")
123
- for price in prices.prices:
124
- print(f"- {price.car_description}: ${price.price}")
136
+ async with BookALimo(credentials) as client:
137
+ # Build locations
138
+ pickup = create_airport_location("JFK", "New York")
139
+ dropoff = create_address_location("53 East 34th Street, Manhattan")
140
+
141
+ prices = await client.get_prices(
142
+ rate_type=RateType.P2P,
143
+ date_time="09/05/2025 12:44 AM",
144
+ pickup=pickup,
145
+ dropoff=dropoff,
146
+ passengers=2,
147
+ luggage=3,
148
+ )
149
+
150
+ print(f"Available cars: {len(prices.prices)}")
151
+ for price in prices.prices:
152
+ print(f"- {price.car_description}: ${price.price}")
125
153
 
126
154
  if __name__ == "__main__":
127
155
  asyncio.run(main())
@@ -241,7 +269,7 @@ stops = [
241
269
  ### Using Account Info (Travel Agents)
242
270
 
243
271
  ```python
244
- from bookalimo.models import Account # models (not re-exported at top-level)
272
+ from bookalimo.models import Account
245
273
 
246
274
  account = Account(
247
275
  id="TA10007",
@@ -279,7 +307,7 @@ cancel_result = await client.edit_reservation(
279
307
  ## Error Handling
280
308
 
281
309
  ```python
282
- from bookalimo._client import BookALimoError # currently defined here
310
+ from bookalimo.exceptions import BookALimoError
283
311
 
284
312
  try:
285
313
  reservations = await client.list_reservations()
@@ -289,6 +317,50 @@ except BookALimoError as e:
289
317
  print(f"Response Data: {e.response_data}")
290
318
  ```
291
319
 
320
+ ## Logging
321
+
322
+ By default, no log messages appear. Enable logging for debugging or monitoring.
323
+
324
+ ### Enable Debug Logging
325
+
326
+ ```python
327
+ import bookalimo
328
+
329
+ bookalimo.enable_debug_logging()
330
+
331
+ async with bookalimo.BookALimo(credentials) as client:
332
+ reservations = await client.list_reservations() # Shows API calls, timing, etc.
333
+ ```
334
+
335
+ Or use the environment variable:
336
+ ```bash
337
+ export BOOKALIMO_LOG_LEVEL=DEBUG
338
+ ```
339
+
340
+ ### Custom Logging
341
+
342
+ ```python
343
+ import logging
344
+ import bookalimo
345
+
346
+ logging.basicConfig(level=logging.INFO)
347
+ bookalimo.get_logger().setLevel(logging.WARNING) # Production setting
348
+ ```
349
+
350
+ ### Security
351
+
352
+ Sensitive data is automatically redacted in logs:
353
+ - Passwords, tokens, CVV codes: `******`
354
+ - API keys: `abc123…89` (first 6, last 2 chars)
355
+ - Emails: `j***@example.com`
356
+ - Credit cards: `**** **** **** 1234`
357
+
358
+ ### Disable Logging
359
+
360
+ ```python
361
+ bookalimo.disable_debug_logging()
362
+ ```
363
+
292
364
  ## Development
293
365
 
294
366
  ```bash
@@ -0,0 +1,12 @@
1
+ bookalimo/__init__.py,sha256=rzbMBdkp0hIIQJ8AVLHeA6y4YrNIezfzUxP2iNcRBaw,691
2
+ bookalimo/_client.py,sha256=-kpaWBeEwFcXL28he0zFpx557jcZTUcx_NTjraSHSPQ,14537
3
+ bookalimo/_logging.py,sha256=frnwQtx9FTADPa0r38A1y7j-1VLBgGJ1p6dod_g0n6k,7646
4
+ bookalimo/exceptions.py,sha256=ubOUZiXGEOWZvXyz2D5f5zwxBlQZVDBv1uOPmokAQ6Q,404
5
+ bookalimo/models.py,sha256=-gvxrT7llrYLxSHWn3vpmvkQqnk2ejSd97Sw8BxgBLw,16420
6
+ bookalimo/py.typed,sha256=8PjyZ1aVoQpRVvt71muvuq5qE-jTFZkK-GLHkhdebmc,26
7
+ bookalimo/wrapper.py,sha256=tusu2D4CkQqI7VKxSh62bK9hvleNHjuGD_6EpsWi2xg,13381
8
+ bookalimo-0.1.4.dist-info/licenses/LICENSE,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ bookalimo-0.1.4.dist-info/METADATA,sha256=f_kTmUYU_MhJJNXhienHzlSDEo4M8IVw0Tcc4WyGkfQ,10739
10
+ bookalimo-0.1.4.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
11
+ bookalimo-0.1.4.dist-info/top_level.txt,sha256=ZgYiDX2GfZCp4pevWn4X2qyEr-Dh7-cq5Y1j2jMhB1s,10
12
+ bookalimo-0.1.4.dist-info/RECORD,,
@@ -1,10 +0,0 @@
1
- bookalimo/__init__.py,sha256=ch1t7uvw_F3zNpqb-IfH0n2sDwFLnAQDgm2lpwtvpj4,568
2
- bookalimo/_client.py,sha256=H5PYZ3S1OMqaP8kzsZfqyTnvyOwGeg_WsCG2Imb5-98,11498
3
- bookalimo/models.py,sha256=-gvxrT7llrYLxSHWn3vpmvkQqnk2ejSd97Sw8BxgBLw,16420
4
- bookalimo/py.typed,sha256=8PjyZ1aVoQpRVvt71muvuq5qE-jTFZkK-GLHkhdebmc,26
5
- bookalimo/wrapper.py,sha256=zialtJGS7mK7aXHyTIcAQghIDai9W73dbRln0Lz7ndE,11873
6
- bookalimo-0.1.2.dist-info/licenses/LICENSE,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
- bookalimo-0.1.2.dist-info/METADATA,sha256=XWdArhmpR0SfcsQtD10Y1EG6kBHAshbx_Xy25SG00ss,9005
8
- bookalimo-0.1.2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
9
- bookalimo-0.1.2.dist-info/top_level.txt,sha256=ZgYiDX2GfZCp4pevWn4X2qyEr-Dh7-cq5Y1j2jMhB1s,10
10
- bookalimo-0.1.2.dist-info/RECORD,,