sendmux-core 1.0.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.
@@ -0,0 +1,17 @@
1
+ from sendmux_core.auth import configure_auth, validate_api_key
2
+ from sendmux_core.errors import SendmuxApiError
3
+ from sendmux_core.headers import conditional_headers, idempotency_headers
4
+ from sendmux_core.pagination import CursorPage, iter_cursor_pages
5
+ from sendmux_core.retry import RetryOptions
6
+
7
+ __all__ = [
8
+ "CursorPage",
9
+ "RetryOptions",
10
+ "SendmuxApiError",
11
+ "conditional_headers",
12
+ "configure_auth",
13
+ "idempotency_headers",
14
+ "iter_cursor_pages",
15
+ "validate_api_key",
16
+ ]
17
+
sendmux_core/auth.py ADDED
@@ -0,0 +1,17 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Literal
4
+
5
+ ApiKeySurface = Literal["root", "mailbox"]
6
+
7
+
8
+ def validate_api_key(api_key: str, *, surface: ApiKeySurface) -> None:
9
+ expected_prefix = "smx_mbx_" if surface == "mailbox" else "smx_root_"
10
+ if not api_key.startswith(expected_prefix):
11
+ raise ValueError(f"Expected a {expected_prefix} API key for the {surface} surface")
12
+
13
+
14
+ def configure_auth(configuration: Any, *, api_key: str) -> None:
15
+ configuration.api_key["BearerAuth"] = api_key
16
+ configuration.api_key_prefix["BearerAuth"] = "Bearer"
17
+
sendmux_core/errors.py ADDED
@@ -0,0 +1,101 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass
5
+ from typing import Any, Mapping
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class SendmuxApiError(Exception):
10
+ status_code: int | None
11
+ code: str
12
+ message: str
13
+ retryable: bool
14
+ request_id: str | None
15
+ headers: Mapping[str, str]
16
+ raw_body: str | None = None
17
+
18
+ def __str__(self) -> str:
19
+ status = f"{self.status_code} " if self.status_code is not None else ""
20
+ return f"{status}{self.code}: {self.message}"
21
+
22
+
23
+ def map_api_exception(exc: Any) -> SendmuxApiError:
24
+ headers = _headers(exc)
25
+ payload = _payload(exc)
26
+ detail = _error_detail(payload)
27
+ status_code = getattr(exc, "status", None)
28
+ code = _string(detail.get("code")) or "api_error"
29
+ message = _string(detail.get("message")) or _string(getattr(exc, "reason", None)) or "Sendmux API request failed"
30
+ retryable = _bool(detail.get("retryable"))
31
+
32
+ return SendmuxApiError(
33
+ status_code=status_code if isinstance(status_code, int) else None,
34
+ code=code,
35
+ message=message,
36
+ retryable=retryable if retryable is not None else _default_retryable(status_code),
37
+ request_id=_request_id(payload) or _header(headers, "x-request-id"),
38
+ headers=headers,
39
+ raw_body=getattr(exc, "body", None),
40
+ )
41
+
42
+
43
+ def _headers(exc: Any) -> Mapping[str, str]:
44
+ value = getattr(exc, "headers", None)
45
+ if not value:
46
+ return {}
47
+ return {str(key): str(child) for key, child in dict(value).items()}
48
+
49
+
50
+ def _payload(exc: Any) -> Any:
51
+ data = getattr(exc, "data", None)
52
+ if data is not None:
53
+ if hasattr(data, "to_dict"):
54
+ return data.to_dict()
55
+ return data
56
+
57
+ body = getattr(exc, "body", None)
58
+ if isinstance(body, str) and body:
59
+ try:
60
+ return json.loads(body)
61
+ except json.JSONDecodeError:
62
+ return None
63
+ return None
64
+
65
+
66
+ def _error_detail(payload: Any) -> Mapping[str, Any]:
67
+ if not isinstance(payload, Mapping):
68
+ return {}
69
+ error = payload.get("error")
70
+ if isinstance(error, Mapping):
71
+ return error
72
+ return {}
73
+
74
+
75
+ def _request_id(payload: Any) -> str | None:
76
+ if not isinstance(payload, Mapping):
77
+ return None
78
+ meta = payload.get("meta")
79
+ if not isinstance(meta, Mapping):
80
+ return None
81
+ return _string(meta.get("request_id"))
82
+
83
+
84
+ def _header(headers: Mapping[str, str], name: str) -> str | None:
85
+ name = name.lower()
86
+ for key, value in headers.items():
87
+ if key.lower() == name:
88
+ return value
89
+ return None
90
+
91
+
92
+ def _string(value: Any) -> str | None:
93
+ return value if isinstance(value, str) and value else None
94
+
95
+
96
+ def _bool(value: Any) -> bool | None:
97
+ return value if isinstance(value, bool) else None
98
+
99
+
100
+ def _default_retryable(status_code: Any) -> bool:
101
+ return isinstance(status_code, int) and (status_code == 429 or status_code >= 500)
@@ -0,0 +1,15 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ def idempotency_headers(idempotency_key: str | None = None) -> dict[str, str]:
5
+ return {"Idempotency-Key": idempotency_key} if idempotency_key else {}
6
+
7
+
8
+ def conditional_headers(*, if_match: str | None = None, if_none_match: str | None = None) -> dict[str, str]:
9
+ headers: dict[str, str] = {}
10
+ if if_match:
11
+ headers["If-Match"] = if_match
12
+ if if_none_match:
13
+ headers["If-None-Match"] = if_none_match
14
+ return headers
15
+
@@ -0,0 +1,41 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable, Iterator, Sequence
4
+ from dataclasses import dataclass
5
+ from typing import Generic, Protocol, TypeVar
6
+
7
+ T = TypeVar("T")
8
+
9
+
10
+ class PaginationMeta(Protocol):
11
+ has_more: bool
12
+ next_cursor: str | None
13
+
14
+
15
+ class CursorResponse(Protocol[T]):
16
+ data: Sequence[T]
17
+ pagination: PaginationMeta
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class CursorPage(Generic[T]):
22
+ data: Sequence[T]
23
+ has_more: bool
24
+ next_cursor: str | None
25
+
26
+
27
+ def iter_cursor_pages(fetch_page: Callable[[str | None], CursorResponse[T]]) -> Iterator[T]:
28
+ cursor: str | None = None
29
+
30
+ while True:
31
+ response = fetch_page(cursor)
32
+ for item in response.data:
33
+ yield item
34
+
35
+ if not response.pagination.has_more:
36
+ return
37
+
38
+ next_cursor = response.pagination.next_cursor
39
+ if not next_cursor or next_cursor == cursor:
40
+ raise RuntimeError("Sendmux cursor pagination did not return a new next_cursor")
41
+ cursor = next_cursor
sendmux_core/retry.py ADDED
@@ -0,0 +1,94 @@
1
+ from __future__ import annotations
2
+
3
+ import random
4
+ import time
5
+ from dataclasses import dataclass
6
+ from email.utils import parsedate_to_datetime
7
+ from typing import Any, Callable, Mapping
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class RetryOptions:
12
+ max_attempts: int = 3
13
+ base_delay_seconds: float = 0.25
14
+ max_delay_seconds: float = 4.0
15
+ sleep: Callable[[float], None] = time.sleep
16
+
17
+
18
+ class RetryingRestClient:
19
+ def __init__(self, inner: Any, *, retry_options: RetryOptions | None = None) -> None:
20
+ self._inner = inner
21
+ self._options = retry_options or RetryOptions()
22
+
23
+ def request(self, method: str, url: str, **kwargs: Any) -> Any:
24
+ attempt = 1
25
+ while True:
26
+ response = self._inner.request(method, url, **kwargs)
27
+ if not self._should_retry(method, kwargs.get("headers") or {}, response, attempt):
28
+ return response
29
+
30
+ delay = self._delay_seconds(response.headers, attempt)
31
+ _release_response(response)
32
+ self._options.sleep(delay)
33
+ attempt += 1
34
+
35
+ def _should_retry(self, method: str, headers: Mapping[str, str], response: Any, attempt: int) -> bool:
36
+ if attempt >= self._options.max_attempts:
37
+ return False
38
+ if response.status not in {408, 409, 425, 429, 500, 502, 503, 504}:
39
+ return False
40
+ method = method.upper()
41
+ if method in {"GET", "HEAD", "OPTIONS"}:
42
+ return True
43
+ return method == "POST" and _header(headers, "idempotency-key") is not None
44
+
45
+ def _delay_seconds(self, headers: Mapping[str, str], attempt: int) -> float:
46
+ retry_after = _retry_after_seconds(_header(headers, "retry-after"))
47
+ if retry_after is not None:
48
+ return min(retry_after, self._options.max_delay_seconds)
49
+
50
+ rate_limit_reset = _rate_limit_reset_seconds(_header(headers, "x-ratelimit-reset"))
51
+ if rate_limit_reset is not None:
52
+ return min(rate_limit_reset, self._options.max_delay_seconds)
53
+
54
+ exponential = min(
55
+ self._options.base_delay_seconds * (2 ** (attempt - 1)),
56
+ self._options.max_delay_seconds,
57
+ )
58
+ return random.uniform(0, exponential)
59
+
60
+
61
+ def _header(headers: Mapping[str, str], name: str) -> str | None:
62
+ name = name.lower()
63
+ for key, value in headers.items():
64
+ if key.lower() == name:
65
+ return value
66
+ return None
67
+
68
+
69
+ def _retry_after_seconds(value: str | None) -> float | None:
70
+ if not value:
71
+ return None
72
+ try:
73
+ return max(float(value), 0.0)
74
+ except ValueError:
75
+ try:
76
+ return max(parsedate_to_datetime(value).timestamp() - time.time(), 0.0)
77
+ except (TypeError, ValueError):
78
+ return None
79
+
80
+
81
+ def _rate_limit_reset_seconds(value: str | None) -> float | None:
82
+ if not value:
83
+ return None
84
+ try:
85
+ return max(float(value) - time.time(), 0.0)
86
+ except ValueError:
87
+ return None
88
+
89
+
90
+ def _release_response(response: Any) -> None:
91
+ raw = getattr(response, "response", None)
92
+ release_conn = getattr(raw, "release_conn", None)
93
+ if callable(release_conn):
94
+ release_conn()
@@ -0,0 +1,13 @@
1
+ Metadata-Version: 2.4
2
+ Name: sendmux-core
3
+ Version: 1.0.0
4
+ Summary: Shared runtime helpers for the Sendmux Python SDK packages.
5
+ Project-URL: Repository, https://github.com/Sendmux/sendmux-sdk
6
+ Requires-Python: >=3.10
7
+ Requires-Dist: urllib3<3,>=2.3.0
8
+ Description-Content-Type: text/markdown
9
+
10
+ # sendmux-core
11
+
12
+ Shared runtime helpers for the Sendmux Python SDK packages.
13
+
@@ -0,0 +1,9 @@
1
+ sendmux_core/__init__.py,sha256=yjbS2853lFzEvlXATXtweS_unHkDuZ2xvMlSM2FIGQQ,497
2
+ sendmux_core/auth.py,sha256=HiwZcpv83bCeaVQpu8hR7kvNrmudHi3OLZoQ7jRPU_E,575
3
+ sendmux_core/errors.py,sha256=cnF_ts7WAX7sjwXzUwWRdVkZmBJwOzfwFEpJhIL02l0,2899
4
+ sendmux_core/headers.py,sha256=tYTUQV2Xqpa4_lyod_fslY65xj2ASxKZCitH-l0K_tE,482
5
+ sendmux_core/pagination.py,sha256=EjPENnfzxbzcmN-9S8AYaUWTdDfJIbsRcH5MAmLpgJc,1020
6
+ sendmux_core/retry.py,sha256=lT41uux8NPbV1XCss9jASpfyJMeFSGLSd-jMfRmsibE,3142
7
+ sendmux_core-1.0.0.dist-info/METADATA,sha256=S2hVVCkdppGqEU3OX2Oz_vnA3Tsff9Gcoz0pBD0jao4,364
8
+ sendmux_core-1.0.0.dist-info/WHEEL,sha256=KvPDMDa6F56cTSWKcFW-N0kTgjEkQQ6hmuMKcPkRB_Y,87
9
+ sendmux_core-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any