bookalimo 1.0.0__py3-none-any.whl → 1.0.2__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.
@@ -1,13 +1,9 @@
1
1
  """Reservations service for listing, getting, editing, and booking reservations."""
2
2
 
3
- from typing import Any, Optional
4
-
5
- from ..exceptions import BookalimoRequestError
6
- from ..schemas.booking import (
3
+ from ..schemas import (
7
4
  BookRequest,
8
5
  BookResponse,
9
- CreditCard,
10
- EditableReservationRequest,
6
+ EditReservationRequest,
11
7
  EditReservationResponse,
12
8
  GetReservationRequest,
13
9
  GetReservationResponse,
@@ -53,73 +49,30 @@ class AsyncReservationsService:
53
49
  "/booking/reservation/get/", request, GetReservationResponse
54
50
  )
55
51
 
56
- async def edit(
57
- self, confirmation: str, *, is_cancel: bool = False, **changes: Any
58
- ) -> EditReservationResponse:
52
+ async def edit(self, request: EditReservationRequest) -> EditReservationResponse:
59
53
  """
60
54
  Edit or cancel a reservation.
61
55
 
62
56
  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)
57
+ request: Complete edit request with confirmation and fields to change
68
58
 
69
59
  Returns:
70
60
  EditReservationResponse
71
61
  """
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
62
  return await self._transport.post(
85
63
  "/booking/edit/", request, EditReservationResponse
86
64
  )
87
65
 
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:
66
+ async def book(self, request: BookRequest) -> BookResponse:
96
67
  """
97
68
  Book a reservation.
98
69
 
99
70
  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
71
+ request: Complete booking request with token and payment details
104
72
 
105
73
  Returns:
106
74
  BookResponse with reservation_id
107
75
  """
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
76
  return await self._transport.post("/booking/book/", request, BookResponse)
124
77
 
125
78
 
@@ -159,69 +112,26 @@ class ReservationsService:
159
112
  "/booking/reservation/get/", request, GetReservationResponse
160
113
  )
161
114
 
162
- def edit(
163
- self, confirmation: str, *, is_cancel: bool = False, **changes: Any
164
- ) -> EditReservationResponse:
115
+ def edit(self, request: EditReservationRequest) -> EditReservationResponse:
165
116
  """
166
117
  Edit or cancel a reservation.
167
118
 
168
119
  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)
120
+ request: Complete edit request with confirmation and fields to change
174
121
 
175
122
  Returns:
176
123
  EditReservationResponse
177
124
  """
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
125
  return self._transport.post("/booking/edit/", request, EditReservationResponse)
191
126
 
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:
127
+ def book(self, request: BookRequest) -> BookResponse:
200
128
  """
201
129
  Book a reservation.
202
130
 
203
131
  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
132
+ request: Complete booking request with token and payment details
208
133
 
209
134
  Returns:
210
135
  BookResponse with reservation_id
211
136
  """
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
137
  return self._transport.post("/booking/book/", request, BookResponse)
@@ -3,10 +3,10 @@
3
3
  import hashlib
4
4
  from typing import Any, Optional
5
5
 
6
- from ..schemas.base import ApiModel
6
+ from ..schemas.base import RequestModel
7
7
 
8
8
 
9
- class Credentials(ApiModel):
9
+ class Credentials(RequestModel):
10
10
  """Authentication credentials for Book-A-Limo API."""
11
11
 
12
12
  id: str
@@ -15,17 +15,21 @@ from ..config import (
15
15
  DEFAULT_TIMEOUTS,
16
16
  DEFAULT_USER_AGENT,
17
17
  )
18
- from ..exceptions import (
19
- BookalimoConnectionError,
20
- BookalimoError,
21
- BookalimoHTTPError,
22
- BookalimoRequestError,
23
- BookalimoTimeout,
24
- )
18
+ from ..logging import redact_url
25
19
  from .auth import Credentials, inject_credentials
26
20
  from .base import AsyncBaseTransport
27
21
  from .retry import async_retry, should_retry_exception, should_retry_status
28
- from .utils import handle_api_errors, handle_http_error
22
+ from .utils import (
23
+ build_url,
24
+ handle_api_errors,
25
+ handle_http_error,
26
+ map_httpx_exceptions,
27
+ parse_json_or_raise,
28
+ post_log,
29
+ pre_log,
30
+ raise_if_retryable_status,
31
+ setup_secure_logging_and_client,
32
+ )
29
33
 
30
34
  logger = logging.getLogger("bookalimo.transport")
