redblack-python-sdk 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.
redblack/errors.py ADDED
@@ -0,0 +1,483 @@
1
+ """Public exception hierarchy and defensive API-error parsing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from collections.abc import Iterable, Mapping
8
+ from dataclasses import dataclass, field
9
+ from types import MappingProxyType
10
+ from typing import TYPE_CHECKING, Any
11
+ from urllib.parse import urlsplit, urlunsplit
12
+
13
+ if TYPE_CHECKING:
14
+ import httpx
15
+
16
+ _MAX_ERROR_BODY = 65_536
17
+ _SENSITIVE_HEADERS = {
18
+ "authorization",
19
+ "client-id",
20
+ "cookie",
21
+ "proxy-authorization",
22
+ "set-cookie",
23
+ "x-api-key",
24
+ }
25
+ _REQUEST_ID_HEADERS = (
26
+ "x-request-id",
27
+ "request-id",
28
+ "x-correlation-id",
29
+ "traceparent",
30
+ )
31
+ _SENSITIVE_ASSIGNMENT = re.compile(
32
+ r"(?i)(authorization|client[_-]?secret|password|access[_-]?token|"
33
+ r"x-api-key|api[_-]?key)\s*([:=]\s*|[\"']\s*:\s*[\"'])"
34
+ r"([^,\s&\"']+)"
35
+ )
36
+ _BEARER_VALUE = re.compile(r"(?i)\bBearer\s+[A-Za-z0-9._~+/=-]+")
37
+
38
+
39
+ class RedBlackError(Exception):
40
+ """Base class for all SDK-owned exceptions."""
41
+
42
+
43
+ class ConfigurationError(RedBlackError):
44
+ """The SDK configuration is missing, ambiguous, or unsafe."""
45
+
46
+
47
+ class AuthenticationError(RedBlackError):
48
+ """Token acquisition or a custom token provider failed."""
49
+
50
+ def __init__(
51
+ self,
52
+ message: str = "RedBlack authentication failed",
53
+ *,
54
+ error: str | None = None,
55
+ error_description: str | None = None,
56
+ status_code: int | None = None,
57
+ request_id: str | None = None,
58
+ secrets: Iterable[str] = (),
59
+ ) -> None:
60
+ self.error = redact_text(error, secrets=secrets) if error else None
61
+ self.error_description = (
62
+ redact_text(error_description, secrets=secrets) if error_description else None
63
+ )
64
+ self.status_code = status_code
65
+ self.request_id = request_id
66
+ super().__init__(redact_text(message, secrets=secrets))
67
+
68
+ def __repr__(self) -> str:
69
+ return (
70
+ f"{type(self).__name__}(status_code={self.status_code!r}, "
71
+ f"error={self.error!r}, request_id={self.request_id!r})"
72
+ )
73
+
74
+
75
+ class TransportError(RedBlackError):
76
+ """The request did not produce an HTTP response."""
77
+
78
+ def __init__(
79
+ self,
80
+ message: str,
81
+ *,
82
+ method: str | None = None,
83
+ path: str | None = None,
84
+ ) -> None:
85
+ self.method = method
86
+ self.path = path
87
+ context = _format_request_context(method, path)
88
+ super().__init__(f"{message}{context}")
89
+
90
+
91
+ class RequestTimeoutError(TransportError):
92
+ """An HTTP request timed out."""
93
+
94
+
95
+ @dataclass(frozen=True, slots=True, repr=False)
96
+ class ProblemDetails:
97
+ """Tolerant representation of an RFC-style problem response."""
98
+
99
+ type: str | None = None
100
+ title: str | None = None
101
+ status: int | None = None
102
+ detail: str | None = field(default=None, repr=False)
103
+ instance: str | None = field(default=None, repr=False)
104
+ extensions: Mapping[str, Any] = field(default_factory=lambda: MappingProxyType({}), repr=False)
105
+
106
+ @classmethod
107
+ def from_mapping(cls, value: Mapping[str, Any]) -> ProblemDetails:
108
+ known = {"type", "title", "status", "detail", "instance"}
109
+ raw_status = value.get("status")
110
+ status = (
111
+ raw_status if isinstance(raw_status, int) and not isinstance(raw_status, bool) else None
112
+ )
113
+ return cls(
114
+ type=_optional_string(value.get("type")),
115
+ title=_optional_string(value.get("title")),
116
+ status=status,
117
+ detail=_optional_string(value.get("detail")),
118
+ instance=_optional_string(value.get("instance")),
119
+ extensions=MappingProxyType(
120
+ {key: item for key, item in value.items() if key not in known}
121
+ ),
122
+ )
123
+
124
+ def __repr__(self) -> str:
125
+ return (
126
+ "ProblemDetails("
127
+ f"has_type={self.type is not None}, "
128
+ f"has_title={self.title is not None}, status={self.status!r}, "
129
+ f"has_detail={self.detail is not None}, "
130
+ f"extension_count={len(self.extensions)!r})"
131
+ )
132
+
133
+
134
+ class ApiError(RedBlackError):
135
+ """A RedBlack resource endpoint returned an unsuccessful response."""
136
+
137
+ def __init__(
138
+ self,
139
+ *,
140
+ status_code: int,
141
+ method: str,
142
+ path: str,
143
+ problem: ProblemDetails | None = None,
144
+ structured_errors: Any = None,
145
+ unknown_fields: Mapping[str, Any] | None = None,
146
+ headers: Mapping[str, str] | None = None,
147
+ request_id: str | None = None,
148
+ raw_body: bytes = b"",
149
+ body_truncated: bool = False,
150
+ ) -> None:
151
+ self.status_code = status_code
152
+ self.method = method.upper()
153
+ self.path = path
154
+ self.problem = problem
155
+ self.structured_errors = structured_errors
156
+ self.unknown_fields = MappingProxyType(dict(unknown_fields or {}))
157
+ self.headers = MappingProxyType(dict(headers or {}))
158
+ self.request_id = request_id
159
+ self.raw_body = raw_body
160
+ self.body_truncated = body_truncated
161
+ message = (
162
+ f"RedBlack API request failed with HTTP {status_code}"
163
+ f"{_format_request_context(self.method, self.path)}"
164
+ )
165
+ if request_id:
166
+ message += f" [request_id={request_id}]"
167
+ super().__init__(message)
168
+
169
+ def __repr__(self) -> str:
170
+ return (
171
+ f"{type(self).__name__}(status_code={self.status_code!r}, "
172
+ f"method={self.method!r}, path={self.path!r}, "
173
+ f"request_id={self.request_id!r}, "
174
+ f"has_problem={self.problem is not None}, "
175
+ f"raw_body_bytes={len(self.raw_body)!r})"
176
+ )
177
+
178
+
179
+ class BadRequestError(ApiError):
180
+ """HTTP 400."""
181
+
182
+
183
+ class UnauthorizedError(ApiError):
184
+ """HTTP 401 from a resource endpoint."""
185
+
186
+
187
+ class ForbiddenError(ApiError):
188
+ """HTTP 403."""
189
+
190
+
191
+ class NotFoundError(ApiError):
192
+ """HTTP 404 (not found or not visible to this API user)."""
193
+
194
+
195
+ class MethodNotAllowedError(ApiError):
196
+ """HTTP 405."""
197
+
198
+
199
+ class ConflictError(ApiError):
200
+ """HTTP 409."""
201
+
202
+
203
+ class GoneError(ApiError):
204
+ """HTTP 410."""
205
+
206
+
207
+ class PreconditionFailedError(ApiError):
208
+ """HTTP 412, usually an ETag mismatch."""
209
+
210
+
211
+ class UnprocessableContentError(ApiError):
212
+ """HTTP 422."""
213
+
214
+
215
+ class RateLimitError(ApiError):
216
+ """HTTP 429."""
217
+
218
+ @property
219
+ def retry_after(self) -> str | None:
220
+ return self.headers.get("retry-after")
221
+
222
+
223
+ class ServerError(ApiError):
224
+ """HTTP 5xx."""
225
+
226
+
227
+ class ResponseDecodeError(RedBlackError):
228
+ """A nominally successful response could not be decoded as contracted."""
229
+
230
+ def __init__(
231
+ self,
232
+ message: str,
233
+ *,
234
+ status_code: int | None = None,
235
+ method: str | None = None,
236
+ path: str | None = None,
237
+ request_id: str | None = None,
238
+ raw_body: bytes = b"",
239
+ ) -> None:
240
+ self.status_code = status_code
241
+ self.method = method
242
+ self.path = path
243
+ self.request_id = request_id
244
+ self.raw_body = bytes(raw_body[:_MAX_ERROR_BODY])
245
+ self.body_truncated = len(raw_body) > len(self.raw_body)
246
+ rendered = f"{message}{_format_request_context(method, path)}"
247
+ if request_id:
248
+ rendered += f" [request_id={request_id}]"
249
+ super().__init__(rendered)
250
+
251
+ def __repr__(self) -> str:
252
+ return (
253
+ f"{type(self).__name__}(status_code={self.status_code!r}, "
254
+ f"method={self.method!r}, path={self.path!r}, "
255
+ f"request_id={self.request_id!r}, "
256
+ f"raw_body_bytes={len(self.raw_body)!r}, "
257
+ f"body_truncated={self.body_truncated!r})"
258
+ )
259
+
260
+
261
+ class PollingError(RedBlackError):
262
+ """Base class for local polling and terminal workflow failures."""
263
+
264
+
265
+ class PollTimeoutError(PollingError):
266
+ """A local polling deadline elapsed."""
267
+
268
+ timeout: float | None
269
+ last_value: object | None
270
+
271
+ def __init__(
272
+ self,
273
+ message_or_timeout: str | float | None,
274
+ last_value: object | None = None,
275
+ *,
276
+ timeout: float | None = None,
277
+ ) -> None:
278
+ if isinstance(message_or_timeout, str):
279
+ message = message_or_timeout
280
+ resolved_timeout = timeout
281
+ else:
282
+ resolved_timeout = message_or_timeout if timeout is None else timeout
283
+ message = f"workflow did not finish within {resolved_timeout!r} seconds"
284
+ self.timeout = resolved_timeout
285
+ self.last_value = last_value
286
+ super().__init__(message)
287
+
288
+
289
+ class OperationFailedError(PollingError):
290
+ """A server-side long-running operation reached a failed terminal state."""
291
+
292
+ def __init__(self, message: str, *, final_state: Any = None) -> None:
293
+ self.final_state = final_state
294
+ super().__init__(message)
295
+
296
+
297
+ class OperationNeedsInputError(PollingError):
298
+ """A workflow stopped in a state that requires user action."""
299
+
300
+ def __init__(self, message: str, *, final_state: Any = None) -> None:
301
+ self.final_state = final_state
302
+ super().__init__(message)
303
+
304
+
305
+ _STATUS_ERRORS: dict[int, type[ApiError]] = {
306
+ 400: BadRequestError,
307
+ 401: UnauthorizedError,
308
+ 403: ForbiddenError,
309
+ 404: NotFoundError,
310
+ 405: MethodNotAllowedError,
311
+ 409: ConflictError,
312
+ 410: GoneError,
313
+ 412: PreconditionFailedError,
314
+ 422: UnprocessableContentError,
315
+ 429: RateLimitError,
316
+ }
317
+
318
+
319
+ def api_error_from_response(
320
+ response: httpx.Response,
321
+ *,
322
+ method: str | None = None,
323
+ path: str | None = None,
324
+ ) -> ApiError:
325
+ """Parse a resource response without trusting its Content-Type header."""
326
+
327
+ request_method = method or str(response.request.method)
328
+ request_path = path or response.request.url.path
329
+ original = response.content
330
+ bounded = original[:_MAX_ERROR_BODY]
331
+ body_truncated = len(original) > len(bounded)
332
+
333
+ payload: Any = None
334
+ if original:
335
+ try:
336
+ payload = json.loads(original)
337
+ except (UnicodeDecodeError, json.JSONDecodeError):
338
+ payload = None
339
+
340
+ problem: ProblemDetails | None = None
341
+ structured_errors: Any = None
342
+ unknown_fields: dict[str, Any] = {}
343
+ if isinstance(payload, Mapping):
344
+ problem_keys = {"type", "title", "status", "detail", "instance"}
345
+ if problem_keys.intersection(payload):
346
+ problem = ProblemDetails.from_mapping(payload)
347
+ structured_errors = (
348
+ payload.get("validationErrors")
349
+ if "validationErrors" in payload
350
+ else payload.get("errors")
351
+ )
352
+ recognized = problem_keys | {"errors", "validationErrors"}
353
+ unknown_fields = {
354
+ str(key): value for key, value in payload.items() if key not in recognized
355
+ }
356
+ elif payload is not None:
357
+ unknown_fields = {"payload": payload}
358
+
359
+ headers = safe_response_headers(response.headers)
360
+ request_id = extract_request_id(response.headers)
361
+ error_class = (
362
+ ServerError
363
+ if 500 <= response.status_code <= 599
364
+ else _STATUS_ERRORS.get(response.status_code, ApiError)
365
+ )
366
+ return error_class(
367
+ status_code=response.status_code,
368
+ method=request_method,
369
+ path=request_path,
370
+ problem=problem,
371
+ structured_errors=structured_errors,
372
+ unknown_fields=unknown_fields,
373
+ headers=headers,
374
+ request_id=request_id,
375
+ raw_body=bounded,
376
+ body_truncated=body_truncated,
377
+ )
378
+
379
+
380
+ def extract_request_id(headers: Mapping[str, str]) -> str | None:
381
+ """Return the first recognized server correlation header."""
382
+
383
+ lowered = {key.lower(): value for key, value in headers.items()}
384
+ for name in _REQUEST_ID_HEADERS:
385
+ if value := lowered.get(name):
386
+ return value
387
+ return None
388
+
389
+
390
+ def safe_response_headers(headers: Mapping[str, str]) -> dict[str, str]:
391
+ """Copy response headers while removing credential-bearing values."""
392
+
393
+ safe: dict[str, str] = {}
394
+ for name, value in headers.items():
395
+ lowered = name.lower()
396
+ if lowered in _SENSITIVE_HEADERS:
397
+ continue
398
+ safe[lowered] = sanitize_url(value) if lowered == "location" else redact_text(value)
399
+ return safe
400
+
401
+
402
+ def sanitize_url(value: str) -> str:
403
+ """Remove user information, query values, and fragments from a URL."""
404
+
405
+ try:
406
+ split = urlsplit(value)
407
+ except ValueError:
408
+ return "<redacted-url>"
409
+ if not split.scheme and not split.netloc:
410
+ return urlunsplit(("", "", split.path, "", ""))
411
+ hostname = split.hostname or ""
412
+ if ":" in hostname and not hostname.startswith("["):
413
+ hostname = f"[{hostname}]"
414
+ netloc = hostname
415
+ try:
416
+ port = split.port
417
+ except ValueError:
418
+ return "<redacted-url>"
419
+ if port is not None:
420
+ netloc += f":{port}"
421
+ return urlunsplit((split.scheme, netloc, split.path, "", ""))
422
+
423
+
424
+ def redact_text(value: str | None, *, secrets: Iterable[str] = ()) -> str:
425
+ """Best-effort defense against common credential strings in diagnostics."""
426
+
427
+ if value is None:
428
+ return ""
429
+ redacted = str(value)
430
+ for secret in secrets:
431
+ if secret:
432
+ redacted = redacted.replace(secret, "<redacted>")
433
+ redacted = _BEARER_VALUE.sub("Bearer <redacted>", redacted)
434
+ redacted = _SENSITIVE_ASSIGNMENT.sub(
435
+ lambda match: f"{match.group(1)}{match.group(2)}<redacted>", redacted
436
+ )
437
+ return redacted
438
+
439
+
440
+ def _optional_string(value: Any) -> str | None:
441
+ return value if isinstance(value, str) else None
442
+
443
+
444
+ def _format_request_context(method: str | None, path: str | None) -> str:
445
+ if method and path:
446
+ return f" ({method.upper()} {path})"
447
+ if method:
448
+ return f" ({method.upper()})"
449
+ if path:
450
+ return f" ({path})"
451
+ return ""
452
+
453
+
454
+ __all__ = [
455
+ "ApiError",
456
+ "AuthenticationError",
457
+ "BadRequestError",
458
+ "ConfigurationError",
459
+ "ConflictError",
460
+ "ForbiddenError",
461
+ "GoneError",
462
+ "MethodNotAllowedError",
463
+ "NotFoundError",
464
+ "OperationFailedError",
465
+ "OperationNeedsInputError",
466
+ "PollingError",
467
+ "PollTimeoutError",
468
+ "PreconditionFailedError",
469
+ "ProblemDetails",
470
+ "RateLimitError",
471
+ "RedBlackError",
472
+ "RequestTimeoutError",
473
+ "ResponseDecodeError",
474
+ "ServerError",
475
+ "TransportError",
476
+ "UnauthorizedError",
477
+ "UnprocessableContentError",
478
+ "api_error_from_response",
479
+ "extract_request_id",
480
+ "redact_text",
481
+ "safe_response_headers",
482
+ "sanitize_url",
483
+ ]
@@ -0,0 +1,105 @@
1
+ """Stable application-facing model aliases for RedBlack 2026.3.0.
2
+
3
+ Exact generated wire types remain available from
4
+ ``redblack.models.generated``.
5
+ """
6
+
7
+ from . import generated as _generated
8
+ from .base import (
9
+ UNSET,
10
+ ODataPageModel,
11
+ OpenIntEnum,
12
+ RequestModel,
13
+ ResponseModel,
14
+ UnsetType,
15
+ )
16
+
17
+ Account = _generated.AccountsAccountQueryRow
18
+ AccountExportJob = _generated.AccountsAccountExportJob
19
+ CashAction = _generated.CashActionCashActionQueryRow
20
+ CashActionCreate = _generated.CashActionsAddRequest
21
+ CashActionCreateResult = _generated.CashActionsAddResponse201
22
+ CashActionDeleteResult = _generated.CashActionsDeleteCashActionsResponse200
23
+ CashActionDetails = _generated.CashActionsGetResponse200
24
+ CashActionUpdate = _generated.CashActionsUpdateRequest
25
+ CashActionUpdateResult = _generated.CashActionsUpdateResponse200
26
+ Equivalent = _generated.EquivalentQueryEquivalentsResponse200ValueItem
27
+ EquivalentExportJob = _generated.EquivalentsEquivalentExportJob
28
+ Household = _generated.HouseholdsQueryHouseholdsResponse200ValueItem
29
+ HouseholdExportJob = _generated.HouseholdsHouseholdExportJob
30
+ Order = _generated.OrdersOrderQueryRow
31
+ OrderAddCancelResult = _generated.CommonResponseApiModelBase
32
+ OrderAddResult = _generated.ReviewSetsOrderMessageInOrderAddSessionQueryRow
33
+ OrderAddSession = _generated.ReviewSetsOrderAddSession
34
+ OrderAddSessionItem = _generated.ReviewSetsOrderInOrderAddSession
35
+ OrderCreate = _generated.ReviewSetsOrderAddRequest
36
+ OrderCreateResult = _generated.CommonEntityAddResult
37
+ OrderFilterType = _generated.OrdersOrderQueryFilterType
38
+ PendingOrder = _generated.ReviewSetsPendingOrderQueryRow
39
+ ProblemDetails = _generated.CommonProblemDetails
40
+ ReleaseError = _generated.ReviewSetsQueryReleaseResultsResponse200ValueItem
41
+ ReleaseValidationResult = _generated.ReviewSetsQueryReleaseValidationResultsResponse200ValueItem
42
+ Restriction = _generated.RestrictionQueryRestrictionsResponse200ValueItem
43
+ RestrictionExportJob = _generated.RestrictionsRestrictionExportJob
44
+ ReviewSet = _generated.ReviewSetsQueryResponse200ValueItem
45
+ ReviewSetReleaseSession = _generated.ReviewSetsGetReleaseSessionResponse200
46
+ Security = _generated.SecuritiesSecurityQueryRow
47
+ SweepSecurity = _generated.SecuritiesSweepSecurityQueryRow
48
+ Task = _generated.TasksGetResponse200
49
+ TaskCreate = _generated.TasksAddRequest
50
+ TaskCreateResult = _generated.TasksAddResponse201
51
+ TaskListItem = _generated.TasksTaskQueryRow
52
+ TaskStatus = _generated.TaskSettingsTaskStatusQueryRow
53
+ TaskType = _generated.TaskSettingsTaskTypeQueryRow
54
+ TaskUpdate = _generated.TasksTaskUpdateSpecification
55
+ TaskUpdateResult = _generated.TasksUpdateResponse200
56
+ User = _generated.UsersUserQueryRow
57
+
58
+ __all__ = [
59
+ "OpenIntEnum",
60
+ "ODataPageModel",
61
+ "RequestModel",
62
+ "ResponseModel",
63
+ "UNSET",
64
+ "UnsetType",
65
+ "Account",
66
+ "AccountExportJob",
67
+ "CashAction",
68
+ "CashActionCreate",
69
+ "CashActionCreateResult",
70
+ "CashActionDeleteResult",
71
+ "CashActionDetails",
72
+ "CashActionUpdate",
73
+ "CashActionUpdateResult",
74
+ "Equivalent",
75
+ "EquivalentExportJob",
76
+ "Household",
77
+ "HouseholdExportJob",
78
+ "Order",
79
+ "OrderAddCancelResult",
80
+ "OrderAddResult",
81
+ "OrderAddSession",
82
+ "OrderAddSessionItem",
83
+ "OrderCreate",
84
+ "OrderCreateResult",
85
+ "OrderFilterType",
86
+ "PendingOrder",
87
+ "ProblemDetails",
88
+ "ReleaseError",
89
+ "ReleaseValidationResult",
90
+ "Restriction",
91
+ "RestrictionExportJob",
92
+ "ReviewSet",
93
+ "ReviewSetReleaseSession",
94
+ "Security",
95
+ "SweepSecurity",
96
+ "Task",
97
+ "TaskCreate",
98
+ "TaskCreateResult",
99
+ "TaskListItem",
100
+ "TaskStatus",
101
+ "TaskType",
102
+ "TaskUpdate",
103
+ "TaskUpdateResult",
104
+ "User",
105
+ ]