smscode 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.
smscode/__init__.py ADDED
@@ -0,0 +1,153 @@
1
+ from importlib import metadata
2
+
3
+ from smscode.async_client import AsyncSmscodeClient
4
+ from smscode.client import SmscodeClient
5
+ from smscode.errors import (
6
+ CancelTooEarlyError,
7
+ ConflictError,
8
+ ForbiddenError,
9
+ FxRateUnavailableError,
10
+ IdempotencyKeyReuseError,
11
+ InsufficientBalanceError,
12
+ InternalError,
13
+ InvalidMoneyError,
14
+ InvalidResponseError,
15
+ NetworkError,
16
+ NoOfferAvailableError,
17
+ NotFoundError,
18
+ OrderTerminalError,
19
+ OtpTimeoutError,
20
+ PayloadTooLargeError,
21
+ ProviderError,
22
+ RateLimitError,
23
+ RequestCancelledError,
24
+ RequestInProgressError,
25
+ ServiceUnavailableError,
26
+ SmscodeError,
27
+ TempBannedError,
28
+ TimeoutError,
29
+ UnauthorizedError,
30
+ ValidationError,
31
+ map_error,
32
+ )
33
+ from smscode.idempotency import (
34
+ IDEMPOTENCY_KEY_PATTERN,
35
+ is_valid_idempotency_key,
36
+ resolve_idempotency_key,
37
+ )
38
+ from smscode.models import (
39
+ BalanceV1,
40
+ BalanceV2,
41
+ CancelResult,
42
+ CancelResultV2,
43
+ Country,
44
+ CreatedOrder,
45
+ CreateOrderResult,
46
+ CreateOrderResultV1,
47
+ CreateOrderResultV2,
48
+ ExchangeRate,
49
+ FinishResult,
50
+ Order,
51
+ OrderCapabilities,
52
+ OrdersList,
53
+ OrdersListV2,
54
+ Product,
55
+ ProductsPage,
56
+ ProductsPageV1,
57
+ ProductsPageV2,
58
+ ProductV2,
59
+ ResendResult,
60
+ Service,
61
+ V2Fx,
62
+ WebhookConfig,
63
+ WebhookTestResult,
64
+ )
65
+ from smscode.money import Money, parse_money
66
+ from smscode.retry import RetryPolicy, async_with_retry, with_retry
67
+ from smscode.types import ApiResult, RequestOptions
68
+ from smscode.wait import OtpResult, async_wait_for_otp, wait_for_otp
69
+ from smscode.webhook import (
70
+ WebhookEvent,
71
+ is_webhook_event,
72
+ parse_webhook_event,
73
+ verify_webhook_signature,
74
+ )
75
+
76
+ VERSION = metadata.version("smscode")
77
+ __version__ = VERSION
78
+
79
+
80
+ __all__ = [
81
+ "IDEMPOTENCY_KEY_PATTERN",
82
+ "VERSION",
83
+ "ApiResult",
84
+ "AsyncSmscodeClient",
85
+ "BalanceV1",
86
+ "BalanceV2",
87
+ "CancelResult",
88
+ "CancelResultV2",
89
+ "CancelTooEarlyError",
90
+ "ConflictError",
91
+ "Country",
92
+ "CreateOrderResult",
93
+ "CreateOrderResultV1",
94
+ "CreateOrderResultV2",
95
+ "CreatedOrder",
96
+ "ExchangeRate",
97
+ "FinishResult",
98
+ "ForbiddenError",
99
+ "FxRateUnavailableError",
100
+ "IdempotencyKeyReuseError",
101
+ "InsufficientBalanceError",
102
+ "InternalError",
103
+ "InvalidMoneyError",
104
+ "InvalidResponseError",
105
+ "Money",
106
+ "NetworkError",
107
+ "NoOfferAvailableError",
108
+ "NotFoundError",
109
+ "Order",
110
+ "OrderCapabilities",
111
+ "OrderTerminalError",
112
+ "OrdersList",
113
+ "OrdersListV2",
114
+ "OtpResult",
115
+ "OtpTimeoutError",
116
+ "PayloadTooLargeError",
117
+ "Product",
118
+ "ProductV2",
119
+ "ProductsPage",
120
+ "ProductsPageV1",
121
+ "ProductsPageV2",
122
+ "ProviderError",
123
+ "RateLimitError",
124
+ "RequestCancelledError",
125
+ "RequestInProgressError",
126
+ "RequestOptions",
127
+ "ResendResult",
128
+ "RetryPolicy",
129
+ "Service",
130
+ "ServiceUnavailableError",
131
+ "SmscodeClient",
132
+ "SmscodeError",
133
+ "TempBannedError",
134
+ "TimeoutError",
135
+ "UnauthorizedError",
136
+ "V2Fx",
137
+ "ValidationError",
138
+ "WebhookConfig",
139
+ "WebhookEvent",
140
+ "WebhookTestResult",
141
+ "__version__",
142
+ "async_wait_for_otp",
143
+ "async_with_retry",
144
+ "is_valid_idempotency_key",
145
+ "is_webhook_event",
146
+ "map_error",
147
+ "parse_money",
148
+ "parse_webhook_event",
149
+ "resolve_idempotency_key",
150
+ "verify_webhook_signature",
151
+ "wait_for_otp",
152
+ "with_retry",
153
+ ]
@@ -0,0 +1,90 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+ from typing import Any
5
+
6
+ import httpx
7
+
8
+ from smscode._decode import decode_response, retry_after_from_error
9
+ from smscode._transport import DEFAULT_BASE_URL, build_url, clean_params, is_retryable
10
+ from smscode.errors import NetworkError, TimeoutError
11
+ from smscode.retry import RetryPolicy, async_with_retry
12
+ from smscode.types import ApiResult, HttpMethod, QueryValue
13
+
14
+
15
+ class AsyncTransport:
16
+ def __init__(
17
+ self,
18
+ *,
19
+ token: str,
20
+ base_url: str = DEFAULT_BASE_URL,
21
+ timeout_ms: int = 0,
22
+ max_retries: int = 0,
23
+ client: httpx.AsyncClient | None = None,
24
+ transport: httpx.AsyncBaseTransport | None = None,
25
+ ) -> None:
26
+ if not token:
27
+ raise TypeError("AsyncSmscodeClient requires a token.")
28
+ if client is not None and transport is not None:
29
+ raise TypeError("Pass either client or transport, not both.")
30
+ self._token = token
31
+ self._base_url = base_url
32
+ self._max_retries = max(0, max_retries)
33
+ self._owns_client = client is None
34
+ timeout = None if timeout_ms <= 0 else timeout_ms / 1000
35
+ self._client = client or httpx.AsyncClient(timeout=timeout, transport=transport)
36
+
37
+ async def aclose(self) -> None:
38
+ if self._owns_client:
39
+ await self._client.aclose()
40
+
41
+ async def request(
42
+ self,
43
+ method: HttpMethod,
44
+ path: str,
45
+ *,
46
+ params: Mapping[str, QueryValue] | None = None,
47
+ json: Any | None = None,
48
+ headers: Mapping[str, str] | None = None,
49
+ retry: int | None = None,
50
+ ) -> ApiResult[Any]:
51
+ max_retries = self._max_retries if retry is None else max(0, retry)
52
+ policy = RetryPolicy(
53
+ max_retries=max_retries,
54
+ retry_on=is_retryable,
55
+ retry_after=retry_after_from_error,
56
+ )
57
+ return await async_with_retry(
58
+ lambda: self._attempt(method, path, params=params, json=json, headers=headers),
59
+ policy,
60
+ )
61
+
62
+ async def _attempt(
63
+ self,
64
+ method: HttpMethod,
65
+ path: str,
66
+ *,
67
+ params: Mapping[str, QueryValue] | None,
68
+ json: Any | None,
69
+ headers: Mapping[str, str] | None,
70
+ ) -> ApiResult[Any]:
71
+ request_headers = {
72
+ "Authorization": f"Bearer {self._token}",
73
+ "Accept": "application/json",
74
+ "Content-Type": "application/json",
75
+ }
76
+ if headers is not None:
77
+ request_headers.update(headers)
78
+ try:
79
+ response = await self._client.request(
80
+ method,
81
+ build_url(self._base_url, path),
82
+ params=clean_params(params),
83
+ json=json,
84
+ headers=request_headers,
85
+ )
86
+ except httpx.TimeoutException as exc:
87
+ raise TimeoutError(str(exc) or "SMSCode request timed out") from exc
88
+ except httpx.RequestError as exc:
89
+ raise NetworkError(str(exc) or "SMSCode network request failed") from exc
90
+ return decode_response(response)
smscode/_decode.py ADDED
@@ -0,0 +1,74 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+ from datetime import timezone
5
+ from email.utils import parsedate_to_datetime
6
+ from typing import Any
7
+
8
+ import httpx
9
+
10
+ from smscode.errors import SmscodeError, map_error
11
+ from smscode.types import ApiResult
12
+
13
+
14
+ def retry_after_seconds(headers: httpx.Headers) -> float | None:
15
+ value = headers.get("Retry-After")
16
+ if value is None:
17
+ return None
18
+ try:
19
+ seconds = float(value)
20
+ except ValueError:
21
+ try:
22
+ retry_at = parsedate_to_datetime(value)
23
+ except (TypeError, ValueError):
24
+ return None
25
+ if retry_at.tzinfo is None:
26
+ retry_at = retry_at.replace(tzinfo=timezone.utc)
27
+ date_header = headers.get("Date")
28
+ if date_header is None:
29
+ return None
30
+ try:
31
+ response_date = parsedate_to_datetime(date_header)
32
+ except (TypeError, ValueError):
33
+ return None
34
+ if response_date.tzinfo is None:
35
+ response_date = response_date.replace(tzinfo=timezone.utc)
36
+ return max(0.0, (retry_at - response_date).total_seconds())
37
+ return max(0.0, seconds)
38
+
39
+
40
+ def retry_after_from_error(err: Exception) -> float | None:
41
+ if isinstance(err, SmscodeError):
42
+ return err.retry_after_seconds
43
+ return None
44
+
45
+
46
+ def decode_response(response: httpx.Response) -> ApiResult[Any]:
47
+ request_id = response.headers.get("X-Request-Id")
48
+ payload = _json_or_none(response)
49
+ envelope = payload if isinstance(payload, Mapping) else None
50
+ is_failure = not response.is_success or (
51
+ envelope is not None and envelope.get("success") is False
52
+ )
53
+ if is_failure:
54
+ raise map_error(
55
+ status=response.status_code,
56
+ payload=envelope,
57
+ request_id=request_id,
58
+ retry_after_seconds=retry_after_seconds(response.headers),
59
+ )
60
+
61
+ meta_value = envelope.get("meta") if envelope is not None else None
62
+ meta = dict(meta_value) if isinstance(meta_value, Mapping) else None
63
+ data = envelope.get("data") if envelope is not None and "data" in envelope else payload
64
+ return ApiResult(data=data, meta=meta, request_id=request_id, status=response.status_code)
65
+
66
+
67
+ def _json_or_none(response: httpx.Response) -> object | None:
68
+ if not response.content:
69
+ return None
70
+ try:
71
+ parsed: object = response.json()
72
+ return parsed
73
+ except ValueError:
74
+ return None
smscode/_transport.py ADDED
@@ -0,0 +1,115 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+ from typing import Any
5
+
6
+ import httpx
7
+
8
+ from smscode._decode import decode_response, retry_after_from_error
9
+ from smscode.errors import NetworkError, SmscodeError, TimeoutError
10
+ from smscode.retry import RetryPolicy, with_retry
11
+ from smscode.types import ApiResult, HttpMethod, QueryValue
12
+
13
+ DEFAULT_BASE_URL = "https://api.smscode.gg"
14
+ RETRYABLE_STATUSES = {429, 503}
15
+
16
+
17
+ def build_url(base_url: str, path: str) -> str:
18
+ base = base_url.rstrip("/")
19
+ rel = path if path.startswith("/") else f"/{path}"
20
+ return f"{base}{rel}"
21
+
22
+
23
+ def clean_params(params: Mapping[str, QueryValue] | None) -> dict[str, str] | None:
24
+ if params is None:
25
+ return None
26
+ cleaned: dict[str, str] = {}
27
+ for key, value in params.items():
28
+ if value is None:
29
+ continue
30
+ cleaned[key] = str(value).lower() if isinstance(value, bool) else str(value)
31
+ return cleaned
32
+
33
+
34
+ class SyncTransport:
35
+ def __init__(
36
+ self,
37
+ *,
38
+ token: str,
39
+ base_url: str = DEFAULT_BASE_URL,
40
+ timeout_ms: int = 0,
41
+ max_retries: int = 0,
42
+ client: httpx.Client | None = None,
43
+ transport: httpx.BaseTransport | None = None,
44
+ ) -> None:
45
+ if not token:
46
+ raise TypeError("SmscodeClient requires a token.")
47
+ if client is not None and transport is not None:
48
+ raise TypeError("Pass either client or transport, not both.")
49
+ self._token = token
50
+ self._base_url = base_url
51
+ self._max_retries = max(0, max_retries)
52
+ self._owns_client = client is None
53
+ timeout = None if timeout_ms <= 0 else timeout_ms / 1000
54
+ self._client = client or httpx.Client(timeout=timeout, transport=transport)
55
+
56
+ def close(self) -> None:
57
+ if self._owns_client:
58
+ self._client.close()
59
+
60
+ def request(
61
+ self,
62
+ method: HttpMethod,
63
+ path: str,
64
+ *,
65
+ params: Mapping[str, QueryValue] | None = None,
66
+ json: Any | None = None,
67
+ headers: Mapping[str, str] | None = None,
68
+ retry: int | None = None,
69
+ ) -> ApiResult[Any]:
70
+ max_retries = self._max_retries if retry is None else max(0, retry)
71
+ policy = RetryPolicy(
72
+ max_retries=max_retries,
73
+ retry_on=is_retryable,
74
+ retry_after=retry_after_from_error,
75
+ )
76
+ return with_retry(
77
+ lambda: self._attempt(method, path, params=params, json=json, headers=headers),
78
+ policy,
79
+ )
80
+
81
+ def _attempt(
82
+ self,
83
+ method: HttpMethod,
84
+ path: str,
85
+ *,
86
+ params: Mapping[str, QueryValue] | None,
87
+ json: Any | None,
88
+ headers: Mapping[str, str] | None,
89
+ ) -> ApiResult[Any]:
90
+ request_headers = {
91
+ "Authorization": f"Bearer {self._token}",
92
+ "Accept": "application/json",
93
+ "Content-Type": "application/json",
94
+ }
95
+ if headers is not None:
96
+ request_headers.update(headers)
97
+ try:
98
+ response = self._client.request(
99
+ method,
100
+ build_url(self._base_url, path),
101
+ params=clean_params(params),
102
+ json=json,
103
+ headers=request_headers,
104
+ )
105
+ except httpx.TimeoutException as exc:
106
+ raise TimeoutError(str(exc) or "SMSCode request timed out") from exc
107
+ except httpx.RequestError as exc:
108
+ raise NetworkError(str(exc) or "SMSCode network request failed") from exc
109
+ return decode_response(response)
110
+
111
+
112
+ def is_retryable(err: Exception) -> bool:
113
+ if isinstance(err, NetworkError):
114
+ return True
115
+ return isinstance(err, SmscodeError) and err.http_status in RETRYABLE_STATUSES
@@ -0,0 +1,83 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+ from types import TracebackType
5
+ from typing import Any
6
+
7
+ import httpx
8
+
9
+ from smscode._async_transport import AsyncTransport
10
+ from smscode._transport import DEFAULT_BASE_URL
11
+ from smscode.resources import (
12
+ AsyncV1CatalogBalanceNamespace,
13
+ AsyncV1CatalogResource,
14
+ AsyncV2CatalogResource,
15
+ )
16
+ from smscode.resources.balance import AsyncV1BalanceResource, AsyncV2BalanceResource
17
+ from smscode.resources.orders import AsyncV1OrdersResource, AsyncV2OrdersResource
18
+ from smscode.resources.webhook import AsyncV1WebhookResource, AsyncV2WebhookResource
19
+ from smscode.types import ApiResult, HttpMethod, QueryValue
20
+
21
+
22
+ class AsyncSmscodeClient:
23
+ def __init__(
24
+ self,
25
+ *,
26
+ token: str,
27
+ base_url: str = DEFAULT_BASE_URL,
28
+ timeout_ms: int = 0,
29
+ max_retries: int = 0,
30
+ client: httpx.AsyncClient | None = None,
31
+ transport: httpx.AsyncBaseTransport | None = None,
32
+ ) -> None:
33
+ self._transport = AsyncTransport(
34
+ token=token,
35
+ base_url=base_url,
36
+ timeout_ms=timeout_ms,
37
+ max_retries=max_retries,
38
+ client=client,
39
+ transport=transport,
40
+ )
41
+ self.catalog = AsyncV2CatalogResource(self.request)
42
+ self.balance = AsyncV2BalanceResource(self.request)
43
+ self.orders = AsyncV2OrdersResource(self.request)
44
+ self.webhook = AsyncV2WebhookResource(self.request)
45
+ self.v1 = AsyncV1CatalogBalanceNamespace(
46
+ catalog=AsyncV1CatalogResource(self.request),
47
+ balance=AsyncV1BalanceResource(self.request),
48
+ orders=AsyncV1OrdersResource(self.request),
49
+ webhook=AsyncV1WebhookResource(self.request),
50
+ )
51
+
52
+ async def __aenter__(self) -> AsyncSmscodeClient:
53
+ return self
54
+
55
+ async def __aexit__(
56
+ self,
57
+ exc_type: type[BaseException] | None,
58
+ exc: BaseException | None,
59
+ traceback: TracebackType | None,
60
+ ) -> None:
61
+ await self.aclose()
62
+
63
+ async def aclose(self) -> None:
64
+ await self._transport.aclose()
65
+
66
+ async def request(
67
+ self,
68
+ method: HttpMethod,
69
+ path: str,
70
+ *,
71
+ params: Mapping[str, QueryValue] | None = None,
72
+ json: Any | None = None,
73
+ headers: Mapping[str, str] | None = None,
74
+ retry: int | None = None,
75
+ ) -> ApiResult[Any]:
76
+ return await self._transport.request(
77
+ method,
78
+ path,
79
+ params=params,
80
+ json=json,
81
+ headers=headers,
82
+ retry=retry,
83
+ )
smscode/client.py ADDED
@@ -0,0 +1,78 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+ from types import TracebackType
5
+ from typing import Any
6
+
7
+ import httpx
8
+
9
+ from smscode._transport import DEFAULT_BASE_URL, SyncTransport
10
+ from smscode.resources import V1CatalogBalanceNamespace, V1CatalogResource, V2CatalogResource
11
+ from smscode.resources.balance import V1BalanceResource, V2BalanceResource
12
+ from smscode.resources.orders import V1OrdersResource, V2OrdersResource
13
+ from smscode.resources.webhook import V1WebhookResource, V2WebhookResource
14
+ from smscode.types import ApiResult, HttpMethod, QueryValue
15
+
16
+
17
+ class SmscodeClient:
18
+ def __init__(
19
+ self,
20
+ *,
21
+ token: str,
22
+ base_url: str = DEFAULT_BASE_URL,
23
+ timeout_ms: int = 0,
24
+ max_retries: int = 0,
25
+ client: httpx.Client | None = None,
26
+ transport: httpx.BaseTransport | None = None,
27
+ ) -> None:
28
+ self._transport = SyncTransport(
29
+ token=token,
30
+ base_url=base_url,
31
+ timeout_ms=timeout_ms,
32
+ max_retries=max_retries,
33
+ client=client,
34
+ transport=transport,
35
+ )
36
+ self.catalog = V2CatalogResource(self.request)
37
+ self.balance = V2BalanceResource(self.request)
38
+ self.orders = V2OrdersResource(self.request)
39
+ self.webhook = V2WebhookResource(self.request)
40
+ self.v1 = V1CatalogBalanceNamespace(
41
+ catalog=V1CatalogResource(self.request),
42
+ balance=V1BalanceResource(self.request),
43
+ orders=V1OrdersResource(self.request),
44
+ webhook=V1WebhookResource(self.request),
45
+ )
46
+
47
+ def __enter__(self) -> SmscodeClient:
48
+ return self
49
+
50
+ def __exit__(
51
+ self,
52
+ exc_type: type[BaseException] | None,
53
+ exc: BaseException | None,
54
+ traceback: TracebackType | None,
55
+ ) -> None:
56
+ self.close()
57
+
58
+ def close(self) -> None:
59
+ self._transport.close()
60
+
61
+ def request(
62
+ self,
63
+ method: HttpMethod,
64
+ path: str,
65
+ *,
66
+ params: Mapping[str, QueryValue] | None = None,
67
+ json: Any | None = None,
68
+ headers: Mapping[str, str] | None = None,
69
+ retry: int | None = None,
70
+ ) -> ApiResult[Any]:
71
+ return self._transport.request(
72
+ method,
73
+ path,
74
+ params=params,
75
+ json=json,
76
+ headers=headers,
77
+ retry=retry,
78
+ )