31
35
 
@@ -46,7 +50,7 @@ class AsyncTransport(AsyncBaseTransport):
46
50
  backoff: float = DEFAULT_BACKOFF,
47
51
  ):
48
52
  self.base_url = base_url.rstrip("/")
49
- self.credentials = credentials
53
+ self._credentials = credentials
50
54
  self.retries = retries
51
55
  self.backoff = backoff
52
56
  self.headers = {
@@ -54,18 +58,28 @@ class AsyncTransport(AsyncBaseTransport):
54
58
  "user-agent": user_agent,
55
59
  }
56
60
 
57
- # Create client if not provided
58
- self._owns_client = client is None
59
- self.client = client or httpx.AsyncClient(timeout=timeouts)
61
+ # Setup secure logging and create client
62
+ self.client, self._owns_client = setup_secure_logging_and_client(
63
+ is_async=True, timeout=timeouts, client=client
64
+ )
60
65
 
61
66
  if logger.isEnabledFor(logging.DEBUG):
62
67
  logger.debug(
63
- "AsyncTransport initialized (base_url=%s, timeout=%s, user_agent=%s)",
64
- self.base_url,
68
+ "%s initialized (base_url=%s, timeout=%s, user_agent=%s)",
69
+ self.__class__.__name__,
70
+ redact_url(self.base_url),
65
71
  timeouts,
66
72
  user_agent,
67
73
  )
68
74
 
75
+ @property
76
+ def credentials(self) -> Optional[Credentials]:
77
+ return self._credentials
78
+
79
+ @credentials.setter
80
+ def credentials(self, credentials: Optional[Credentials]) -> None:
81
+ self._credentials = credentials
82
+
69
83
  @overload
70
84
  async def post(self, path: str, model: BaseModel) -> Any: ...
71
85
  @overload
@@ -75,30 +89,18 @@ class AsyncTransport(AsyncBaseTransport):
75
89
  self, path: str, model: BaseModel, response_model: Optional[type[T]] = None
76
90
  ) -> Union[T, Any]:
