smscode 1.0.0__tar.gz
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-1.0.0/.gitignore +9 -0
- smscode-1.0.0/LICENSE +21 -0
- smscode-1.0.0/PKG-INFO +169 -0
- smscode-1.0.0/README.md +144 -0
- smscode-1.0.0/pyproject.toml +82 -0
- smscode-1.0.0/src/smscode/__init__.py +153 -0
- smscode-1.0.0/src/smscode/_async_transport.py +90 -0
- smscode-1.0.0/src/smscode/_decode.py +74 -0
- smscode-1.0.0/src/smscode/_transport.py +115 -0
- smscode-1.0.0/src/smscode/async_client.py +83 -0
- smscode-1.0.0/src/smscode/client.py +78 -0
- smscode-1.0.0/src/smscode/errors.py +224 -0
- smscode-1.0.0/src/smscode/idempotency.py +22 -0
- smscode-1.0.0/src/smscode/models.py +280 -0
- smscode-1.0.0/src/smscode/money.py +46 -0
- smscode-1.0.0/src/smscode/py.typed +1 -0
- smscode-1.0.0/src/smscode/resources/__init__.py +66 -0
- smscode-1.0.0/src/smscode/resources/balance.py +52 -0
- smscode-1.0.0/src/smscode/resources/catalog.py +359 -0
- smscode-1.0.0/src/smscode/resources/orders.py +625 -0
- smscode-1.0.0/src/smscode/resources/webhook.py +135 -0
- smscode-1.0.0/src/smscode/retry.py +66 -0
- smscode-1.0.0/src/smscode/types.py +35 -0
- smscode-1.0.0/src/smscode/wait.py +125 -0
- smscode-1.0.0/src/smscode/webhook.py +67 -0
- smscode-1.0.0/tests/contract_manifest.json +76 -0
- smscode-1.0.0/tests/fixtures/negative_v1_create_missing_orders.json +1 -0
- smscode-1.0.0/tests/fixtures/negative_v2_create_malformed_money.json +1 -0
- smscode-1.0.0/tests/fixtures/negative_v2_order_missing_capability.json +1 -0
- smscode-1.0.0/tests/fixtures/v1_balance.json +1 -0
- smscode-1.0.0/tests/fixtures/v1_cancel_result.json +1 -0
- smscode-1.0.0/tests/fixtures/v1_catalog_countries.json +1 -0
- smscode-1.0.0/tests/fixtures/v1_catalog_products.json +1 -0
- smscode-1.0.0/tests/fixtures/v1_catalog_services.json +1 -0
- smscode-1.0.0/tests/fixtures/v1_create_result.json +1 -0
- smscode-1.0.0/tests/fixtures/v1_exchange_rate.json +1 -0
- smscode-1.0.0/tests/fixtures/v1_finish_result.json +1 -0
- smscode-1.0.0/tests/fixtures/v1_order_statuses.json +1 -0
- smscode-1.0.0/tests/fixtures/v1_order_summary.json +1 -0
- smscode-1.0.0/tests/fixtures/v1_orders_list.json +1 -0
- smscode-1.0.0/tests/fixtures/v1_resend_result.json +1 -0
- smscode-1.0.0/tests/fixtures/v2_balance.json +1 -0
- smscode-1.0.0/tests/fixtures/v2_cancel_result.json +1 -0
- smscode-1.0.0/tests/fixtures/v2_catalog_products.json +1 -0
- smscode-1.0.0/tests/fixtures/v2_create_result.json +1 -0
- smscode-1.0.0/tests/fixtures/v2_exchange_rate.json +1 -0
- smscode-1.0.0/tests/fixtures/v2_order_summary.json +1 -0
- smscode-1.0.0/tests/fixtures/v2_orders_list.json +1 -0
- smscode-1.0.0/tests/fixtures/webhook_config.json +1 -0
- smscode-1.0.0/tests/fixtures/webhook_order_canceled.json +1 -0
- smscode-1.0.0/tests/fixtures/webhook_order_completed.json +1 -0
- smscode-1.0.0/tests/fixtures/webhook_order_expired.json +1 -0
- smscode-1.0.0/tests/fixtures/webhook_order_otp_received.json +1 -0
- smscode-1.0.0/tests/fixtures/webhook_test_event.json +1 -0
- smscode-1.0.0/tests/fixtures/webhook_test_result.json +1 -0
- smscode-1.0.0/tests/test_balance.py +70 -0
- smscode-1.0.0/tests/test_catalog.py +191 -0
- smscode-1.0.0/tests/test_client.py +55 -0
- smscode-1.0.0/tests/test_contract.py +168 -0
- smscode-1.0.0/tests/test_errors.py +85 -0
- smscode-1.0.0/tests/test_idempotency.py +15 -0
- smscode-1.0.0/tests/test_models.py +8 -0
- smscode-1.0.0/tests/test_money.py +25 -0
- smscode-1.0.0/tests/test_orders.py +229 -0
- smscode-1.0.0/tests/test_orders_idempotency.py +106 -0
- smscode-1.0.0/tests/test_package_install.py +93 -0
- smscode-1.0.0/tests/test_package_metadata.py +41 -0
- smscode-1.0.0/tests/test_retry.py +27 -0
- smscode-1.0.0/tests/test_types.py +25 -0
- smscode-1.0.0/tests/test_wait.py +150 -0
- smscode-1.0.0/tests/test_webhook.py +141 -0
- smscode-1.0.0/uv.lock +1098 -0
smscode-1.0.0/.gitignore
ADDED
smscode-1.0.0/LICENSE
ADDED
|
@@ -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.
|
smscode-1.0.0/PKG-INFO
ADDED
|
@@ -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
|
smscode-1.0.0/README.md
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# smscode
|
|
2
|
+
|
|
3
|
+
Official Python SDK for the SMSCode virtual-number API.
|
|
4
|
+
|
|
5
|
+
Use it to rent temporary phone numbers, receive SMS OTP verification codes, and
|
|
6
|
+
manage order lifecycle from Python services, bots, and automations.
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
pip install smscode
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Requires Python 3.10+.
|
|
15
|
+
|
|
16
|
+
## Quick start
|
|
17
|
+
|
|
18
|
+
`SmscodeClient` uses the USD-native `/v2` API by default. Money values are typed
|
|
19
|
+
objects with the exact IDR ledger amount preserved as `canonical_amount`.
|
|
20
|
+
|
|
21
|
+
```py
|
|
22
|
+
import os
|
|
23
|
+
|
|
24
|
+
from smscode import OtpTimeoutError, OrderTerminalError, SmscodeClient
|
|
25
|
+
|
|
26
|
+
client = SmscodeClient(token=os.environ["SMSCODE_TOKEN"])
|
|
27
|
+
|
|
28
|
+
body = {
|
|
29
|
+
"catalog_product_id": int(os.environ["SMSCODE_CATALOG_PRODUCT_ID"]),
|
|
30
|
+
"max_price": "0.50", # /v2 uses a USD decimal string, never a float
|
|
31
|
+
"quantity": 1,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
with client:
|
|
35
|
+
created = client.orders.create(body)
|
|
36
|
+
order = created.orders[0]
|
|
37
|
+
order_id = int(order["id"])
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
otp = client.orders.wait_for_otp(order_id, timeout_ms=120_000)
|
|
41
|
+
print("OTP:", otp.otp_code)
|
|
42
|
+
# Submit otp.otp_code in your target app here.
|
|
43
|
+
client.orders.finish(order_id)
|
|
44
|
+
except (OtpTimeoutError, OrderTerminalError):
|
|
45
|
+
# No OTP evidence arrived. Cancel remains available only in that case.
|
|
46
|
+
client.orders.cancel(order_id)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Async client
|
|
50
|
+
|
|
51
|
+
The async client has the same surface and uses `httpx.AsyncClient` internally.
|
|
52
|
+
|
|
53
|
+
```py
|
|
54
|
+
import os
|
|
55
|
+
|
|
56
|
+
from smscode import AsyncSmscodeClient
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
async def main() -> None:
|
|
60
|
+
async with AsyncSmscodeClient(token=os.environ["SMSCODE_TOKEN"]) as client:
|
|
61
|
+
balance = await client.balance.get()
|
|
62
|
+
print(balance.balance.amount, balance.balance.currency)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Resend and wait for a new OTP
|
|
66
|
+
|
|
67
|
+
`finish` does not require a new OTP after resend; the order is finishable once it
|
|
68
|
+
has OTP evidence. If your integration needs to wait for a different post-resend
|
|
69
|
+
code, pass the previous code as `after_code`.
|
|
70
|
+
|
|
71
|
+
```py
|
|
72
|
+
first = client.orders.wait_for_otp(order_id)
|
|
73
|
+
|
|
74
|
+
client.orders.resend(order_id)
|
|
75
|
+
|
|
76
|
+
second = client.orders.wait_for_otp(
|
|
77
|
+
order_id,
|
|
78
|
+
after_code=first.otp_code,
|
|
79
|
+
timeout_ms=120_000,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
print("new OTP:", second.otp_code)
|
|
83
|
+
# Submit second.otp_code in your target app here, then finish.
|
|
84
|
+
client.orders.finish(order_id)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
If the provider sends the same digits again, code-based polling cannot
|
|
88
|
+
distinguish it from the previous OTP.
|
|
89
|
+
|
|
90
|
+
## Idempotent order create
|
|
91
|
+
|
|
92
|
+
Order create is money-sensitive. The SDK resolves an idempotency key before the
|
|
93
|
+
request, sends it as `idempotency-key`, and attaches it to create errors.
|
|
94
|
+
|
|
95
|
+
```py
|
|
96
|
+
from smscode import SmscodeError
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
created = client.orders.create(body)
|
|
100
|
+
except SmscodeError as err:
|
|
101
|
+
if err.idempotency_key is None:
|
|
102
|
+
raise
|
|
103
|
+
# Retry the exact same body with the same key. Never mint a fresh key for
|
|
104
|
+
# the same attempted create.
|
|
105
|
+
created = client.orders.create(body, idempotency_key=err.idempotency_key)
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Webhooks
|
|
109
|
+
|
|
110
|
+
Verify webhook signatures against the raw request body before parsing JSON.
|
|
111
|
+
|
|
112
|
+
```py
|
|
113
|
+
from smscode import parse_webhook_event, verify_webhook_signature
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def handle_webhook(raw_body: bytes, signature_header: str | None, secret: str) -> int:
|
|
117
|
+
if not verify_webhook_signature(raw_body, signature_header or "", secret):
|
|
118
|
+
return 401
|
|
119
|
+
|
|
120
|
+
event = parse_webhook_event(raw_body)
|
|
121
|
+
if event["event"] == "order.otp_received":
|
|
122
|
+
print(event["data"]["otp_code"])
|
|
123
|
+
return 204
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## `/v1` namespace
|
|
127
|
+
|
|
128
|
+
Use `.v1` only when you intentionally want legacy IDR-only shapes.
|
|
129
|
+
|
|
130
|
+
```py
|
|
131
|
+
with SmscodeClient(token=os.environ["SMSCODE_TOKEN"]) as client:
|
|
132
|
+
balance_v2 = client.balance.get()
|
|
133
|
+
balance_v1 = client.v1.balance.get()
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## Error handling
|
|
137
|
+
|
|
138
|
+
Every API error is a typed `SmscodeError` subclass. Branch on the class or
|
|
139
|
+
`err.code`, not on `err.message`. `RateLimitError` and retryable server errors
|
|
140
|
+
carry `retry_after_seconds` when the API sends `Retry-After`.
|
|
141
|
+
|
|
142
|
+
## License
|
|
143
|
+
|
|
144
|
+
MIT
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.25"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "smscode"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Official Python SDK for the SMSCode virtual number API."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "SMSCode", email = "dev@smscode.gg" }]
|
|
13
|
+
keywords = [
|
|
14
|
+
"smscode",
|
|
15
|
+
"sms",
|
|
16
|
+
"otp",
|
|
17
|
+
"virtual-number",
|
|
18
|
+
"phone-verification",
|
|
19
|
+
"2fa",
|
|
20
|
+
"sms-verification",
|
|
21
|
+
"api-client",
|
|
22
|
+
"sdk",
|
|
23
|
+
"python",
|
|
24
|
+
"receive-sms-online",
|
|
25
|
+
"sms-otp",
|
|
26
|
+
"temporary-phone-number",
|
|
27
|
+
"virtual-phone-number",
|
|
28
|
+
"online-sms",
|
|
29
|
+
]
|
|
30
|
+
classifiers = [
|
|
31
|
+
"Development Status :: 5 - Production/Stable",
|
|
32
|
+
"Intended Audience :: Developers",
|
|
33
|
+
"License :: OSI Approved :: MIT License",
|
|
34
|
+
"Programming Language :: Python :: 3",
|
|
35
|
+
"Programming Language :: Python :: 3.10",
|
|
36
|
+
"Programming Language :: Python :: 3.11",
|
|
37
|
+
"Programming Language :: Python :: 3.12",
|
|
38
|
+
"Programming Language :: Python :: 3.13",
|
|
39
|
+
"Typing :: Typed",
|
|
40
|
+
]
|
|
41
|
+
dependencies = ["httpx>=0.27,<1"]
|
|
42
|
+
|
|
43
|
+
[project.urls]
|
|
44
|
+
Homepage = "https://smscode.gg"
|
|
45
|
+
Documentation = "https://smscode.gg/docs/ai.md"
|
|
46
|
+
Repository = "https://github.com/smscode-gg/sdks"
|
|
47
|
+
Issues = "https://github.com/smscode-gg/sdks/issues"
|
|
48
|
+
|
|
49
|
+
[dependency-groups]
|
|
50
|
+
test = ["pytest>=8", "pytest-asyncio>=0.23", "respx>=0.21", "pyyaml>=6"]
|
|
51
|
+
lint = ["ruff>=0.8"]
|
|
52
|
+
type = ["mypy>=1.11"]
|
|
53
|
+
build = ["build>=1.2", "twine>=5"]
|
|
54
|
+
|
|
55
|
+
[tool.hatch.build.targets.wheel]
|
|
56
|
+
packages = ["src/smscode"]
|
|
57
|
+
|
|
58
|
+
[tool.hatch.build.targets.sdist]
|
|
59
|
+
include = [
|
|
60
|
+
"/src/smscode",
|
|
61
|
+
"/tests",
|
|
62
|
+
"/LICENSE",
|
|
63
|
+
"/README.md",
|
|
64
|
+
"/pyproject.toml",
|
|
65
|
+
"/uv.lock",
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
[tool.ruff]
|
|
69
|
+
line-length = 100
|
|
70
|
+
target-version = "py310"
|
|
71
|
+
|
|
72
|
+
[tool.ruff.lint]
|
|
73
|
+
select = ["E", "F", "I", "UP", "B", "SIM", "RUF"]
|
|
74
|
+
|
|
75
|
+
[tool.mypy]
|
|
76
|
+
python_version = "3.10"
|
|
77
|
+
strict = true
|
|
78
|
+
packages = ["smscode"]
|
|
79
|
+
|
|
80
|
+
[tool.pytest.ini_options]
|
|
81
|
+
testpaths = ["tests"]
|
|
82
|
+
asyncio_mode = "auto"
|
|
@@ -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)
|