statuspro-openapi-client 0.1.0__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 (74) hide show
  1. statuspro_openapi_client-0.1.0.dist-info/METADATA +337 -0
  2. statuspro_openapi_client-0.1.0.dist-info/RECORD +74 -0
  3. statuspro_openapi_client-0.1.0.dist-info/WHEEL +4 -0
  4. statuspro_openapi_client-0.1.0.dist-info/entry_points.txt +3 -0
  5. statuspro_openapi_client-0.1.0.dist-info/licenses/LICENSE +21 -0
  6. statuspro_public_api_client/__init__.py +36 -0
  7. statuspro_public_api_client/_logging.py +33 -0
  8. statuspro_public_api_client/api/__init__.py +1 -0
  9. statuspro_public_api_client/api/orders/__init__.py +1 -0
  10. statuspro_public_api_client/api/orders/add_order_comment.py +215 -0
  11. statuspro_public_api_client/api/orders/bulk_update_order_status.py +194 -0
  12. statuspro_public_api_client/api/orders/get_order.py +188 -0
  13. statuspro_public_api_client/api/orders/get_viable_statuses.py +193 -0
  14. statuspro_public_api_client/api/orders/list_orders.py +366 -0
  15. statuspro_public_api_client/api/orders/lookup_order.py +208 -0
  16. statuspro_public_api_client/api/orders/set_order_due_date.py +215 -0
  17. statuspro_public_api_client/api/orders/update_order_status.py +215 -0
  18. statuspro_public_api_client/api/statuses/__init__.py +1 -0
  19. statuspro_public_api_client/api/statuses/get_statuses.py +161 -0
  20. statuspro_public_api_client/api_wrapper/__init__.py +15 -0
  21. statuspro_public_api_client/api_wrapper/_namespace.py +40 -0
  22. statuspro_public_api_client/api_wrapper/_registry.py +43 -0
  23. statuspro_public_api_client/api_wrapper/_resource.py +116 -0
  24. statuspro_public_api_client/client.py +267 -0
  25. statuspro_public_api_client/client_types.py +54 -0
  26. statuspro_public_api_client/domain/__init__.py +33 -0
  27. statuspro_public_api_client/domain/base.py +117 -0
  28. statuspro_public_api_client/domain/converters.py +71 -0
  29. statuspro_public_api_client/domain/order.py +87 -0
  30. statuspro_public_api_client/domain/status.py +30 -0
  31. statuspro_public_api_client/errors.py +16 -0
  32. statuspro_public_api_client/helpers/__init__.py +21 -0
  33. statuspro_public_api_client/helpers/base.py +26 -0
  34. statuspro_public_api_client/helpers/orders.py +78 -0
  35. statuspro_public_api_client/helpers/statuses.py +37 -0
  36. statuspro_public_api_client/log_setup.py +99 -0
  37. statuspro_public_api_client/models/__init__.py +53 -0
  38. statuspro_public_api_client/models/add_order_comment_request.py +68 -0
  39. statuspro_public_api_client/models/bulk_status_update_request.py +124 -0
  40. statuspro_public_api_client/models/bulk_status_update_response.py +72 -0
  41. statuspro_public_api_client/models/customer.py +74 -0
  42. statuspro_public_api_client/models/error_response.py +58 -0
  43. statuspro_public_api_client/models/history_item.py +171 -0
  44. statuspro_public_api_client/models/list_orders_financial_status_item.py +15 -0
  45. statuspro_public_api_client/models/list_orders_fulfillment_status_item.py +11 -0
  46. statuspro_public_api_client/models/locale_translation.py +66 -0
  47. statuspro_public_api_client/models/mail_log.py +82 -0
  48. statuspro_public_api_client/models/message_response.py +58 -0
  49. statuspro_public_api_client/models/order_list_item.py +180 -0
  50. statuspro_public_api_client/models/order_list_meta.py +120 -0
  51. statuspro_public_api_client/models/order_list_response.py +81 -0
  52. statuspro_public_api_client/models/order_response.py +220 -0
  53. statuspro_public_api_client/models/progress_timeline_item.py +93 -0
  54. statuspro_public_api_client/models/set_due_date_request.py +95 -0
  55. statuspro_public_api_client/models/status.py +190 -0
  56. statuspro_public_api_client/models/status_definition.py +82 -0
  57. statuspro_public_api_client/models/status_translations.py +62 -0
  58. statuspro_public_api_client/models/update_order_status_request.py +92 -0
  59. statuspro_public_api_client/models/validation_error_response.py +81 -0
  60. statuspro_public_api_client/models/validation_error_response_errors.py +54 -0
  61. statuspro_public_api_client/models/viable_status.py +82 -0
  62. statuspro_public_api_client/models_pydantic/__init__.py +122 -0
  63. statuspro_public_api_client/models_pydantic/_auto_registry.py +115 -0
  64. statuspro_public_api_client/models_pydantic/_base.py +349 -0
  65. statuspro_public_api_client/models_pydantic/_generated/__init__.py +53 -0
  66. statuspro_public_api_client/models_pydantic/_generated/errors.py +24 -0
  67. statuspro_public_api_client/models_pydantic/_generated/orders.py +136 -0
  68. statuspro_public_api_client/models_pydantic/_generated/statuses.py +25 -0
  69. statuspro_public_api_client/models_pydantic/_registry.py +171 -0
  70. statuspro_public_api_client/models_pydantic/converters.py +184 -0
  71. statuspro_public_api_client/py.typed +1 -0
  72. statuspro_public_api_client/statuspro-openapi.yaml +859 -0
  73. statuspro_public_api_client/statuspro_client.py +1156 -0
  74. statuspro_public_api_client/utils.py +290 -0
