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/errors.py ADDED
@@ -0,0 +1,224 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from collections.abc import Mapping
5
+ from typing import Any
6
+
7
+
8
+ class SmscodeError(Exception):
9
+ default_code = "SMSCODE_ERROR"
10
+
11
+ def __init__(
12
+ self,
13
+ message: str,
14
+ *,
15
+ code: str | None = None,
16
+ http_status: int | None = None,
17
+ details: Any | None = None,
18
+ request_id: str | None = None,
19
+ retry_after_seconds: float | None = None,
20
+ idempotency_key: str | None = None,
21
+ ) -> None:
22
+ super().__init__(message)
23
+ self.message = message
24
+ self.code = code or self.default_code
25
+ self.http_status = http_status
26
+ self.details = details
27
+ self.request_id = request_id
28
+ self.retry_after_seconds = retry_after_seconds
29
+ self.idempotency_key = idempotency_key
30
+
31
+ def with_idempotency_key(self, key: str) -> SmscodeError:
32
+ if self.idempotency_key is None:
33
+ self.idempotency_key = key
34
+ return self
35
+
36
+
37
+ class UnauthorizedError(SmscodeError):
38
+ default_code = "UNAUTHORIZED"
39
+
40
+
41
+ class ForbiddenError(SmscodeError):
42
+ default_code = "FORBIDDEN"
43
+
44
+
45
+ class NotFoundError(SmscodeError):
46
+ default_code = "NOT_FOUND"
47
+
48
+
49
+ class ValidationError(SmscodeError):
50
+ default_code = "VALIDATION_ERROR"
51
+
52
+
53
+ class ProviderError(SmscodeError):
54
+ default_code = "PROVIDER_ERROR"
55
+
56
+
57
+ class NoOfferAvailableError(SmscodeError):
58
+ default_code = "NO_OFFER_AVAILABLE"
59
+
60
+
61
+ class IdempotencyKeyReuseError(SmscodeError):
62
+ default_code = "IDEMPOTENCY_KEY_REUSED"
63
+
64
+
65
+ class InsufficientBalanceError(SmscodeError):
66
+ default_code = "INSUFFICIENT_BALANCE"
67
+
68
+
69
+ class ConflictError(SmscodeError):
70
+ default_code = "CONFLICT"
71
+
72
+
73
+ class CancelTooEarlyError(SmscodeError):
74
+ default_code = "CANCEL_TOO_EARLY"
75
+
76
+
77
+ class RequestInProgressError(SmscodeError):
78
+ default_code = "REQUEST_IN_PROGRESS"
79
+
80
+
81
+ class RateLimitError(SmscodeError):
82
+ default_code = "RATE_LIMIT_EXCEEDED"
83
+
84
+
85
+ class TempBannedError(SmscodeError):
86
+ default_code = "TEMP_BANNED_ABUSE_GUARD"
87
+
88
+
89
+ class ServiceUnavailableError(SmscodeError):
90
+ default_code = "SERVICE_UNAVAILABLE"
91
+
92
+
93
+ class FxRateUnavailableError(SmscodeError):
94
+ default_code = "FX_RATE_UNAVAILABLE"
95
+
96
+
97
+ class InternalError(SmscodeError):
98
+ default_code = "INTERNAL_ERROR"
99
+
100
+
101
+ class PayloadTooLargeError(SmscodeError):
102
+ default_code = "PAYLOAD_TOO_LARGE"
103
+
104
+
105
+ class NetworkError(SmscodeError):
106
+ default_code = "NETWORK_ERROR"
107
+
108
+
109
+ class TimeoutError(SmscodeError):
110
+ default_code = "TIMEOUT"
111
+
112
+
113
+ class InvalidResponseError(SmscodeError):
114
+ default_code = "INVALID_RESPONSE"
115
+
116
+
117
+ class InvalidMoneyError(SmscodeError):
118
+ default_code = "INVALID_MONEY"
119
+
120
+
121
+ class OtpTimeoutError(SmscodeError):
122
+ default_code = "OTP_TIMEOUT"
123
+
124
+
125
+ class OrderTerminalError(SmscodeError):
126
+ default_code = "ORDER_TERMINAL"
127
+
128
+
129
+ class RequestCancelledError(asyncio.CancelledError):
130
+ code = "REQUEST_CANCELLED"
131
+
132
+ def __init__(
133
+ self,
134
+ message: str = "Request cancelled",
135
+ *,
136
+ request_id: str | None = None,
137
+ idempotency_key: str | None = None,
138
+ ) -> None:
139
+ super().__init__(message)
140
+ self.message = message
141
+ self.http_status: int | None = None
142
+ self.details: Any | None = None
143
+ self.request_id = request_id
144
+ self.retry_after_seconds: float | None = None
145
+ self.idempotency_key = idempotency_key
146
+
147
+ def with_idempotency_key(self, key: str) -> RequestCancelledError:
148
+ if self.idempotency_key is None:
149
+ self.idempotency_key = key
150
+ return self
151
+
152
+
153
+ ERROR_BY_CODE: dict[str, type[SmscodeError]] = {
154
+ "UNAUTHORIZED": UnauthorizedError,
155
+ "FORBIDDEN": ForbiddenError,
156
+ "NOT_FOUND": NotFoundError,
157
+ "VALIDATION_ERROR": ValidationError,
158
+ "PROVIDER_ERROR": ProviderError,
159
+ "NO_OFFER_AVAILABLE": NoOfferAvailableError,
160
+ "IDEMPOTENCY_KEY_REUSED": IdempotencyKeyReuseError,
161
+ "INSUFFICIENT_BALANCE": InsufficientBalanceError,
162
+ "CONFLICT": ConflictError,
163
+ "CANCEL_TOO_EARLY": CancelTooEarlyError,
164
+ "REQUEST_IN_PROGRESS": RequestInProgressError,
165
+ "RATE_LIMIT_EXCEEDED": RateLimitError,
166
+ "TEMP_BANNED_ABUSE_GUARD": TempBannedError,
167
+ "SERVICE_UNAVAILABLE": ServiceUnavailableError,
168
+ "FX_RATE_UNAVAILABLE": FxRateUnavailableError,
169
+ "INTERNAL_ERROR": InternalError,
170
+ "PAYLOAD_TOO_LARGE": PayloadTooLargeError,
171
+ }
172
+
173
+ STATUS_FALLBACK: dict[int, tuple[str, type[SmscodeError], str]] = {
174
+ 401: ("UNAUTHORIZED", UnauthorizedError, "Unauthorized"),
175
+ 402: ("INSUFFICIENT_BALANCE", InsufficientBalanceError, "Insufficient balance"),
176
+ 403: ("FORBIDDEN", ForbiddenError, "Forbidden"),
177
+ 404: ("NOT_FOUND", NotFoundError, "Not found"),
178
+ 409: ("CONFLICT", ConflictError, "Conflict"),
179
+ 413: ("PAYLOAD_TOO_LARGE", PayloadTooLargeError, "Payload too large"),
180
+ 422: ("VALIDATION_ERROR", ValidationError, "Validation error"),
181
+ 429: ("RATE_LIMIT_EXCEEDED", RateLimitError, "Rate limit exceeded"),
182
+ 500: ("INTERNAL_ERROR", InternalError, "Internal server error"),
183
+ 503: ("SERVICE_UNAVAILABLE", ServiceUnavailableError, "Service unavailable"),
184
+ }
185
+
186
+
187
+ def map_error(
188
+ *,
189
+ status: int,
190
+ payload: Mapping[str, Any] | None,
191
+ request_id: str | None,
192
+ retry_after_seconds: float | None,
193
+ ) -> SmscodeError:
194
+ error = payload.get("error") if isinstance(payload, Mapping) else None
195
+ if isinstance(error, Mapping):
196
+ code = error.get("code")
197
+ message = error.get("message")
198
+ details = error.get("details")
199
+ else:
200
+ code = None
201
+ message = None
202
+ details = None
203
+
204
+ if isinstance(code, str) and code:
205
+ normalized_code = code
206
+ error_cls = ERROR_BY_CODE.get(normalized_code, SmscodeError)
207
+ normalized_message = (
208
+ message if isinstance(message, str) and message else "SMSCode API error"
209
+ )
210
+ else:
211
+ normalized_code, error_cls, fallback_message = STATUS_FALLBACK.get(
212
+ status,
213
+ ("UNKNOWN_ERROR", SmscodeError, "SMSCode API error"),
214
+ )
215
+ normalized_message = message if isinstance(message, str) and message else fallback_message
216
+
217
+ return error_cls(
218
+ normalized_message,
219
+ code=normalized_code,
220
+ http_status=status,
221
+ details=details,
222
+ request_id=request_id,
223
+ retry_after_seconds=retry_after_seconds,
224
+ )
smscode/idempotency.py ADDED
@@ -0,0 +1,22 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import uuid
5
+
6
+ from smscode.errors import ValidationError
7
+
8
+ IDEMPOTENCY_KEY_PATTERN = re.compile(r"^[A-Za-z0-9_-]{1,128}$")
9
+
10
+
11
+ def is_valid_idempotency_key(key: str) -> bool:
12
+ return bool(IDEMPOTENCY_KEY_PATTERN.fullmatch(key))
13
+
14
+
15
+ def resolve_idempotency_key(provided: str | None = None) -> str:
16
+ if provided is not None:
17
+ if not is_valid_idempotency_key(provided):
18
+ raise ValidationError(
19
+ f"Invalid idempotency key {provided!r} (allowed: A-Z a-z 0-9 _ - ; 1-128 chars)."
20
+ )
21
+ return provided
22
+ return str(uuid.uuid4())
smscode/models.py ADDED
@@ -0,0 +1,280 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+ from dataclasses import dataclass, field
5
+ from typing import Any
6
+
7
+ from smscode.errors import InvalidResponseError
8
+ from smscode.money import Money
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class V2Fx:
13
+ pair: str
14
+ rate: int | str
15
+ rate_as_of: str | None = None
16
+ source: str | None = None
17
+ as_of: str | None = None
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class ExchangeRate:
22
+ pair: str
23
+ rate: int | str
24
+ base_currency: str | None = None
25
+ quote_currency: str | None = None
26
+ rate_as_of: str | None = None
27
+ raw: dict[str, Any] = field(default_factory=dict, compare=False)
28
+
29
+ def __getitem__(self, key: str) -> Any:
30
+ return self.raw[key]
31
+
32
+ def get(self, key: str, default: Any = None) -> Any:
33
+ return self.raw.get(key, default)
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class Country:
38
+ id: int
39
+ name: str
40
+ code: str | None = None
41
+ dial_code: str | None = None
42
+ emoji: str | None = None
43
+ active: bool = True
44
+ raw: dict[str, Any] = field(default_factory=dict, compare=False)
45
+
46
+ def __getitem__(self, key: str) -> Any:
47
+ return self.raw[key]
48
+
49
+ def get(self, key: str, default: Any = None) -> Any:
50
+ return self.raw.get(key, default)
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class Service:
55
+ id: int
56
+ name: str
57
+ code: str
58
+ active: bool = True
59
+ raw: dict[str, Any] = field(default_factory=dict, compare=False)
60
+
61
+ def __getitem__(self, key: str) -> Any:
62
+ return self.raw[key]
63
+
64
+ def get(self, key: str, default: Any = None) -> Any:
65
+ return self.raw.get(key, default)
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class OrderCapabilities:
70
+ can_finish: bool
71
+ can_resend: bool
72
+ can_cancel: bool
73
+ can_replace: bool
74
+ resend_available_at: str | None = None
75
+ cancel_available_at: str | None = None
76
+ replace_available_at: str | None = None
77
+
78
+
79
+ @dataclass(frozen=True)
80
+ class Order:
81
+ raw: dict[str, Any]
82
+ capabilities: OrderCapabilities
83
+ amount: Money | int | None = None
84
+ fx: V2Fx | None = None
85
+
86
+ def __getitem__(self, key: str) -> Any:
87
+ return self.raw[key]
88
+
89
+ def get(self, key: str, default: Any = None) -> Any:
90
+ return self.raw.get(key, default)
91
+
92
+
93
+ @dataclass(frozen=True)
94
+ class CreatedOrder(Order):
95
+ pass
96
+
97
+
98
+ @dataclass(frozen=True)
99
+ class FinishResult:
100
+ order_id: int
101
+ status: str
102
+ raw: dict[str, Any]
103
+
104
+ def __getitem__(self, key: str) -> Any:
105
+ return self.raw[key]
106
+
107
+
108
+ @dataclass(frozen=True)
109
+ class ResendResult:
110
+ order_id: int
111
+ status: str
112
+ raw: dict[str, Any]
113
+ resent: bool | None = None
114
+
115
+ def __getitem__(self, key: str) -> Any:
116
+ return self.raw[key]
117
+
118
+
119
+ @dataclass(frozen=True)
120
+ class WebhookConfig:
121
+ webhook_url: str | None
122
+ webhook_secret: str | None
123
+ webhook_disabled_at: str | None
124
+ webhook_disabled_reason: str | None
125
+ webhook_consecutive_failures: int
126
+ raw: dict[str, Any]
127
+
128
+ def __getitem__(self, key: str) -> Any:
129
+ return self.raw[key]
130
+
131
+
132
+ @dataclass(frozen=True)
133
+ class WebhookTestResult:
134
+ status_code: int
135
+ raw: dict[str, Any]
136
+
137
+ def __getitem__(self, key: str) -> Any:
138
+ return self.raw[key]
139
+
140
+ @property
141
+ def status(self) -> int:
142
+ return self.status_code
143
+
144
+
145
+ @dataclass(frozen=True)
146
+ class BalanceV2:
147
+ balance: Money
148
+ fx: V2Fx
149
+
150
+
151
+ @dataclass(frozen=True)
152
+ class BalanceV1:
153
+ balance: int
154
+ currency: str = "IDR"
155
+ raw: dict[str, Any] = field(default_factory=dict, compare=False)
156
+
157
+ def __getitem__(self, key: str) -> Any:
158
+ return self.raw[key]
159
+
160
+ def get(self, key: str, default: Any = None) -> Any:
161
+ return self.raw.get(key, default)
162
+
163
+
164
+ @dataclass(frozen=True)
165
+ class Product:
166
+ id: int
167
+ name: str | None
168
+ country_id: int | None
169
+ platform_id: int | None
170
+ available: int
171
+ price: Money | int | None
172
+ active: bool
173
+ catalog_product_id: int | None = None
174
+ raw: dict[str, Any] = field(default_factory=dict, compare=False)
175
+
176
+ def __getitem__(self, key: str) -> Any:
177
+ return self.raw[key]
178
+
179
+ def get(self, key: str, default: Any = None) -> Any:
180
+ return self.raw.get(key, default)
181
+
182
+
183
+ @dataclass(frozen=True)
184
+ class ProductV2(Product):
185
+ price: Money
186
+
187
+
188
+ @dataclass(frozen=True)
189
+ class ProductsPage:
190
+ products: list[Product]
191
+ page: int | None
192
+ limit: int | None
193
+ count: int | None
194
+ fx: V2Fx | None = None
195
+
196
+
197
+ @dataclass(frozen=True)
198
+ class ProductsPageV2(ProductsPage):
199
+ products: list[Product]
200
+ page: int | None
201
+ limit: int | None
202
+ count: int | None
203
+ fx: V2Fx
204
+
205
+
206
+ @dataclass(frozen=True)
207
+ class ProductsPageV1(ProductsPage):
208
+ pass
209
+
210
+
211
+ @dataclass(frozen=True)
212
+ class OrdersList:
213
+ orders: list[Order]
214
+ fx: V2Fx | None = None
215
+
216
+
217
+ @dataclass(frozen=True)
218
+ class OrdersListV2(OrdersList):
219
+ orders: list[Order]
220
+ fx: V2Fx
221
+
222
+
223
+ @dataclass(frozen=True)
224
+ class CreateOrderResult:
225
+ orders: list[CreatedOrder]
226
+ failed_count: int
227
+ idempotency_key: str
228
+ fx: V2Fx | None = None
229
+
230
+
231
+ @dataclass(frozen=True)
232
+ class CreateOrderResultV2(CreateOrderResult):
233
+ orders: list[CreatedOrder]
234
+ failed_count: int
235
+ idempotency_key: str
236
+ fx: V2Fx
237
+
238
+
239
+ @dataclass(frozen=True)
240
+ class CreateOrderResultV1(CreateOrderResult):
241
+ pass
242
+
243
+
244
+ @dataclass(frozen=True)
245
+ class CancelResult:
246
+ order_id: int
247
+ status: str
248
+ refund_amount: Money | int
249
+ new_balance: Money | int
250
+ fx: V2Fx | None = None
251
+
252
+
253
+ @dataclass(frozen=True)
254
+ class CancelResultV2(CancelResult):
255
+ order_id: int
256
+ status: str
257
+ refund_amount: Money
258
+ new_balance: Money
259
+ fx: V2Fx
260
+
261
+
262
+ def parse_v2_fx(value: object) -> V2Fx:
263
+ if not isinstance(value, Mapping):
264
+ raise InvalidResponseError("The /v2 response is missing its FX receipt.")
265
+ pair = value.get("pair")
266
+ rate = value.get("rate")
267
+ rate_as_of = value.get("rate_as_of")
268
+ if not isinstance(pair, str) or not isinstance(rate, (int, str)):
269
+ raise InvalidResponseError("The /v2 response has a malformed FX receipt.")
270
+ if rate_as_of is not None and not isinstance(rate_as_of, str):
271
+ raise InvalidResponseError("The /v2 response has a malformed FX receipt.")
272
+ source = value.get("source")
273
+ as_of = value.get("as_of")
274
+ return V2Fx(
275
+ pair=pair,
276
+ rate=rate,
277
+ rate_as_of=rate_as_of,
278
+ source=source if isinstance(source, str) else None,
279
+ as_of=as_of if isinstance(as_of, str) else None,
280
+ )
smscode/money.py ADDED
@@ -0,0 +1,46 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+ from dataclasses import dataclass
5
+ from typing import Literal
6
+
7
+ from smscode.errors import InvalidMoneyError
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class Money:
12
+ amount: str
13
+ currency: Literal["USD"]
14
+ canonical_amount: int
15
+ canonical_currency: Literal["IDR"]
16
+
17
+
18
+ def parse_money(value: object) -> Money:
19
+ if not isinstance(value, Mapping):
20
+ raise InvalidMoneyError("A money value is missing or malformed in the response.")
21
+
22
+ amount = value.get("amount")
23
+ currency = value.get("currency")
24
+ canonical_amount = value.get("canonical_amount")
25
+ canonical_currency = value.get("canonical_currency")
26
+
27
+ if (
28
+ not isinstance(amount, str)
29
+ or currency != "USD"
30
+ or type(canonical_amount) is not int
31
+ or canonical_currency != "IDR"
32
+ ):
33
+ raise InvalidMoneyError("A money value is missing or malformed in the response.")
34
+
35
+ return Money(
36
+ amount=amount,
37
+ currency="USD",
38
+ canonical_amount=canonical_amount,
39
+ canonical_currency="IDR",
40
+ )
41
+
42
+
43
+ def parse_optional_money(value: object | None) -> Money | None:
44
+ if value is None:
45
+ return None
46
+ return parse_money(value)
smscode/py.typed ADDED
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,66 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from smscode.resources.balance import (
6
+ AsyncV1BalanceResource,
7
+ AsyncV2BalanceResource,
8
+ V1BalanceResource,
9
+ V2BalanceResource,
10
+ )
11
+ from smscode.resources.catalog import (
12
+ AsyncV1CatalogResource,
13
+ AsyncV2CatalogResource,
14
+ V1CatalogResource,
15
+ V2CatalogResource,
16
+ )
17
+ from smscode.resources.orders import (
18
+ AsyncV1OrdersResource,
19
+ AsyncV2OrdersResource,
20
+ V1OrdersResource,
21
+ V2OrdersResource,
22
+ )
23
+ from smscode.resources.webhook import (
24
+ AsyncV1WebhookResource,
25
+ AsyncV2WebhookResource,
26
+ V1WebhookResource,
27
+ V2WebhookResource,
28
+ )
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class V1CatalogBalanceNamespace:
33
+ catalog: V1CatalogResource
34
+ balance: V1BalanceResource
35
+ orders: V1OrdersResource
36
+ webhook: V1WebhookResource
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class AsyncV1CatalogBalanceNamespace:
41
+ catalog: AsyncV1CatalogResource
42
+ balance: AsyncV1BalanceResource
43
+ orders: AsyncV1OrdersResource
44
+ webhook: AsyncV1WebhookResource
45
+
46
+
47
+ __all__ = [
48
+ "AsyncV1BalanceResource",
49
+ "AsyncV1CatalogBalanceNamespace",
50
+ "AsyncV1CatalogResource",
51
+ "AsyncV1OrdersResource",
52
+ "AsyncV1WebhookResource",
53
+ "AsyncV2BalanceResource",
54
+ "AsyncV2CatalogResource",
55
+ "AsyncV2OrdersResource",
56
+ "AsyncV2WebhookResource",
57
+ "V1BalanceResource",
58
+ "V1CatalogBalanceNamespace",
59
+ "V1CatalogResource",
60
+ "V1OrdersResource",
61
+ "V1WebhookResource",
62
+ "V2BalanceResource",
63
+ "V2CatalogResource",
64
+ "V2OrdersResource",
65
+ "V2WebhookResource",
66
+ ]
@@ -0,0 +1,52 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from smscode.models import BalanceV1, BalanceV2, parse_v2_fx
6
+ from smscode.money import parse_money
7
+
8
+
9
+ def _decode_v2_balance(result: Any) -> BalanceV2:
10
+ return BalanceV2(balance=parse_money(result.data["balance"]), fx=parse_v2_fx(result.meta["fx"]))
11
+
12
+
13
+ def _decode_v1_balance(result: Any) -> BalanceV1:
14
+ data = result.data
15
+ currency = data.get("currency") if isinstance(data, dict) else None
16
+ return BalanceV1(
17
+ balance=int(data["balance"]),
18
+ currency=currency if isinstance(currency, str) else "IDR",
19
+ raw=dict(data),
20
+ )
21
+
22
+
23
+ class V2BalanceResource:
24
+ def __init__(self, request: Any) -> None:
25
+ self._request = request
26
+
27
+ def get(self) -> BalanceV2:
28
+ return _decode_v2_balance(self._request("GET", "/v2/balance"))
29
+
30
+
31
+ class V1BalanceResource:
32
+ def __init__(self, request: Any) -> None:
33
+ self._request = request
34
+
35
+ def get(self) -> BalanceV1:
36
+ return _decode_v1_balance(self._request("GET", "/v1/balance"))
37
+
38
+
39
+ class AsyncV2BalanceResource:
40
+ def __init__(self, request: Any) -> None:
41
+ self._request = request
42
+
43
+ async def get(self) -> BalanceV2:
44
+ return _decode_v2_balance(await self._request("GET", "/v2/balance"))
45
+
46
+
47
+ class AsyncV1BalanceResource:
48
+ def __init__(self, request: Any) -> None:
49
+ self._request = request
50
+
51
+ async def get(self) -> BalanceV1:
52
+ return _decode_v1_balance(await self._request("GET", "/v1/balance"))