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.
@@ -0,0 +1,135 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Awaitable, Callable
4
+ from typing import Any, Literal
5
+
6
+ from smscode.errors import InvalidResponseError
7
+ from smscode.models import WebhookConfig, WebhookTestResult
8
+ from smscode.types import ApiResult
9
+
10
+ SyncRequest = Callable[..., ApiResult[Any]]
11
+ AsyncRequest = Callable[..., Awaitable[ApiResult[Any]]]
12
+ ApiPrefix = Literal["/v1", "/v2"]
13
+
14
+
15
+ def _update_body(
16
+ *,
17
+ webhook_url: str | None = None,
18
+ webhook_secret: str | None = None,
19
+ ) -> dict[str, str]:
20
+ body: dict[str, str] = {}
21
+ if webhook_url is not None:
22
+ body["webhook_url"] = webhook_url
23
+ if webhook_secret is not None:
24
+ body["webhook_secret"] = webhook_secret
25
+ return body
26
+
27
+
28
+ def _decode_webhook_config(data: object) -> WebhookConfig:
29
+ if not isinstance(data, dict):
30
+ raise InvalidResponseError("The webhook config response is malformed.")
31
+ consecutive = data.get("webhook_consecutive_failures")
32
+ return WebhookConfig(
33
+ webhook_url=data.get("webhook_url") if isinstance(data.get("webhook_url"), str) else None,
34
+ webhook_secret=(
35
+ data.get("webhook_secret") if isinstance(data.get("webhook_secret"), str) else None
36
+ ),
37
+ webhook_disabled_at=(
38
+ data.get("webhook_disabled_at")
39
+ if isinstance(data.get("webhook_disabled_at"), str)
40
+ else None
41
+ ),
42
+ webhook_disabled_reason=(
43
+ data.get("webhook_disabled_reason")
44
+ if isinstance(data.get("webhook_disabled_reason"), str)
45
+ else None
46
+ ),
47
+ webhook_consecutive_failures=consecutive if type(consecutive) is int else 0,
48
+ raw=dict(data),
49
+ )
50
+
51
+
52
+ def _decode_webhook_test_result(data: object) -> WebhookTestResult:
53
+ if not isinstance(data, dict) or type(data.get("status_code")) is not int:
54
+ raise InvalidResponseError("The webhook test response is malformed.")
55
+ return WebhookTestResult(status_code=data["status_code"], raw=dict(data))
56
+
57
+
58
+ class WebhookResource:
59
+ def __init__(self, request: SyncRequest, prefix: ApiPrefix) -> None:
60
+ self._request = request
61
+ self._prefix = prefix
62
+
63
+ def get(self) -> WebhookConfig:
64
+ return _decode_webhook_config(self._request("GET", f"{self._prefix}/webhook").data)
65
+
66
+ def update(
67
+ self,
68
+ *,
69
+ webhook_url: str | None = None,
70
+ webhook_secret: str | None = None,
71
+ ) -> WebhookConfig:
72
+ return _decode_webhook_config(
73
+ self._request(
74
+ "PATCH",
75
+ f"{self._prefix}/webhook",
76
+ json=_update_body(webhook_url=webhook_url, webhook_secret=webhook_secret),
77
+ retry=0,
78
+ ).data
79
+ )
80
+
81
+ def test(self) -> WebhookTestResult:
82
+ return _decode_webhook_test_result(
83
+ self._request("POST", f"{self._prefix}/webhook/test", retry=0).data
84
+ )
85
+
86
+
87
+ class V2WebhookResource(WebhookResource):
88
+ def __init__(self, request: SyncRequest) -> None:
89
+ super().__init__(request, "/v2")
90
+
91
+
92
+ class V1WebhookResource(WebhookResource):
93
+ def __init__(self, request: SyncRequest) -> None:
94
+ super().__init__(request, "/v1")
95
+
96
+
97
+ class AsyncWebhookResource:
98
+ def __init__(self, request: AsyncRequest, prefix: ApiPrefix) -> None:
99
+ self._request = request
100
+ self._prefix = prefix
101
+
102
+ async def get(self) -> WebhookConfig:
103
+ return _decode_webhook_config((await self._request("GET", f"{self._prefix}/webhook")).data)
104
+
105
+ async def update(
106
+ self,
107
+ *,
108
+ webhook_url: str | None = None,
109
+ webhook_secret: str | None = None,
110
+ ) -> WebhookConfig:
111
+ return _decode_webhook_config(
112
+ (
113
+ await self._request(
114
+ "PATCH",
115
+ f"{self._prefix}/webhook",
116
+ json=_update_body(webhook_url=webhook_url, webhook_secret=webhook_secret),
117
+ retry=0,
118
+ )
119
+ ).data
120
+ )
121
+
122
+ async def test(self) -> WebhookTestResult:
123
+ return _decode_webhook_test_result(
124
+ (await self._request("POST", f"{self._prefix}/webhook/test", retry=0)).data
125
+ )
126
+
127
+
128
+ class AsyncV2WebhookResource(AsyncWebhookResource):
129
+ def __init__(self, request: AsyncRequest) -> None:
130
+ super().__init__(request, "/v2")
131
+
132
+
133
+ class AsyncV1WebhookResource(AsyncWebhookResource):
134
+ def __init__(self, request: AsyncRequest) -> None:
135
+ super().__init__(request, "/v1")
smscode/retry.py ADDED
@@ -0,0 +1,66 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import random
5
+ import time
6
+ from collections.abc import Awaitable, Callable
7
+ from dataclasses import dataclass
8
+ from typing import TypeVar
9
+
10
+ T = TypeVar("T")
11
+
12
+ BACKOFF_BASE_SECONDS = 0.250
13
+ BACKOFF_CAP_SECONDS = 10.0
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class RetryPolicy:
18
+ max_retries: int = 0
19
+ retry_on: Callable[[Exception], bool] = lambda err: False
20
+ retry_after: Callable[[Exception], float | None] | None = None
21
+ delay_seconds: Callable[[int], float] | None = None
22
+ sleep: Callable[[float], None] | None = None
23
+ async_sleep: Callable[[float], Awaitable[None]] | None = None
24
+
25
+
26
+ def default_delay_seconds(attempt: int) -> float:
27
+ ceiling = min(BACKOFF_CAP_SECONDS, BACKOFF_BASE_SECONDS * 2 ** max(0, attempt - 1))
28
+ return float(random.random() * ceiling)
29
+
30
+
31
+ def _retry_delay_seconds(err: Exception, attempt: int, policy: RetryPolicy) -> float:
32
+ retry_after = policy.retry_after(err) if policy.retry_after is not None else None
33
+ if retry_after is not None:
34
+ return max(0.0, retry_after)
35
+ delay = (
36
+ policy.delay_seconds(attempt)
37
+ if policy.delay_seconds is not None
38
+ else default_delay_seconds(attempt)
39
+ )
40
+ return max(0.0, delay)
41
+
42
+
43
+ def with_retry(fn: Callable[[], T], policy: RetryPolicy) -> T:
44
+ sleep = policy.sleep or time.sleep
45
+ max_retries = max(0, policy.max_retries)
46
+ for attempt in range(max_retries + 1):
47
+ try:
48
+ return fn()
49
+ except Exception as err:
50
+ if attempt >= max_retries or not policy.retry_on(err):
51
+ raise
52
+ sleep(_retry_delay_seconds(err, attempt + 1, policy))
53
+ raise RuntimeError("unreachable retry loop")
54
+
55
+
56
+ async def async_with_retry(fn: Callable[[], Awaitable[T]], policy: RetryPolicy) -> T:
57
+ sleep = policy.async_sleep or asyncio.sleep
58
+ max_retries = max(0, policy.max_retries)
59
+ for attempt in range(max_retries + 1):
60
+ try:
61
+ return await fn()
62
+ except Exception as err:
63
+ if attempt >= max_retries or not policy.retry_on(err):
64
+ raise
65
+ await sleep(_retry_delay_seconds(err, attempt + 1, policy))
66
+ raise RuntimeError("unreachable retry loop")
smscode/types.py ADDED
@@ -0,0 +1,35 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+ from dataclasses import dataclass
5
+ from typing import Any, Generic, Literal, TypeVar
6
+
7
+ T = TypeVar("T")
8
+
9
+ QueryValue = str | int | float | bool | None
10
+ HttpMethod = Literal["GET", "POST", "PUT", "PATCH", "DELETE"]
11
+ OrderStatus = Literal["ACTIVE", "OTP_RECEIVED", "COMPLETED", "CANCELED", "EXPIRED"]
12
+ SortQuery = Literal[
13
+ "price_asc",
14
+ "price_desc",
15
+ "available_asc",
16
+ "available_desc",
17
+ "name_asc",
18
+ "name_desc",
19
+ ]
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class RequestOptions:
24
+ params: Mapping[str, QueryValue] | None = None
25
+ json: Any | None = None
26
+ headers: Mapping[str, str] | None = None
27
+ retry: int | None = None
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class ApiResult(Generic[T]):
32
+ data: T
33
+ meta: dict[str, Any] | None
34
+ request_id: str | None
35
+ status: int
smscode/wait.py ADDED
@@ -0,0 +1,125 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import time
5
+ from collections.abc import Awaitable, Callable, Mapping
6
+ from dataclasses import dataclass
7
+ from typing import Any
8
+
9
+ from smscode.errors import OrderTerminalError, OtpTimeoutError, RateLimitError
10
+
11
+ TERMINAL_STATUSES = {"COMPLETED", "CANCELED", "EXPIRED"}
12
+ DEFAULT_POLL_INTERVAL_MS = 3000
13
+ DEFAULT_TIMEOUT_MS = 20 * 60 * 1000
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class OtpResult:
18
+ otp_code: str
19
+ status: str
20
+ order: Any
21
+
22
+
23
+ def wait_for_otp(
24
+ poll_order: Callable[[], Any],
25
+ *,
26
+ after_code: str | None = None,
27
+ timeout_ms: int = DEFAULT_TIMEOUT_MS,
28
+ poll_interval_ms: int = DEFAULT_POLL_INTERVAL_MS,
29
+ sleep: Callable[[float], None] | None = None,
30
+ now: Callable[[], float] | None = None,
31
+ ) -> OtpResult:
32
+ sleep_fn = sleep or time.sleep
33
+ now_fn = now or (lambda: time.monotonic() * 1000)
34
+ deadline = now_fn() + timeout_ms
35
+
36
+ while True:
37
+ try:
38
+ order = poll_order()
39
+ except RateLimitError as err:
40
+ delay_seconds = (
41
+ err.retry_after_seconds
42
+ if err.retry_after_seconds is not None
43
+ else poll_interval_ms / 1000
44
+ )
45
+ if now_fn() + delay_seconds * 1000 > deadline:
46
+ raise OtpTimeoutError("Timed out waiting for OTP.") from err
47
+ sleep_fn(delay_seconds)
48
+ continue
49
+
50
+ result = _result_or_error(order, after_code=after_code)
51
+ if result is not None:
52
+ return result
53
+ status = _status(order)
54
+ if status in TERMINAL_STATUSES:
55
+ raise OrderTerminalError(f"Order reached terminal status {status} before OTP arrived.")
56
+ if now_fn() + poll_interval_ms > deadline:
57
+ raise OtpTimeoutError("Timed out waiting for OTP.")
58
+ sleep_fn(poll_interval_ms / 1000)
59
+
60
+
61
+ async def async_wait_for_otp(
62
+ poll_order: Callable[[], Awaitable[Any]],
63
+ *,
64
+ after_code: str | None = None,
65
+ timeout_ms: int = DEFAULT_TIMEOUT_MS,
66
+ poll_interval_ms: int = DEFAULT_POLL_INTERVAL_MS,
67
+ sleep: Callable[[float], Awaitable[None]] | None = None,
68
+ now: Callable[[], float] | None = None,
69
+ ) -> OtpResult:
70
+ sleep_fn = sleep or asyncio.sleep
71
+ now_fn = now or (lambda: time.monotonic() * 1000)
72
+ deadline = now_fn() + timeout_ms
73
+
74
+ while True:
75
+ try:
76
+ order = await poll_order()
77
+ except RateLimitError as err:
78
+ delay_seconds = (
79
+ err.retry_after_seconds
80
+ if err.retry_after_seconds is not None
81
+ else poll_interval_ms / 1000
82
+ )
83
+ if now_fn() + delay_seconds * 1000 > deadline:
84
+ raise OtpTimeoutError("Timed out waiting for OTP.") from err
85
+ await sleep_fn(delay_seconds)
86
+ continue
87
+
88
+ result = _result_or_error(order, after_code=after_code)
89
+ if result is not None:
90
+ return result
91
+ status = _status(order)
92
+ if status in TERMINAL_STATUSES:
93
+ raise OrderTerminalError(f"Order reached terminal status {status} before OTP arrived.")
94
+ if now_fn() + poll_interval_ms > deadline:
95
+ raise OtpTimeoutError("Timed out waiting for OTP.")
96
+ await sleep_fn(poll_interval_ms / 1000)
97
+
98
+
99
+ def _result_or_error(order: Any, *, after_code: str | None) -> OtpResult | None:
100
+ code = _otp_code(order)
101
+ if code is not None and code != "" and code != after_code:
102
+ return OtpResult(otp_code=code, status=_status(order), order=order)
103
+ return None
104
+
105
+
106
+ def _otp_code(order: Any) -> str | None:
107
+ value = _field(order, "otp_code")
108
+ return value if isinstance(value, str) else None
109
+
110
+
111
+ def _status(order: Any) -> str:
112
+ value = _field(order, "status")
113
+ return value if isinstance(value, str) else ""
114
+
115
+
116
+ def _field(order: Any, name: str) -> object:
117
+ if isinstance(order, Mapping):
118
+ return order.get(name)
119
+ value = getattr(order, name, None)
120
+ if value is not None:
121
+ return value
122
+ getter = getattr(order, "get", None)
123
+ if callable(getter):
124
+ return getter(name)
125
+ return None
smscode/webhook.py ADDED
@@ -0,0 +1,67 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import hmac
5
+ import json
6
+ from collections.abc import Mapping
7
+ from typing import Any, TypeAlias
8
+
9
+ WebhookEvent: TypeAlias = dict[str, Any]
10
+
11
+ WEBHOOK_EVENTS = {
12
+ "order.otp_received",
13
+ "order.completed",
14
+ "order.expired",
15
+ "order.canceled",
16
+ "webhook.test",
17
+ }
18
+ SIGNATURE_PREFIX = "sha256="
19
+ SHA256_BYTES = 32
20
+
21
+
22
+ def verify_webhook_signature(raw_body: bytes | str, signature_header: str, secret: str) -> bool:
23
+ if not isinstance(signature_header, str) or not signature_header.startswith(SIGNATURE_PREFIX):
24
+ return False
25
+ digest_hex = signature_header[len(SIGNATURE_PREFIX) :]
26
+ try:
27
+ provided = bytes.fromhex(digest_hex)
28
+ except ValueError:
29
+ return False
30
+ if len(provided) != SHA256_BYTES:
31
+ return False
32
+
33
+ expected = hmac.new(_to_bytes(secret), _to_bytes(raw_body), hashlib.sha256).digest()
34
+ return hmac.compare_digest(expected, provided)
35
+
36
+
37
+ def parse_webhook_event(raw_body: bytes | str | object) -> WebhookEvent:
38
+ value = raw_body
39
+ if isinstance(raw_body, bytes):
40
+ value = _json_loads(raw_body.decode("utf-8"))
41
+ elif isinstance(raw_body, str):
42
+ value = _json_loads(raw_body)
43
+
44
+ if not isinstance(value, Mapping):
45
+ raise TypeError("Webhook payload is not a JSON object.")
46
+ event = value.get("event")
47
+ if not isinstance(event, str) or event not in WEBHOOK_EVENTS:
48
+ raise TypeError(f"Webhook payload has an unknown event type: {event!r}.")
49
+ return dict(value)
50
+
51
+
52
+ def is_webhook_event(value: object) -> bool:
53
+ if not isinstance(value, Mapping):
54
+ return False
55
+ event = value.get("event")
56
+ return isinstance(event, str) and event in WEBHOOK_EVENTS
57
+
58
+
59
+ def _json_loads(raw: str) -> object:
60
+ try:
61
+ return json.loads(raw)
62
+ except json.JSONDecodeError as err:
63
+ raise TypeError("Webhook payload is not valid JSON.") from err
64
+
65
+
66
+ def _to_bytes(value: bytes | str) -> bytes:
67
+ return value if isinstance(value, bytes) else value.encode("utf-8")
@@ -0,0 +1,169 @@
1
+ Metadata-Version: 2.4
2
+ Name: smscode
3
+ Version: 1.0.0
4
+ Summary: Official Python SDK for the SMSCode virtual number API.
5
+ Project-URL: Homepage, https://smscode.gg
6
+ Project-URL: Documentation, https://smscode.gg/docs/ai.md
7
+ Project-URL: Repository, https://github.com/smscode-gg/sdks
8
+ Project-URL: Issues, https://github.com/smscode-gg/sdks/issues
9
+ Author-email: SMSCode <dev@smscode.gg>
10
+ License: MIT
11
+ License-File: LICENSE
12
+ Keywords: 2fa,api-client,online-sms,otp,phone-verification,python,receive-sms-online,sdk,sms,sms-otp,sms-verification,smscode,temporary-phone-number,virtual-number,virtual-phone-number
13
+ Classifier: Development Status :: 5 - Production/Stable
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.10
23
+ Requires-Dist: httpx<1,>=0.27
24
+ Description-Content-Type: text/markdown
25
+
26
+ # smscode
27
+
28
+ Official Python SDK for the SMSCode virtual-number API.
29
+
30
+ Use it to rent temporary phone numbers, receive SMS OTP verification codes, and
31
+ manage order lifecycle from Python services, bots, and automations.
32
+
33
+ ## Install
34
+
35
+ ```bash
36
+ pip install smscode
37
+ ```
38
+
39
+ Requires Python 3.10+.
40
+
41
+ ## Quick start
42
+
43
+ `SmscodeClient` uses the USD-native `/v2` API by default. Money values are typed
44
+ objects with the exact IDR ledger amount preserved as `canonical_amount`.
45
+
46
+ ```py
47
+ import os
48
+
49
+ from smscode import OtpTimeoutError, OrderTerminalError, SmscodeClient
50
+
51
+ client = SmscodeClient(token=os.environ["SMSCODE_TOKEN"])
52
+
53
+ body = {
54
+ "catalog_product_id": int(os.environ["SMSCODE_CATALOG_PRODUCT_ID"]),
55
+ "max_price": "0.50", # /v2 uses a USD decimal string, never a float
56
+ "quantity": 1,
57
+ }
58
+
59
+ with client:
60
+ created = client.orders.create(body)
61
+ order = created.orders[0]
62
+ order_id = int(order["id"])
63
+
64
+ try:
65
+ otp = client.orders.wait_for_otp(order_id, timeout_ms=120_000)
66
+ print("OTP:", otp.otp_code)
67
+ # Submit otp.otp_code in your target app here.
68
+ client.orders.finish(order_id)
69
+ except (OtpTimeoutError, OrderTerminalError):
70
+ # No OTP evidence arrived. Cancel remains available only in that case.
71
+ client.orders.cancel(order_id)
72
+ ```
73
+
74
+ ## Async client
75
+
76
+ The async client has the same surface and uses `httpx.AsyncClient` internally.
77
+
78
+ ```py
79
+ import os
80
+
81
+ from smscode import AsyncSmscodeClient
82
+
83
+
84
+ async def main() -> None:
85
+ async with AsyncSmscodeClient(token=os.environ["SMSCODE_TOKEN"]) as client:
86
+ balance = await client.balance.get()
87
+ print(balance.balance.amount, balance.balance.currency)
88
+ ```
89
+
90
+ ## Resend and wait for a new OTP
91
+
92
+ `finish` does not require a new OTP after resend; the order is finishable once it
93
+ has OTP evidence. If your integration needs to wait for a different post-resend
94
+ code, pass the previous code as `after_code`.
95
+
96
+ ```py
97
+ first = client.orders.wait_for_otp(order_id)
98
+
99
+ client.orders.resend(order_id)
100
+
101
+ second = client.orders.wait_for_otp(
102
+ order_id,
103
+ after_code=first.otp_code,
104
+ timeout_ms=120_000,
105
+ )
106
+
107
+ print("new OTP:", second.otp_code)
108
+ # Submit second.otp_code in your target app here, then finish.
109
+ client.orders.finish(order_id)
110
+ ```
111
+
112
+ If the provider sends the same digits again, code-based polling cannot
113
+ distinguish it from the previous OTP.
114
+
115
+ ## Idempotent order create
116
+
117
+ Order create is money-sensitive. The SDK resolves an idempotency key before the
118
+ request, sends it as `idempotency-key`, and attaches it to create errors.
119
+
120
+ ```py
121
+ from smscode import SmscodeError
122
+
123
+ try:
124
+ created = client.orders.create(body)
125
+ except SmscodeError as err:
126
+ if err.idempotency_key is None:
127
+ raise
128
+ # Retry the exact same body with the same key. Never mint a fresh key for
129
+ # the same attempted create.
130
+ created = client.orders.create(body, idempotency_key=err.idempotency_key)
131
+ ```
132
+
133
+ ## Webhooks
134
+
135
+ Verify webhook signatures against the raw request body before parsing JSON.
136
+
137
+ ```py
138
+ from smscode import parse_webhook_event, verify_webhook_signature
139
+
140
+
141
+ def handle_webhook(raw_body: bytes, signature_header: str | None, secret: str) -> int:
142
+ if not verify_webhook_signature(raw_body, signature_header or "", secret):
143
+ return 401
144
+
145
+ event = parse_webhook_event(raw_body)
146
+ if event["event"] == "order.otp_received":
147
+ print(event["data"]["otp_code"])
148
+ return 204
149
+ ```
150
+
151
+ ## `/v1` namespace
152
+
153
+ Use `.v1` only when you intentionally want legacy IDR-only shapes.
154
+
155
+ ```py
156
+ with SmscodeClient(token=os.environ["SMSCODE_TOKEN"]) as client:
157
+ balance_v2 = client.balance.get()
158
+ balance_v1 = client.v1.balance.get()
159
+ ```
160
+
161
+ ## Error handling
162
+
163
+ Every API error is a typed `SmscodeError` subclass. Branch on the class or
164
+ `err.code`, not on `err.message`. `RateLimitError` and retryable server errors
165
+ carry `retry_after_seconds` when the API sends `Retry-After`.
166
+
167
+ ## License
168
+
169
+ MIT
@@ -0,0 +1,24 @@
1
+ smscode/__init__.py,sha256=MJdQeFt3Dwg01m85xsyV0lUHTk9Bq1jjUXbJEVALicE,3402
2
+ smscode/_async_transport.py,sha256=XyCwuXwthvQ2J-PTLJT71fIb8zI9MatxWNzJh7xqyjU,3130
3
+ smscode/_decode.py,sha256=gitDcscQmgBoTgaNmzPK-d622Xp0cfdpbNOw_ccGFcM,2446
4
+ smscode/_transport.py,sha256=NOIEY8IxEO5aREGllVXwShh60pzy6MgJphw6-CY-s1Q,3762
5
+ smscode/async_client.py,sha256=VZ4n9kFi7TNSxsxPZuUNnuuAADgmJ68Ja29np-o7cno,2611
6
+ smscode/client.py,sha256=szU5GIXZPYkC67nR8mWuTuopMU8UyZka_dA-K5TPmFw,2399
7
+ smscode/errors.py,sha256=1tZrdYdZPvAp6Xxh8qe2E9TBcLCSQCNIf-U58c8J7TA,6286
8
+ smscode/idempotency.py,sha256=e6AfamexMZguo9IHlrnHkgv5YCsp816OHGSy39z7nm0,620
9
+ smscode/models.py,sha256=DWuyW139XIgDA5W6tbQ3LIeW5UNWPdFLkr-52CSccT0,6313
10
+ smscode/money.py,sha256=cn1LvnHjqTTQd3mj5weBnxz93OgdRzd63b_99Gr9q2A,1224
11
+ smscode/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
12
+ smscode/retry.py,sha256=UOzsAt4LAn3zmf_EmNEErjwc4B2ngnrO4yfYWXqbNmM,2207
13
+ smscode/types.py,sha256=oy-sVPrAzUn3uhKLqGQ2uL5bvU4H68lz6VbpcMf8e9o,843
14
+ smscode/wait.py,sha256=DO2SpB7Jkj7wHArmwwFvJkkLeApAWYxkMqRS1disvLw,4076
15
+ smscode/webhook.py,sha256=8RfPzsk5J-dy4YPt2t0a-e9nrO7Vj-ynEJKgVlKr7Eo,2019
16
+ smscode/resources/__init__.py,sha256=zOaE3bBSEqvE4_GgR3cO_hwUfJGYl9FDgkqUt1yc6Kc,1552
17
+ smscode/resources/balance.py,sha256=TZmR-PkKzqnVP4yX30s25kVOU-8_h7y-8CUVItCN6-A,1486
18
+ smscode/resources/catalog.py,sha256=uXoR_eQlRdU9nO49z7jOReRMV-iNYouur3Rk1hWRS6g,10922
19
+ smscode/resources/orders.py,sha256=ipnTkU1MNE93zFD4NPKQHc44U-0nQVvQfbR5qS10BHM,20716
20
+ smscode/resources/webhook.py,sha256=sDheSbx0soO_Pl72zwuVeq59eIcOukdz5vPNwmD1jWg,4450
21
+ smscode-1.0.0.dist-info/METADATA,sha256=xazYDW4-q0iqLKPFbSz1nbGJpuoKHltykjvKrvhPM-4,4998
22
+ smscode-1.0.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
23
+ smscode-1.0.0.dist-info/licenses/LICENSE,sha256=yu5mpVULGeeXNzlzQaxHSgF-lh5sjI_iTktvwfghZlE,1064
24
+ smscode-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SMSCode
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.