@@ -0,0 +1,1156 @@
1
+ """
2
+ StatusProClient - The pythonic StatusPro API client with automatic resilience.
3
+
4
+ This client uses httpx's native transport layer to provide automatic retries,
5
+ rate limiting, error handling, and pagination for all API calls without any
6
+ decorators or wrapper methods needed.
7
+ """
8
+
9
+ import contextlib
10
+ import json
11
+ import logging
12
+ import netrc
13
+ import os
14
+ from collections.abc import Awaitable, Callable
15
+ from http import HTTPStatus
16
+ from pathlib import Path
17
+ from typing import TYPE_CHECKING, Any, cast
18
+ from urllib.parse import parse_qs, quote, urlencode, urlparse, urlunparse
19
+
20
+ if TYPE_CHECKING:
21
+ from .helpers.orders import Orders
22
+ from .helpers.statuses import Statuses
23
+
24
+ import httpx
25
+ from dotenv import load_dotenv
26
+ from httpx import AsyncHTTPTransport
27
+ from httpx_retries import Retry, RetryTransport
28
+
29
+ from ._logging import Logger
30
+ from .api_wrapper import ApiNamespace
31
+ from .client import AuthenticatedClient
32
+
33
+ # Patterns used to identify sensitive query parameters and body fields in logs.
34
+ # Values matching these patterns are redacted to prevent information disclosure.
35
+ # See also: statuspro_mcp_server/src/statuspro_mcp/logging.py filter_sensitive_data()
36
+ # for the MCP equivalent.
37
+ _SENSITIVE_PARAMS: frozenset[str] = frozenset(
38
+ {
39
+ "api_key",
40
+ "auth",
41
+ "authorization",
42
+ "credential",
43
+ "email",
44
+ "key",
45
+ "password",
46
+ "secret",
47
+ "token",
48
+ }
49
+ )
50
+
51
+ _REDACTED = "***"
52
+
53
+
54
+ def _is_sensitive(name: str) -> bool:
55
+ """Check if a parameter/field name matches any sensitive pattern."""
56
+ lower = name.lower()
57
+ return any(pattern in lower for pattern in _SENSITIVE_PARAMS)
58
+
59
+
60
+ def _sanitize_url(url: str) -> str:
61
+ """Redact sensitive query parameter values from a URL for safe logging."""
62
+ try:
63
+ parsed = urlparse(url)
64
+ if not parsed.query:
65
+ return url
66
+ params = parse_qs(parsed.query, keep_blank_values=True)
67
+ sanitized = {}
68
+ for k, values in params.items():
69
+ if _is_sensitive(k):
70
+ sanitized[k] = [_REDACTED]
71
+ else:
72
+ sanitized[k] = values
73
+ # Use urlencode with custom quote function that preserves * characters
74
+ clean_query = urlencode(
75
+ sanitized,
76
+ doseq=True,
77
+ quote_via=lambda s, safe="", encoding=None, errors=None: quote(
78
+ s, safe=safe + "*", encoding=encoding, errors=errors
79
+ ),
80
+ )
81
+ return urlunparse(parsed._replace(query=clean_query))
82
+ except Exception:
83
+ # If URL parsing fails, strip the query string entirely
84
+ base, _, _ = url.partition("?")
85
+ return f"{base}?{_REDACTED}"
86
+
87
+
88
+ def _sanitize_body(body: Any) -> Any:
89
+ """Redact sensitive field values from nested dict/list bodies for safe logging."""
90
+
91
+ def _sanitize_value(value: Any) -> Any:
92
+ """Recursively sanitize nested structures."""
93
+ if isinstance(value, dict):
94
+ return {
95
+ k: _REDACTED if _is_sensitive(k) else _sanitize_value(v)
96
+ for k, v in value.items()
97
+ }
98
+ if isinstance(value, list):
99
+ return [_sanitize_value(item) for item in value]
100
+ return value
101
+
102
+ if not isinstance(body, dict):
103
+ return "[non-dict body]"
104
+ return _sanitize_value(body)
105
+
106
+
107
+ class RateLimitAwareRetry(Retry):
108
+ """
109
+ Custom Retry class that allows non-idempotent methods (POST, PATCH) to be
110
+ retried ONLY when receiving a 429 (Too Many Requests) status code.
111
+
112
+ For all other retryable status codes (502, 503, 504), only idempotent methods
113
+ (HEAD, GET, PUT, DELETE, OPTIONS, TRACE) will be retried.
114
+
115
+ This ensures we don't accidentally retry non-idempotent operations after
116
+ server errors, but we DO retry them when we're being rate-limited.
117
+ """
118
+
119
+ # Idempotent methods that are always safe to retry
120
+ IDEMPOTENT_METHODS = frozenset(["HEAD", "GET", "PUT", "DELETE", "OPTIONS", "TRACE"])
121
+
122
+ def __init__(self, *args: Any, **kwargs: Any):
123
+ """Initialize and track the current request method."""
124
+ super().__init__(*args, **kwargs)
125
+ self._current_method: str | None = None
126
+
127
+ def is_retryable_method(self, method: str) -> bool:
128
+ """
129
+ Allow all methods to pass through the initial check.
130
+
131
+ Store the method for later use in is_retryable_status_code.
132
+ """
133
+ self._current_method = method.upper()
134
+ # Accept all methods - we'll filter in is_retryable_status_code
135
+ return self._current_method in self.allowed_methods
136
+
137
+ def is_retryable_status_code(self, status_code: int) -> bool:
138
+ """
139
+ Check if a status code is retryable for the current method.
140
+
141
+ For 429 (rate limiting), allow all methods.
142
+ For other errors (502, 503, 504), only allow idempotent methods.
143
+ """
144
+ # First check if the status code is in the allowed list at all
145
+ if status_code not in self.status_forcelist:
146
+ return False
147
+
148
+ # If we don't know the method, fall back to default behavior
149
+ if self._current_method is None:
150
+ return True
151
+
152
+ # Rate limiting (429) - retry all methods
153
+ if status_code == HTTPStatus.TOO_MANY_REQUESTS:
154
+ return True
155
+
156
+ # Other retryable errors - only retry idempotent methods
157
+ return self._current_method in self.IDEMPOTENT_METHODS
158
+
159
+ def increment(self) -> "RateLimitAwareRetry":
160
+ """Return a new retry instance with the attempt count incremented."""
161
+ # Call parent's increment which creates a new instance of our class
162
+ new_retry = cast(RateLimitAwareRetry, super().increment())
163
+ # Preserve the current method across retry attempts
164
+ new_retry._current_method = self._current_method
165
+ return new_retry
166
+
167
+
168
+ class ErrorLoggingTransport(AsyncHTTPTransport):
169
+ """
170
+ Transport layer that adds detailed error logging for 4xx client errors.
171
+
172
+ This transport wraps another AsyncHTTPTransport and intercepts responses
173
+ to log detailed error information using the generated error models.
174
+ """
175
+
176
+ def __init__(
177
+ self,
178
+ wrapped_transport: AsyncHTTPTransport | None = None,
179
+ logger: Logger | None = None,
180
+ **kwargs: Any,
181
+ ):
182
+ """
183
+ Initialize the error logging transport.
184
+
185
+ Args:
186
+ wrapped_transport: The transport to wrap. If None, creates a new AsyncHTTPTransport.
187
+ logger: Logger instance for capturing error details. If None, creates a default logger.
188
+ **kwargs: Additional arguments passed to AsyncHTTPTransport if wrapped_transport is None.
189
+ """
190
+ super().__init__()
191
+ if wrapped_transport is None:
192
+ wrapped_transport = AsyncHTTPTransport(**kwargs)
193
+ self._wrapped_transport = wrapped_transport
194
+ self.logger: Logger = logger or logging.getLogger(__name__)
195
+
196
+ async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
197
+ """Handle request and log detailed error information for 4xx responses."""
198
+ response = await self._wrapped_transport.handle_async_request(request)
199
+
200
+ # Log detailed information for 400-level client errors
201
+ if 400 <= response.status_code < 500:
202
+ await self._log_client_error(response, request)
203
+
204
+ return response
205
+
206
+ async def _log_client_error(
207
+ self, response: httpx.Response, request: httpx.Request
208
+ ) -> None:
209
+ """Log 4xx client errors.
210
+
211
+ StatusPro returns either ``ErrorResponse`` ({"message": str}) or
212
+ ``ValidationErrorResponse`` ({"message": str, "errors": {field: [str]}}).
213
+ We log the untyped JSON since both shapes are simple enough to render
214
+ directly without a dedicated typed model.
215
+ """
216
+ method = request.method
217
+ url = _sanitize_url(str(request.url))
218
+ status_code = response.status_code
219
+
220
+ request_body: Any = None
221
+ if request.content:
222
+ with contextlib.suppress(
223
+ json.JSONDecodeError, UnicodeDecodeError, AttributeError, TypeError
224
+ ):
225
+ request_body = json.loads(request.content.decode("utf-8"))
226
+
227
+ if hasattr(response, "aread"):
228
+ with contextlib.suppress(TypeError, AttributeError):
229
+ await response.aread()
230
+
231
+ try:
232
+ error_data = response.json()
233
+ except (json.JSONDecodeError, TypeError, ValueError):
234
+ self.logger.error(
235
+ f"Client error {status_code} for {method} {url} - "
236
+ f"Response: {getattr(response, 'text', '')[:500]}..."
237
+ )
238
+ return
239
+
240
+ prefix = (
241
+ f"Validation error 422 for {method} {url}"
242
+ if status_code == 422
243
+ else f"Client error {status_code} for {method} {url}"
244
+ )
245
+
246
+ if isinstance(error_data, dict):
247
+ message = error_data.get("message") or "(not provided)"
248
+ log_message = f"{prefix}\n Error: {message}"
249
+
250
+ errors = error_data.get("errors")
251
+ if isinstance(errors, dict) and errors:
252
+ log_message += f"\n Validation errors ({len(errors)} fields):"
253
+ for field, field_errors in errors.items():
254
+ sent_value = (
255
+ request_body.get(field)
256
+ if isinstance(request_body, dict)
257
+ else None
258
+ )
259
+ if sent_value is not None and _is_sensitive(str(field)):
260
+ sent_value = _REDACTED
261
+ log_message += f"\n - {field}: {field_errors}"
262
+ if sent_value is not None:
263
+ log_message += f"\n Sent: {sent_value!r}"
264
+ self.logger.error(log_message)
265
+ else:
266
+ self.logger.error(f"{prefix}\n Raw error: {_sanitize_body(error_data)}")
267
+
268
+
269
+ class PaginationTransport(AsyncHTTPTransport):
270
+ """
271
+ Transport layer that adds automatic pagination for GET requests.
272
+
273
+ Auto-pagination behavior (for StatusPro's ``page``/``per_page`` scheme):
274
+ - ON by default for GET requests with NO page parameter in URL
275
+ - Uses 100 items per page (StatusPro's max) when no ``per_page`` is specified
276
+ - If caller specifies ``per_page``, that value is respected
277
+ - ANY explicit ``page`` parameter disables auto-pagination
278
+ - Disabled when request has ``extensions={"auto_pagination": False}``
279
+ - Only applies to GET requests
280
+ - Only applies to wrapped list responses (``{"data": [...], "meta": {...}}``);
281
+ raw-array responses like ``/statuses`` are passed through unchanged.
282
+
283
+ Controlling pagination limits:
284
+ - ``max_pages`` (constructor): Maximum number of pages to fetch
285
+ - ``max_items`` (extension): Maximum total items to collect, e.g.,
286
+ ``extensions={"max_items": 200}``
287
+ """
288
+
289
+ def __init__(
290
+ self,
291
+ wrapped_transport: AsyncHTTPTransport | None = None,
292
+ max_pages: int = 100,
293
+ logger: Logger | None = None,
294
+ **kwargs: Any,
295
+ ):
296
+ """
297
+ Initialize the pagination transport.
298
+
299
+ Args:
300
+ wrapped_transport: The transport to wrap. If None, creates a new AsyncHTTPTransport.
301
+ max_pages: Maximum number of pages to collect during auto-pagination. Defaults to 100.
302
+ logger: Logger instance for capturing pagination operations. If None, creates a default logger.
303
+ **kwargs: Additional arguments passed to AsyncHTTPTransport if wrapped_transport is None.
304
+ """
305
+ # If no wrapped transport provided, create a base one
306
+ if wrapped_transport is None:
307
+ wrapped_transport = AsyncHTTPTransport(**kwargs)
308
+ super().__init__()
309
+ else:
310
+ super().__init__()
311
+
312
+ self._wrapped_transport = wrapped_transport
313
+ self.max_pages = max_pages
314
+ self.logger: Logger = logger or logging.getLogger(__name__)
315
+
316
+ async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
317
+ """Handle request with automatic pagination for GET requests.
318
+
319
+ Auto-pagination is ON by default for GET requests. It is disabled when:
320
+ - `extensions={"auto_pagination": False}` is set, OR
321
+ - ANY explicit `page` parameter is in the URL (e.g., `?page=1` or `?page=2`)
322
+
323
+ To get auto-pagination, simply don't pass a page parameter. The transport
324
+ will automatically use 100 items per page (StatusPro's max) unless you specify
325
+ a limit, in which case your limit will be respected.
326
+ """
327
+ # Check if auto-pagination is explicitly disabled via request extensions
328
+ auto_pagination = request.extensions.get("auto_pagination", True)
329
+
330
+ # ANY page param in URL disables auto-pagination - caller wants specific page
331
+ has_explicit_page = "page" in request.url.params
332
+
333
+ # Only paginate GET requests when auto_pagination is enabled and no explicit page
334
+ should_paginate = (
335
+ request.method == "GET" and auto_pagination and not has_explicit_page
336
+ )
337
+
338
+ if should_paginate:
339
+ return await self._handle_paginated_request(request)
340
+ else:
341
+ # For non-paginated requests, just pass through to wrapped transport
342
+ return await self._wrapped_transport.handle_async_request(request)
343
+
344
+ async def _handle_paginated_request(self, request: httpx.Request) -> httpx.Response:
345
+ """
346
+ Handle paginated requests by automatically collecting all pages.
347
+
348
+ This method detects paginated responses and automatically collects all available
349
+ pages up to the configured maximum. It preserves the original request structure
350
+ while combining data from multiple pages.
351
+
352
+ Args:
353
+ request: The HTTP request to handle (must be a GET request).
354
+
355
+ Returns:
356
+ A combined HTTP response containing data from all collected pages with
357
+ pagination metadata in the response body.
358
+
359
+ Note:
360
+ - Auto-pagination is ON by default for all GET requests
361
+ - If response has no pagination info, returns the single response as-is
362
+ - The response contains an 'auto_paginated' flag in the pagination metadata
363
+ - Data from all pages is combined into a single 'data' array
364
+ - Use `extensions={"max_items": N}` to limit total items collected
365
+ """
366
+ all_data: list[Any] = []
367
+ current_page = 1
368
+ total_pages: int | None = None
369
+ page_num = 1
370
+ response: httpx.Response | None = None
371
+ original_is_raw_list = False
372
+
373
+ # Get max_items limit from extensions (None = unlimited)
374
+ max_items: int | None = request.extensions.get("max_items")
375
+
376
+ # Parse initial parameters, preserving multi-value query params
377
+ # (e.g., tags[]=a&tags[]=b). Using multi_items() instead of dict()
378
+ # to avoid losing duplicate keys.
379
+ base_params = [
380
+ (k, v)
381
+ for k, v in request.url.params.multi_items()
382
+ if k not in ("page", "per_page")
383
+ ]
384
+
385
+ # Get caller's per_page or default to 100 (StatusPro's max)
386
+ original_per_page = request.url.params.get("per_page")
387
+ try:
388
+ page_size = int(original_per_page) if original_per_page else 100
389
+ if page_size <= 0:
390
+ self.logger.warning(
391
+ "Invalid per_page parameter %r (must be positive), using 100",
392
+ original_per_page,
393
+ )
394
+ page_size = 100
395
+ elif page_size > 100:
396
+ self.logger.warning(
397
+ "per_page %d exceeds StatusPro's max of 100, clamping",
398
+ page_size,
399
+ )
400
+ page_size = 100
401
+ except (ValueError, TypeError):
402
+ self.logger.warning(
403
+ "Invalid per_page parameter %r, using 100", original_per_page
404
+ )
405
+ page_size = 100
406
+
407
+ self.logger.info("Auto-paginating request: %s", _sanitize_url(str(request.url)))
408
+
409
+ for page_num in range(1, self.max_pages + 1):
410
+ # Determine per_page for this request
411
+ if max_items is not None:
412
+ remaining = max_items - len(all_data)
413
+ if remaining <= 0:
414
+ break
415
+ current_per_page = str(min(page_size, remaining))
416
+ else:
417
+ current_per_page = str(page_size)
418
+
419
+ # Build params with updated page/per_page, preserving multi-value params
420
+ url_params = [
421
+ *base_params,
422
+ ("page", str(page_num)),
423
+ ("per_page", current_per_page),
424
+ ]
425
+
426
+ # Create a new request with updated parameters
427
+ paginated_request = httpx.Request(
428
+ method=request.method,
429
+ url=request.url.copy_with(params=url_params),
430
+ headers=request.headers,
431
+ content=request.content,
432
+ extensions=request.extensions,
433
+ )
434
+
435
+ # Make the request using the wrapped transport
436
+ response = await self._wrapped_transport.handle_async_request(
437
+ paginated_request
438
+ )
439
+
440
+ if response.status_code != 200:
441
+ # If we get an error, return the original response
442
+ return response
443
+
444
+ # Parse the response
445
+ try:
446
+ # Read the response content if it's streaming
447
+ if hasattr(response, "aread"):
448
+ with contextlib.suppress(TypeError, AttributeError):
449
+ # Skip aread if it's not async (e.g., in tests with mocks)
450
+ await response.aread()
451
+
452
+ data = response.json()
453
+
454
+ # Track original response format on first page
455
+ if page_num == 1:
456
+ original_is_raw_list = isinstance(data, list)
457
+
458
+ # Extract pagination info from headers or response body
459
+ pagination_info = self._extract_pagination_info(response, data)
460
+
461
+ if pagination_info:
462
+ current_page = pagination_info.get("page", page_num)
463
+ total_pages = pagination_info.get("total_pages")
464
+
465
+ # Extract the actual data items
466
+ if isinstance(data, list):
467
+ items = data
468
+ else:
469
+ items = data.get("data", [])
470
+ all_data.extend(items)
471
+
472
+ # Check max_items limit
473
+ if max_items is not None and len(all_data) >= max_items:
474
+ all_data = all_data[:max_items] # Truncate to exact limit
475
+ self.logger.info(
476
+ "Reached max_items limit (%d), stopping pagination",
477
+ max_items,
478
+ )
479
+ break
480
+
481
+ # Check if we're done
482
+ # Break if we've reached the last known page or got an empty page
483
+ if (total_pages and current_page >= total_pages) or len(items) == 0:
484
+ break
485
+
486
+ self.logger.debug(
487
+ "Collected page %s/%s, items: %d, total so far: %d",
488
+ current_page,
489
+ total_pages or "?",
490
+ len(items),
491
+ len(all_data),
492
+ )
493
+ else:
494
+ # No pagination info - return response preserving its shape
495
+ self.logger.info(
496
+ "No pagination info found, returning single-page response"
497
+ )
498
+ # Apply max_items truncation if set
499
+ if max_items is not None:
500
+ if isinstance(data, list) and len(data) > max_items:
501
+ truncated = json.dumps(data[:max_items]).encode()
502
+ headers = dict(response.headers)
503
+ headers.pop("content-encoding", None)
504
+ headers.pop("content-length", None)
505
+ return httpx.Response(
506
+ status_code=200,
507
+ headers=headers,
508
+ content=truncated,
509
+ request=request,
510
+ )
511
+ if isinstance(data, dict) and "data" in data:
512
+ items = data["data"]
513
+ if isinstance(items, list) and len(items) > max_items:
514
+ data["data"] = items[:max_items]
515
+ truncated = json.dumps(data).encode()
516
+ headers = dict(response.headers)
517
+ headers.pop("content-encoding", None)
518
+ headers.pop("content-length", None)
519
+ return httpx.Response(
520
+ status_code=200,
521
+ headers=headers,
522
+ content=truncated,
523
+ request=request,
524
+ )
525
+ return response
526
+
527
+ except (json.JSONDecodeError, KeyError) as e:
528
+ self.logger.warning("Failed to parse paginated response: %s", e)
529
+ return response
530
+
531
+ # Ensure we have a response at this point
532
+ if response is None:
533
+ msg = "No response available after pagination"
534
+ raise RuntimeError(msg)
535
+
536
+ # Create a combined response, preserving the original response shape
537
+ if original_is_raw_list:
538
+ # Original endpoint returned a raw JSON list - preserve that format
539
+ combined_content = json.dumps(all_data).encode()
540
+ else:
541
+ combined_data: dict[str, Any] = {"data": all_data}
542
+ # Add pagination metadata
543
+ if total_pages:
544
+ combined_data["pagination"] = {
545
+ "total_pages": total_pages,
546
+ "collected_pages": page_num,
547
+ "total_items": len(all_data),
548
+ "auto_paginated": True,
549
+ }
550
+ combined_content = json.dumps(combined_data).encode()
551
+
552
+ # Remove content-encoding headers to avoid compression issues
553
+ headers = dict(response.headers)
554
+ headers.pop("content-encoding", None)
555
+ headers.pop("content-length", None) # Will be recalculated
556
+
557
+ combined_response = httpx.Response(
558
+ status_code=200,
559
+ headers=headers,
560
+ content=combined_content,
561
+ request=request,
562
+ )
563
+
564
+ self.logger.info(
565
+ "Auto-pagination complete: collected %d items from %d pages",
566
+ len(all_data),
567
+ page_num,
568
+ )
569
+
570
+ return combined_response
571
+
572
+ def _normalize_pagination_values(
573
+ self, pagination_info: dict[str, Any]
574
+ ) -> dict[str, Any]:
575
+ """Convert pagination values from strings to appropriate Python types.
576
+
577
+ JSON parsing may return numeric values as strings (e.g., "41" instead of 41).
578
+ String comparison produces incorrect results: "5" >= "41" is True because
579
+ "5" > "4" lexicographically. This method ensures all numeric pagination
580
+ fields are proper integers for correct comparisons.
581
+
582
+ Additionally, boolean fields like first_page and last_page may come as
583
+ string values ("true"/"false") and are converted to Python booleans.
584
+
585
+ Args:
586
+ pagination_info: Dictionary containing pagination metadata.
587
+
588
+ Returns:
589
+ Dictionary with numeric fields converted to integers and boolean
590
+ fields converted to booleans.
591
+ """
592
+ # Fields that should be integers for pagination comparisons
593
+ numeric_fields = [
594
+ "page",
595
+ "total_pages",
596
+ "total_items",
597
+ "limit",
598
+ "offset",
599
+ "count",
600
+ "per_page",
601
+ "current_page",
602
+ "total_records",
603
+ ]
604
+
605
+ # Fields that should be booleans (API returns "true"/"false" strings)
606
+ boolean_fields = [
607
+ "first_page",
608
+ "last_page",
609
+ ]
610
+
611
+ result = pagination_info.copy()
612
+
613
+ # Convert numeric fields
614
+ for field in numeric_fields:
615
+ if field in result:
616
+ value = result[field]
617
+ # Convert string numbers to integers
618
+ if isinstance(value, str):
619
+ try:
620
+ result[field] = int(value)
621
+ except ValueError:
622
+ self.logger.warning(
623
+ "Invalid pagination value for %s: %r, removing field",
624
+ field,
625
+ value,
626
+ )
627
+ # Remove invalid field so fallback values are used
628
+ del result[field]
629
+ # Already an int or float - ensure it's int
630
+ elif isinstance(value, float):
631
+ # Warn if float has a fractional part (unexpected for pagination)
632
+ if value != int(value):
633
+ self.logger.warning(
634
+ "Pagination value %s has fractional part: %r, truncating to %d",
635
+ field,
636
+ value,
637
+ int(value),
638
+ )
639
+ result[field] = int(value)
640
+ # If it's already an int, leave it as is
641
+
642
+ # Convert boolean fields ("true"/"false" strings to Python booleans)
643
+ for field in boolean_fields:
644
+ if field in result:
645
+ value = result[field]
646
+ if isinstance(value, str):
647
+ lower_value = value.lower()
648
+ if lower_value == "true":
649
+ result[field] = True
650
+ elif lower_value == "false":
651
+ result[field] = False
652
+ else:
653
+ self.logger.warning(
654
+ "Invalid boolean pagination value for %s: %r, removing field",
655
+ field,
656
+ value,
657
+ )
658
+ del result[field]
659
+ elif not isinstance(value, bool):
660
+ # Unexpected type - convert truthy/falsy to bool
661
+ result[field] = bool(value)
662
+
663
+ return result
664
+
665
+ def _extract_pagination_info(
666
+ self, response: httpx.Response, data: dict[str, Any]
667
+ ) -> dict[str, Any] | None:
668
+ """Extract pagination information from response headers or body.
669
+
670
+ Note:
671
+ All numeric pagination values (page, total_pages, total_items, etc.)
672
+ are converted to integers to ensure correct comparisons. This is important
673
+ because JSON parsing may return string values, and string comparison
674
+ (e.g., "5" >= "41") produces incorrect results.
675
+ """
676
+ pagination_info: dict[str, Any] = {}
677
+
678
+ # Check for X-Pagination header (JSON format)
679
+ if "X-Pagination" in response.headers:
680
+ try:
681
+ header_data = json.loads(response.headers["X-Pagination"])
682
+ # Validate that parsed JSON is a dictionary
683
+ if not isinstance(header_data, dict):
684
+ self.logger.warning(
685
+ "X-Pagination header is not a JSON object: %r", header_data
686
+ )
687
+ else:
688
+ # Convert numeric string values to integers to avoid string comparison bugs
689
+ # (e.g., "5" >= "41" is True in string comparison but should be False)
690
+ pagination_info = self._normalize_pagination_values(header_data)
691
+ # Only return early if we got valid pagination data
692
+ if pagination_info:
693
+ return pagination_info
694
+ except json.JSONDecodeError:
695
+ pass
696
+
697
+ # Check for individual headers (with validation for malformed values)
698
+ if "X-Total-Pages" in response.headers:
699
+ try:
700
+ pagination_info["total_pages"] = int(response.headers["X-Total-Pages"])
701
+ except ValueError:
702
+ self.logger.warning(
703
+ "Invalid X-Total-Pages header value: %s",
704
+ response.headers["X-Total-Pages"],
705
+ )
706
+ if "X-Current-Page" in response.headers:
707
+ try:
708
+ pagination_info["page"] = int(response.headers["X-Current-Page"])
709
+ except ValueError:
710
+ self.logger.warning(
711
+ "Invalid X-Current-Page header value: %s",
712
+ response.headers["X-Current-Page"],
713
+ )
714
+
715
+ # Check for pagination in response body
716
+ if isinstance(data, dict):
717
+ if "pagination" in data and isinstance(data["pagination"], dict):
718
+ pagination_info.update(
719
+ {str(k): v for k, v in data["pagination"].items()}
720
+ )
721
+ elif "meta" in data and isinstance(data["meta"], dict):
722
+ meta = data["meta"]
723
+ # StatusPro shape: meta has current_page, last_page, per_page, total
724
+ if "current_page" in meta or "last_page" in meta:
725
+ pagination_info.update({str(k): v for k, v in meta.items()})
726
+ # Map StatusPro field names to the internal canonical names so
727
+ # the downstream stop-condition check in _handle_paginated_request
728
+ # continues to use `page` / `total_pages`.
729
+ if "current_page" in meta:
730
+ pagination_info["page"] = meta["current_page"]
731
+ if "last_page" in meta:
732
+ pagination_info["total_pages"] = meta["last_page"]
733
+ # Alternate nested pagination shape: meta.pagination
734
+ elif "pagination" in meta and isinstance(meta["pagination"], dict):
735
+ pagination_info.update(
736
+ {str(k): v for k, v in meta["pagination"].items()}
737
+ )
738
+
739
+ # Normalize all numeric values to ensure correct comparisons
740
+ if pagination_info:
741
+ pagination_info = self._normalize_pagination_values(pagination_info)
742
+
743
+ return pagination_info if pagination_info else None
744
+
745
+
746
+ def ResilientAsyncTransport(
747
+ max_retries: int = 5,
748
+ max_pages: int = 100,
749
+ logger: Logger | None = None,
750
+ **kwargs: Any,
751
+ ) -> RetryTransport:
752
+ """
753
+ Factory function that creates a chained transport with error logging,
754
+ pagination, and retry capabilities.
755
+
756
+ This function chains multiple transport layers:
757
+ 1. AsyncHTTPTransport (base HTTP transport)
758
+ 2. ErrorLoggingTransport (logs detailed 4xx errors)
759
+ 3. PaginationTransport (auto-collects paginated responses)
760
+ 4. RetryTransport (handles retries with Retry-After header support)
761
+
762
+ Args:
763
+ max_retries: Maximum number of retry attempts for failed requests. Defaults to 5.
764
+ max_pages: Maximum number of pages to collect during auto-pagination. Defaults to 100.
765
+ logger: Logger instance for capturing operations. If None, creates a default logger.
766
+ **kwargs: Additional arguments passed to the base AsyncHTTPTransport.
767
+ Common parameters include:
768
+ - http2 (bool): Enable HTTP/2 support
769
+ - limits (httpx.Limits): Connection pool limits
770
+ - verify (bool | str | ssl.SSLContext): SSL certificate verification
771
+ - cert (str | tuple): Client-side certificates
772
+ - trust_env (bool): Trust environment variables for proxy configuration
773
+
774
+ Returns:
775
+ A RetryTransport instance wrapping all the layered transports.
776
+
777
+ Note:
778
+ When using a custom transport, parameters like http2, limits, and verify
779
+ must be passed to this factory function (which passes them to the base
780
+ AsyncHTTPTransport), not to the httpx.Client/AsyncClient constructor.
781
+
782
+ Example:
783
+ ```python
784
+ transport = ResilientAsyncTransport(max_retries=3, max_pages=50)
785
+ async with httpx.AsyncClient(transport=transport) as client:
786
+ response = await client.get("https://api.example.com/items")
787
+ ```
788
+ """
789
+ resolved_logger: Logger = (
790
+ logger if logger is not None else logging.getLogger(__name__)
791
+ )
792
+
793
+ # Build the transport chain from inside out:
794
+ # 1. Base AsyncHTTPTransport
795
+ base_transport = AsyncHTTPTransport(**kwargs)
796
+
797
+ # 2. Wrap with error logging
798
+ error_logging_transport = ErrorLoggingTransport(
799
+ wrapped_transport=base_transport,
800
+ logger=resolved_logger,
801
+ )
802
+
803
+ # 3. Wrap with pagination
804
+ pagination_transport = PaginationTransport(
805
+ wrapped_transport=error_logging_transport,
806
+ max_pages=max_pages,
807
+ logger=resolved_logger,
808
+ )
809
+
810
+ # Finally wrap with retry logic (outermost layer)
811
+ # Use RateLimitAwareRetry which:
812
+ # - Retries ALL methods (including POST/PATCH) for 429 rate limiting
813
+ # - Retries ONLY idempotent methods for server errors (502, 503, 504)
814
+ retry = RateLimitAwareRetry(
815
+ total=max_retries,
816
+ backoff_factor=1.0, # Exponential backoff: 1, 2, 4, 8, 16 seconds
817
+ respect_retry_after_header=True, # Honor server's Retry-After header
818
+ status_forcelist=[
819
+ 429,
820
+ 502,
821
+ 503,
822
+ 504,
823
+ ], # Status codes that should trigger retries
824
+ allowed_methods=[
825
+ "HEAD",
826
+ "GET",
827
+ "PUT",
828
+ "DELETE",
829
+ "OPTIONS",
830
+ "TRACE",
831
+ "POST",
832
+ "PATCH",
833
+ ],
834
+ )
835
+ retry_transport = RetryTransport(
836
+ transport=pagination_transport,
837
+ retry=retry,
838
+ )
839
+
840
+ return retry_transport
841
+
842
+
843
+ class StatusProClient(AuthenticatedClient):
844
+ """The pythonic StatusPro API client with automatic resilience and pagination.
845
+
846
+ Inherits from ``AuthenticatedClient`` and can be passed directly to
847
+ generated API methods without a ``.client`` property.
848
+
849
+ Features:
850
+ - Automatic retries on network errors and server errors (5xx)
851
+ - Automatic rate-limit handling (parses ``Retry-After``, falls back to
852
+ exponential backoff on 429 since StatusPro doesn't emit the header)
853
+ - Auto-pagination for wrapped list endpoints (``{"data": [...], "meta": {...}}``)
854
+ - Uses 100 items per page (StatusPro's max) by default
855
+ - Raw-array endpoints (``/statuses``, ``/orders/{id}/viable-statuses``) are passed through
856
+ - Rich logging and observability
857
+
858
+ Auto-pagination behavior:
859
+ - ON by default for GET requests with no ``page`` parameter
860
+ - ``per_page`` defaults to 100; caller values are respected (capped at 100)
861
+ - ANY explicit ``page`` param disables auto-pagination
862
+ - Disabled per-request via ``extensions={"auto_pagination": False}``
863
+ - ``max_pages`` constructor argument caps total pages collected
864
+ - ``extensions={"max_items": N}`` caps total items collected
865
+
866
+ Usage:
867
+ async with StatusProClient() as client:
868
+ from statuspro_public_api_client.api.orders import list_orders
869
+
870
+ response = await list_orders.asyncio_detailed(client=client)
871
+
872
+ # One specific page (disables auto-pagination)
873
+ response = await list_orders.asyncio_detailed(
874
+ client=client, page=2, per_page=25
875
+ )
876
+ """
877
+
878
+ @staticmethod
879
+ def _read_from_netrc(base_url: str) -> str | None:
880
+ """
881
+ Read API key from ~/.netrc file.
882
+
883
+ Args:
884
+ base_url: The base URL to extract the hostname from.
885
+
886
+ Returns:
887
+ The API key (password field) from netrc, or None if not found.
888
+
889
+ Note:
890
+ The password field in netrc is used to store the API token since
891
+ StatusPro API uses bearer token authentication, not HTTP Basic Auth.
892
+ """
893
+ try:
894
+ # Extract hostname from base_url - handle both full URLs and bare hostnames
895
+ parsed = urlparse(base_url)
896
+ host: str | None = None
897
+
898
+ if parsed.hostname:
899
+ # URL with scheme (e.g., "https://app.orderstatuspro.com/api/v1")
900
+ host = parsed.hostname
901
+ else:
902
+ # Try parsing as URL without scheme (e.g., "app.orderstatuspro.com/api/v1")
903
+ parsed_with_scheme = urlparse(f"https://{base_url}")
904
+ if parsed_with_scheme.hostname:
905
+ host = parsed_with_scheme.hostname
906
+ else:
907
+ # Final fallback: treat as bare hostname (e.g., "api.example.com")
908
+ # Extract just the hostname part before any path
909
+ host = base_url.split("/")[0] if base_url else None
910
+
911
+ # If we couldn't extract a valid hostname, return None
912
+ if not host:
913
+ return None
914
+
915
+ netrc_path = Path.home() / ".netrc"
916
+ if not netrc_path.exists():
917
+ return None
918
+
919
+ # Warn if .netrc is readable by group or others (POSIX only)
920
+ if os.name != "nt":
921
+ mode = netrc_path.stat().st_mode
922
+ if mode & 0o077:
923
+ import warnings
924
+
925
+ warnings.warn(
926
+ f"~/.netrc has insecure permissions ({oct(mode & 0o777)}). "
927
+ "This may expose your API key. Run: chmod 600 ~/.netrc",
928
+ stacklevel=2,
929
+ )
930
+
931
+ auth = netrc.netrc(str(netrc_path))
932
+ authenticators = auth.authenticators(host)
933
+
934
+ if authenticators:
935
+ # Return password field (which contains our API token)
936
+ # netrc returns (login, account, password)
937
+ _login, _account, password = authenticators
938
+ return password
939
+ except (FileNotFoundError, netrc.NetrcParseError, OSError):
940
+ # Silently ignore netrc errors - it's an optional source
941
+ pass
942
+
943
+ return None
944
+
945
+ def __init__(
946
+ self,
947
+ api_key: str | None = None,
948
+ base_url: str | None = None,
949
+ timeout: float = 30.0,
950
+ max_retries: int = 5,
951
+ max_pages: int = 100,
952
+ logger: Logger | None = None,
953
+ **httpx_kwargs: Any,
954
+ ):
955
+ """
956
+ Initialize the StatusPro API client with automatic resilience features.
957
+
958
+ Args:
959
+ api_key: StatusPro API key. If None, will try to load from STATUSPRO_API_KEY env var,
960
+ .env file, or ~/.netrc file (in that order).
961
+ base_url: Base URL for the StatusPro API. Defaults to https://app.orderstatuspro.com/api/v1
962
+ timeout: Request timeout in seconds. Defaults to 30.0.
963
+ max_retries: Maximum number of retry attempts for failed requests. Defaults to 5.
964
+ max_pages: Maximum number of pages to collect during auto-pagination. Defaults to 100.
965
+ logger: Any object whose debug/info/warning/error methods accept
966
+ (msg, *args, **kwargs) — the standard logging.Logger call convention
967
+ (e.g. logging.Logger, structlog.BoundLogger). If None, creates a
968
+ default stdlib logger.
969
+ **httpx_kwargs: Additional arguments passed to the base AsyncHTTPTransport.
970
+ Common parameters include:
971
+ - http2 (bool): Enable HTTP/2 support
972
+ - limits (httpx.Limits): Connection pool limits
973
+ - verify (bool | str | ssl.SSLContext): SSL certificate verification
974
+ - cert (str | tuple): Client-side certificates
975
+ - trust_env (bool): Trust environment variables for proxy configuration
976
+ - event_hooks (dict): Custom event hooks (will be merged with built-in hooks)
977
+
978
+ Raises:
979
+ ValueError: If no API key is provided via api_key param, STATUSPRO_API_KEY env var,
980
+ .env file, or ~/.netrc file.
981
+
982
+ Note:
983
+ Transport-related parameters (http2, limits, verify, etc.) are correctly
984
+ passed to the innermost AsyncHTTPTransport layer, ensuring they take effect
985
+ even with the layered transport architecture.
986
+
987
+ Example:
988
+ >>> async with StatusProClient() as client:
989
+ ... # All API calls through client get automatic resilience
990
+ ... response = await some_api_method.asyncio_detailed(client=client)
991
+ """
992
+ load_dotenv()
993
+
994
+ # Handle backwards compatibility: accept 'token' kwarg as alias for 'api_key'
995
+ if "token" in httpx_kwargs:
996
+ if api_key is not None:
997
+ raise ValueError("Cannot specify both 'api_key' and 'token' parameters")
998
+ api_key = httpx_kwargs.pop("token")
999
+
1000
+ # Determine base_url early so we can use it for netrc lookup
1001
+ base_url = (
1002
+ base_url
1003
+ or os.getenv("STATUSPRO_BASE_URL")
1004
+ or "https://app.orderstatuspro.com/api/v1"
1005
+ )
1006
+
1007
+ # Setup credentials with priority: param > env (including .env) > netrc
1008
+ api_key = (
1009
+ api_key or os.getenv("STATUSPRO_API_KEY") or self._read_from_netrc(base_url)
1010
+ )
1011
+
1012
+ if not api_key:
1013
+ raise ValueError(
1014
+ "API key required via: api_key param, STATUSPRO_API_KEY env var, "
1015
+ ".env file, or ~/.netrc"
1016
+ )
1017
+
1018
+ self.logger: Logger = logger or logging.getLogger(__name__)
1019
+ self.max_pages = max_pages
1020
+
1021
+ # Warn if SSL verification is disabled — risk of MITM attacks
1022
+ if httpx_kwargs.get("verify") is False:
1023
+ self.logger.warning(
1024
+ "SSL certificate verification is disabled (verify=False). "
1025
+ "This exposes the connection to MITM attacks. "
1026
+ "Only use this for local development."
1027
+ )
1028
+
1029
+ # Domain helper instances (lazy-loaded via properties)
1030
+ self._orders: Orders | None = None
1031
+ self._statuses: Statuses | None = None
1032
+ self._api_namespace: ApiNamespace | None = None
1033
+
1034
+ # Extract client-level parameters that shouldn't go to the transport
1035
+ # Event hooks for observability - start with our defaults
1036
+ event_hooks: dict[str, list[Callable[[httpx.Response], Awaitable[None]]]] = {
1037
+ "response": [
1038
+ self._capture_pagination_metadata,
1039
+ self._log_response_metrics,
1040
+ ]
1041
+ }
1042
+
1043
+ # Extract and merge user hooks
1044
+ user_hooks = httpx_kwargs.pop("event_hooks", {})
1045
+ for event, hooks in user_hooks.items():
1046
+ # Normalize to list and add to existing or create new event
1047
+ hook_list = cast(
1048
+ list[Callable[[httpx.Response], Awaitable[None]]],
1049
+ hooks if isinstance(hooks, list) else [hooks],
1050
+ )
1051
+ if event in event_hooks:
1052
+ event_hooks[event].extend(hook_list)
1053
+ else:
1054
+ event_hooks[event] = hook_list
1055
+
1056
+ # Check if user wants to override the transport entirely
1057
+ custom_transport = httpx_kwargs.pop("transport", None) or httpx_kwargs.pop(
1058
+ "async_transport", None
1059
+ )
1060
+
1061
+ if custom_transport:
1062
+ # User provided a custom transport, use it as-is
1063
+ transport = custom_transport
1064
+ else:
1065
+ # Separate transport-specific kwargs from client-specific kwargs
1066
+ # Client-specific params that should NOT go to the transport
1067
+ client_only_params = ["headers", "cookies", "params", "auth"]
1068
+ client_kwargs = {
1069
+ k: httpx_kwargs.pop(k)
1070
+ for k in list(httpx_kwargs.keys())
1071
+ if k in client_only_params
1072
+ }
1073
+
1074
+ # Create resilient transport with remaining transport-specific httpx_kwargs
1075
+ # These will be passed to the base AsyncHTTPTransport (http2, limits, verify, etc.)
1076
+ transport = ResilientAsyncTransport(
1077
+ max_retries=max_retries,
1078
+ max_pages=max_pages,
1079
+ logger=self.logger,
1080
+ **httpx_kwargs, # Pass through http2, limits, verify, cert, trust_env, etc.
1081
+ )
1082
+
1083
+ # Put client-specific params back into httpx_kwargs for the parent class
1084
+ httpx_kwargs.update(client_kwargs)
1085
+
1086
+ # Initialize the parent AuthenticatedClient
1087
+ super().__init__(
1088
+ base_url=base_url,
1089
+ token=api_key,
1090
+ timeout=httpx.Timeout(timeout),
1091
+ httpx_args={
1092
+ "transport": transport,
1093
+ "event_hooks": event_hooks,
1094
+ **httpx_kwargs, # Include any remaining client-level kwargs
1095
+ },
1096
+ )
1097
+
1098
+ # Remove the client property since we inherit from AuthenticatedClient
1099
+ # Users can now pass the StatusProClient instance directly to API methods
1100
+
1101
+ # Domain properties for ergonomic access
1102
+ @property
1103
+ def orders(self) -> "Orders":
1104
+ """Access order operations (list, lookup, get, update status, etc.)."""
1105
+ from .helpers.orders import Orders
1106
+
1107
+ if self._orders is None:
1108
+ self._orders = Orders(self)
1109
+ return self._orders
1110
+
1111
+ @property
1112
+ def statuses(self) -> "Statuses":
1113
+ """Access status catalog operations."""
1114
+ from .helpers.statuses import Statuses
1115
+
1116
+ if self._statuses is None:
1117
+ self._statuses = Statuses(self)
1118
+ return self._statuses
1119
+
1120
+ @property
1121
+ def api(self) -> ApiNamespace:
1122
+ """Thin CRUD wrappers for all API resources. Returns raw attrs models."""
1123
+ if self._api_namespace is None:
1124
+ self._api_namespace = ApiNamespace(self)
1125
+ return self._api_namespace
1126
+
1127
+ # Event hooks for observability
1128
+ async def _capture_pagination_metadata(self, response: httpx.Response) -> None:
1129
+ """Capture and store pagination metadata from response headers."""
1130
+ if response.status_code == 200:
1131
+ x_pagination = response.headers.get("X-Pagination")
1132
+ if x_pagination:
1133
+ try:
1134
+ pagination_info = json.loads(x_pagination)
1135
+ self.logger.debug(f"Pagination metadata: {pagination_info}")
1136
+ # Store pagination info for easy access
1137
+ setattr(response, "pagination_info", pagination_info) # noqa: B010
1138
+ except json.JSONDecodeError:
1139
+ self.logger.warning(f"Invalid X-Pagination header: {x_pagination}")
1140
+
1141
+ async def _log_response_metrics(self, response: httpx.Response) -> None:
1142
+ """Log response metrics for observability."""
1143
+ # Extract timing info if available (after response is read)
1144
+ try:
1145
+ if hasattr(response, "elapsed"):
1146
+ duration = response.elapsed.total_seconds()
1147
+ else:
1148
+ duration = 0.0
1149
+ except RuntimeError:
1150
+ # elapsed not available yet
1151
+ duration = 0.0
1152
+
1153
+ self.logger.debug(
1154
+ f"Response: {response.status_code} {response.request.method} "
1155
+ f"{_sanitize_url(str(response.request.url))} ({duration:.2f}s)"
1156
+ )