77
91
  """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}"
92
+ url = build_url(self.base_url, path)
81
93
 
82
- # Prepare data and inject credentials
83
94
  data = self.prepare_data(model)
84
95
  data = inject_credentials(data, self.credentials)
85
96
 
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()
97
+ req_id = uuid4().hex[:8] if logger.isEnabledFor(logging.DEBUG) else None
98
+ start = perf_counter() if req_id else 0.0
99
+ if req_id:
92
100
  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
- )
101
+ pre_log(url, body_keys, req_id)
99
102
 
100
- try:
101
- # Make request with retry logic
103
+ with map_httpx_exceptions(req_id, path):
102
104
  response = await async_retry(
103
105
  lambda: self._make_request(url, data),
104
106
  retries=self.retries,
@@ -110,121 +112,35 @@ class AsyncTransport(AsyncBaseTransport):
110
112
  ),
111
113
  )
112
114
 
113
- # Handle HTTP errors
114
115
  if response.status_code >= 400:
115
116
  handle_http_error(response, req_id, path)
116
117
 
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
118
+ json_data = parse_json_or_raise(response, path, req_id)
119
+
131
120
  handle_api_errors(json_data, req_id, path)
132
121
 
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
122
+ if req_id:
123
+ post_log(url, response, start, req_id)
124
+
153
125
  return (
154
126
  response_model.model_validate(json_data)
155
127
  if response_model
156
128
  else json_data
157
129
  )
158
130
 
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
131
  async def _make_request(self, url: str, data: dict[str, Any]) -> httpx.Response:
206
- """Make the actual HTTP request."""
207
132
  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
- )
133
+ raise_if_retryable_status(resp, should_retry_status)
215
134
  return resp
216
135
 
217
136
  async def aclose(self) -> None:
218
- """Close the HTTP client if we own it."""
219
137
  if self._owns_client and not self.client.is_closed:
220
138
  await self.client.aclose()
221
139
  if logger.isEnabledFor(logging.DEBUG):
222
- logger.debug("AsyncTransport HTTP client closed")
140
+ logger.debug("%s HTTP client closed", self.__class__.__name__)
223
141
 
224
142
  async def __aenter__(self) -> "AsyncTransport":
225
- """Async context manager entry."""
226
143
  return self
227
144
 
228
145
  async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
229
- """Async context manager exit."""
230
146
  await self.aclose()
@@ -15,17 +15,21 @@ from ..config import (
15
15
  DEFAULT_TIMEOUTS,
16
16
  DEFAULT_USER_AGENT,
17
17
  )
18
- from ..exceptions import (
19
- BookalimoConnectionError,
20
- BookalimoError,
21
- BookalimoHTTPError,
22
- BookalimoRequestError,
23
- BookalimoTimeout,
24
- )
18
+ from ..logging import redact_url
25
19
  from .auth import Credentials, inject_credentials
26
20
  from .base import BaseTransport
27
21
  from .retry import should_retry_exception, should_retry_status, sync_retry
28
- from .utils import handle_api_errors, handle_http_error
22
+ from .utils import (
23
+ build_url,
24
+ handle_api_errors,
25
+ handle_http_error,
26
+ map_httpx_exceptions,
27
+ parse_json_or_raise,
28
+ post_log,
29
+ pre_log,
30
+ raise_if_retryable_status,
31
+ setup_secure_logging_and_client,
32
+ )
29
33
 
30
34
  logger = logging.getLogger("bookalimo.transport")
31
35
 
@@ -54,14 +58,16 @@ class SyncTransport(BaseTransport):
54
58
  "user-agent": user_agent,
55
59
  }
56
60
 
57
- # Create client if not provided
58
- self._owns_client = client is None
59
- self.client = client or httpx.Client(timeout=timeouts)
61
+ # Setup secure logging and create client
62
+ self.client, self._owns_client = setup_secure_logging_and_client(
63
+ is_async=False, timeout=timeouts, client=client
64
+ )
60
65
 
61
66
  if logger.isEnabledFor(logging.DEBUG):
62
67
  logger.debug(
63
- "SyncTransport initialized (base_url=%s, timeout=%s, user_agent=%s)",
64
- self.base_url,
68
+ "%s initialized (base_url=%s, timeout=%s, user_agent=%s)",
69
+ self.__class__.__name__,
70
+ redact_url(self.base_url),
65
71
  timeouts,
66
72
  user_agent,
67
73
  )
@@ -76,28 +82,20 @@ class SyncTransport(BaseTransport):
76
82
  ) -> Union[T, Any]:
77
83
  """Make a POST request and return parsed response."""
78
84
  # Prepare URL
79
- path = path if path.startswith("/") else f"/{path}"
80
- url = f"{self.base_url}{path}"
85
+ url = build_url(self.base_url, path)
81
86
 
82
87
  # Prepare data and inject credentials
83
88
  data = self.prepare_data(model)
84
89
  data = inject_credentials(data, self.credentials)
85
90
 
86
91
  # 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
+ req_id = uuid4().hex[:8] if logger.isEnabledFor(logging.DEBUG) else None
93
+ start = perf_counter() if req_id else 0.0
94
+ if req_id:
92
95
  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
- )
96
+ pre_log(url, body_keys, req_id)
99
97
 
100
- try:
98
+ with map_httpx_exceptions(req_id, path):
101
99
  # Make request with retry logic
102
100
  response = sync_retry(
103
101
  lambda: self._make_request(url, data),
@@ -115,39 +113,14 @@ class SyncTransport(BaseTransport):
115
113
  handle_http_error(response, req_id, path)
116
114
 
117
115
  # 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
116
+ json_data = parse_json_or_raise(response, path, req_id)
129
117
 
130
118
  # Handle API-level errors
131
119
  handle_api_errors(json_data, req_id, path)
132
120
 
133
121
  # 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
- )
122
+ if req_id:
123
+ post_log(url, response, start, req_id)
151
124
 
152
125
  # Parse and return response
153
126
  return (
@@ -156,62 +129,10 @@ class SyncTransport(BaseTransport):
156
129
  else json_data
157
130
  )
158
131
 
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
132
  def _make_request(self, url: str, data: dict[str, Any]) -> httpx.Response:
206
133
  """Make the actual HTTP request."""
207
134
  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
- )
135
+ raise_if_retryable_status(resp, should_retry_status)
215
136
  return resp
216
137
 
217
138
  def close(self) -> None:
@@ -219,7 +140,7 @@ class SyncTransport(BaseTransport):
219
140
  if self._owns_client and not self.client.is_closed:
220
141
  self.client.close()
221
142
  if logger.isEnabledFor(logging.DEBUG):
222
- logger.debug("SyncTransport HTTP client closed")
143
+ logger.debug("%s HTTP client closed", self.__class__.__name__)
223
144
 
224
145
  def __enter__(self) -> "SyncTransport":
225
146
  """Context manager entry."""