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,625 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from collections.abc import Awaitable, Callable, Mapping
5
+ from typing import Any, TypeVar, cast
6
+
7
+ from smscode.errors import (
8
+ InvalidResponseError,
9
+ RequestCancelledError,
10
+ SmscodeError,
11
+ )
12
+ from smscode.idempotency import resolve_idempotency_key
13
+ from smscode.models import (
14
+ CancelResult,
15
+ CancelResultV2,
16
+ CreatedOrder,
17
+ CreateOrderResultV1,
18
+ CreateOrderResultV2,
19
+ FinishResult,
20
+ Order,
21
+ OrderCapabilities,
22
+ OrdersListV2,
23
+ ResendResult,
24
+ V2Fx,
25
+ parse_v2_fx,
26
+ )
27
+ from smscode.money import Money, parse_money
28
+ from smscode.types import ApiResult, OrderStatus, QueryValue
29
+ from smscode.wait import (
30
+ DEFAULT_POLL_INTERVAL_MS,
31
+ DEFAULT_TIMEOUT_MS,
32
+ OtpResult,
33
+ async_wait_for_otp,
34
+ wait_for_otp,
35
+ )
36
+
37
+ SyncRequest = Callable[..., ApiResult[Any]]
38
+ AsyncRequest = Callable[..., Awaitable[ApiResult[Any]]]
39
+ SyncSleep = Callable[[float], None]
40
+ SyncNow = Callable[[], float]
41
+ AsyncSleep = Callable[[float], Awaitable[None]]
42
+ T = TypeVar("T")
43
+ AnyList = list[Any]
44
+ OrderList = list[Order]
45
+ CreatedOrderList = list[CreatedOrder]
46
+ CAPABILITY_BOOL_FIELDS = ("can_finish", "can_resend", "can_cancel", "can_replace")
47
+ CAPABILITY_TIME_FIELDS = ("resend_available_at", "cancel_available_at", "replace_available_at")
48
+
49
+
50
+ def _orders_params(
51
+ *,
52
+ limit: int | None = None,
53
+ offset: int | None = None,
54
+ status: OrderStatus | None = None,
55
+ ) -> dict[str, QueryValue]:
56
+ return {"limit": limit, "offset": offset, "status": status}
57
+
58
+
59
+ def _capabilities(raw: Mapping[str, Any], *, label: str) -> OrderCapabilities:
60
+ _validate_capabilities(raw, label=label)
61
+ return OrderCapabilities(
62
+ can_finish=raw["can_finish"],
63
+ can_resend=raw["can_resend"],
64
+ can_cancel=raw["can_cancel"],
65
+ can_replace=raw["can_replace"],
66
+ resend_available_at=raw["resend_available_at"],
67
+ cancel_available_at=raw["cancel_available_at"],
68
+ replace_available_at=raw["replace_available_at"],
69
+ )
70
+
71
+
72
+ def _decode_v2_order(raw: object, fx: V2Fx, *, created: bool = False) -> Order:
73
+ if not isinstance(raw, Mapping):
74
+ raise InvalidResponseError("The /v2 order response is malformed.")
75
+ order = dict(raw)
76
+ order["amount"] = parse_money(raw.get("amount"))
77
+ capabilities = _capabilities(raw, label="/v2 order")
78
+ order["fx"] = fx
79
+ model = CreatedOrder if created else Order
80
+ return model(raw=order, capabilities=capabilities, amount=order["amount"], fx=fx)
81
+
82
+
83
+ def _decode_v1_order(raw: object, *, label: str, created: bool = False) -> Order:
84
+ if not isinstance(raw, Mapping):
85
+ raise InvalidResponseError(f"The {label} response is malformed.")
86
+ order = dict(raw)
87
+ capabilities = _capabilities(raw, label=label)
88
+ amount = order.get("amount")
89
+ model = CreatedOrder if created else Order
90
+ return model(
91
+ raw=order,
92
+ capabilities=capabilities,
93
+ amount=amount if isinstance(amount, (int, Money)) else None,
94
+ )
95
+
96
+
97
+ def _decode_v2_orders(raw: object, fx: V2Fx, *, created: bool = False) -> OrderList:
98
+ if not isinstance(raw, list):
99
+ raise InvalidResponseError("The /v2 orders response is malformed.")
100
+ return [_decode_v2_order(item, fx, created=created) for item in raw]
101
+
102
+
103
+ def _decode_v1_orders(raw: object, *, label: str, created: bool = False) -> OrderList:
104
+ if not isinstance(raw, list):
105
+ raise InvalidResponseError(f"The {label} response is malformed.")
106
+ return [_decode_v1_order(item, label=label, created=created) for item in raw]
107
+
108
+
109
+ def _validate_capabilities(raw: Mapping[str, Any], *, label: str) -> None:
110
+ for field in CAPABILITY_BOOL_FIELDS:
111
+ if type(raw.get(field)) is not bool:
112
+ raise InvalidResponseError(f"The {label} response is missing capability field {field}.")
113
+ for field in CAPABILITY_TIME_FIELDS:
114
+ value = raw.get(field)
115
+ if field not in raw or (value is not None and not isinstance(value, str)):
116
+ raise InvalidResponseError(f"The {label} response is missing capability field {field}.")
117
+
118
+
119
+ def _decode_v2_create_result(result: ApiResult[Any], key: str) -> CreateOrderResultV2:
120
+ data = result.data
121
+ if not isinstance(data, Mapping) or not isinstance(data.get("orders"), list):
122
+ raise InvalidResponseError("The /v2 create response is missing its orders array.")
123
+ fx = parse_v2_fx((result.meta or {}).get("fx"))
124
+ return CreateOrderResultV2(
125
+ orders=cast(list[CreatedOrder], _decode_v2_orders(data["orders"], fx, created=True)),
126
+ failed_count=_int_or_zero(data.get("failed_count")),
127
+ idempotency_key=key,
128
+ fx=fx,
129
+ )
130
+
131
+
132
+ def _decode_v1_create_result(result: ApiResult[Any], key: str) -> CreateOrderResultV1:
133
+ data = result.data
134
+ if not isinstance(data, Mapping) or not isinstance(data.get("orders"), list):
135
+ raise InvalidResponseError(
136
+ "The /v1 create response is missing its orders array; the result cannot be trusted."
137
+ )
138
+ return CreateOrderResultV1(
139
+ orders=cast(
140
+ list[CreatedOrder],
141
+ _decode_v1_orders(data["orders"], label="/v1 create", created=True),
142
+ ),
143
+ failed_count=_int_or_zero(data.get("failed_count")),
144
+ idempotency_key=key,
145
+ )
146
+
147
+
148
+ def _decode_v2_cancel_result(result: ApiResult[Any]) -> CancelResultV2:
149
+ data = result.data
150
+ if not isinstance(data, Mapping):
151
+ raise InvalidResponseError("The /v2 cancel response is malformed.")
152
+ order_id = data.get("order_id")
153
+ status = data.get("status")
154
+ if type(order_id) is not int or not isinstance(status, str):
155
+ raise InvalidResponseError("The /v2 cancel response is malformed.")
156
+ fx = parse_v2_fx((result.meta or {}).get("fx"))
157
+ return CancelResultV2(
158
+ order_id=order_id,
159
+ status=status,
160
+ refund_amount=parse_money(data.get("refund_amount")),
161
+ new_balance=parse_money(data.get("new_balance")),
162
+ fx=fx,
163
+ )
164
+
165
+
166
+ def _decode_v1_cancel_result(result: ApiResult[Any]) -> CancelResult:
167
+ data = result.data
168
+ if not isinstance(data, Mapping):
169
+ raise InvalidResponseError("The /v1 cancel response is malformed.")
170
+ order_id = data.get("order_id")
171
+ status = data.get("status")
172
+ refund_amount = data.get("refund_amount")
173
+ new_balance = data.get("new_balance")
174
+ if (
175
+ type(order_id) is not int
176
+ or not isinstance(status, str)
177
+ or type(refund_amount) is not int
178
+ or type(new_balance) is not int
179
+ ):
180
+ raise InvalidResponseError("The /v1 cancel response is malformed.")
181
+ return CancelResult(
182
+ order_id=order_id,
183
+ status=status,
184
+ refund_amount=refund_amount,
185
+ new_balance=new_balance,
186
+ )
187
+
188
+
189
+ def _decode_finish_result(result: ApiResult[Any], *, label: str) -> FinishResult:
190
+ data = result.data
191
+ if not isinstance(data, Mapping):
192
+ raise InvalidResponseError(f"The {label} response is malformed.")
193
+ order_id = data.get("order_id")
194
+ status = data.get("status")
195
+ if type(order_id) is not int or not isinstance(status, str):
196
+ raise InvalidResponseError(f"The {label} response is malformed.")
197
+ return FinishResult(order_id=order_id, status=status, raw=dict(data))
198
+
199
+
200
+ def _decode_resend_result(result: ApiResult[Any], *, label: str) -> ResendResult:
201
+ data = result.data
202
+ if not isinstance(data, Mapping):
203
+ raise InvalidResponseError(f"The {label} response is malformed.")
204
+ order_id = data.get("order_id")
205
+ status = data.get("status")
206
+ resent = data.get("resent")
207
+ if type(order_id) is not int or not isinstance(status, str):
208
+ raise InvalidResponseError(f"The {label} response is malformed.")
209
+ return ResendResult(
210
+ order_id=order_id,
211
+ status=status,
212
+ raw=dict(data),
213
+ resent=resent if type(resent) is bool else None,
214
+ )
215
+
216
+
217
+ def _int_or_zero(value: object) -> int:
218
+ return value if type(value) is int else 0
219
+
220
+
221
+ def _ensure_list(value: object, *, label: str) -> AnyList:
222
+ if not isinstance(value, list):
223
+ raise InvalidResponseError(f"The {label} response is malformed.")
224
+ return value
225
+
226
+
227
+ def _stamp_create_error(err: Exception, key: str) -> Exception:
228
+ if isinstance(err, SmscodeError):
229
+ return err.with_idempotency_key(key)
230
+ return InvalidResponseError("Failed to decode the create response.").with_idempotency_key(key)
231
+
232
+
233
+ def _create_with_idempotency(
234
+ request: SyncRequest,
235
+ path: str,
236
+ body: Mapping[str, Any],
237
+ *,
238
+ idempotency_key: str | None,
239
+ decode: Callable[[ApiResult[Any], str], T],
240
+ ) -> T:
241
+ key = resolve_idempotency_key(idempotency_key)
242
+ try:
243
+ result = request(
244
+ "POST",
245
+ path,
246
+ json=body,
247
+ headers={"idempotency-key": key},
248
+ )
249
+ return decode(result, key)
250
+ except SmscodeError as err:
251
+ raise err.with_idempotency_key(key) from err
252
+ except Exception as err:
253
+ raise _stamp_create_error(err, key) from err
254
+
255
+
256
+ async def _async_create_with_idempotency(
257
+ request: AsyncRequest,
258
+ path: str,
259
+ body: Mapping[str, Any],
260
+ *,
261
+ idempotency_key: str | None,
262
+ decode: Callable[[ApiResult[Any], str], T],
263
+ ) -> T:
264
+ key = resolve_idempotency_key(idempotency_key)
265
+ try:
266
+ result = await request(
267
+ "POST",
268
+ path,
269
+ json=body,
270
+ headers={"idempotency-key": key},
271
+ )
272
+ return decode(result, key)
273
+ except asyncio.CancelledError as err:
274
+ raise RequestCancelledError(idempotency_key=key) from err
275
+ except SmscodeError as err:
276
+ raise err.with_idempotency_key(key) from err
277
+ except Exception as err:
278
+ raise _stamp_create_error(err, key) from err
279
+
280
+
281
+ def _mutate_order(request: SyncRequest, path: str, order_id: int) -> ApiResult[Any]:
282
+ return request("POST", path, json={"id": order_id}, retry=0)
283
+
284
+
285
+ async def _async_mutate_order(request: AsyncRequest, path: str, order_id: int) -> ApiResult[Any]:
286
+ return await request("POST", path, json={"id": order_id}, retry=0)
287
+
288
+
289
+ def _create_body(body: Mapping[str, Any] | None, params: Mapping[str, Any]) -> Mapping[str, Any]:
290
+ if body is not None and params:
291
+ raise TypeError("Pass either a create body mapping or keyword parameters, not both.")
292
+ return body if body is not None else dict(params)
293
+
294
+
295
+ class V2OrdersResource:
296
+ def __init__(self, request: SyncRequest) -> None:
297
+ self._request = request
298
+
299
+ def create(
300
+ self,
301
+ body: Mapping[str, Any] | None = None,
302
+ *,
303
+ idempotency_key: str | None = None,
304
+ **params: Any,
305
+ ) -> CreateOrderResultV2:
306
+ return _create_with_idempotency(
307
+ self._request,
308
+ "/v2/orders/create",
309
+ _create_body(body, params),
310
+ idempotency_key=idempotency_key,
311
+ decode=_decode_v2_create_result,
312
+ )
313
+
314
+ def get(self, order_id: int) -> Order:
315
+ result = self._request("GET", f"/v2/orders/{order_id}")
316
+ fx = parse_v2_fx((result.meta or {}).get("fx"))
317
+ return _decode_v2_order(result.data, fx)
318
+
319
+ def list(
320
+ self,
321
+ *,
322
+ limit: int | None = None,
323
+ offset: int | None = None,
324
+ status: OrderStatus | None = None,
325
+ ) -> OrdersListV2:
326
+ result = self._request(
327
+ "GET",
328
+ "/v2/orders",
329
+ params=_orders_params(limit=limit, offset=offset, status=status),
330
+ )
331
+ fx = parse_v2_fx((result.meta or {}).get("fx"))
332
+ return OrdersListV2(orders=_decode_v2_orders(result.data, fx), fx=fx)
333
+
334
+ def active(self) -> OrderList:
335
+ return _decode_v1_orders(
336
+ self._request("GET", "/v2/orders/active").data,
337
+ label="/v2 active orders",
338
+ )
339
+
340
+ def cancel(self, order_id: int) -> CancelResultV2:
341
+ return _decode_v2_cancel_result(_mutate_order(self._request, "/v2/orders/cancel", order_id))
342
+
343
+ def finish(self, order_id: int) -> FinishResult:
344
+ return _decode_finish_result(
345
+ _mutate_order(self._request, "/v2/orders/finish", order_id),
346
+ label="/v2 finish",
347
+ )
348
+
349
+ def resend(self, order_id: int) -> ResendResult:
350
+ return _decode_resend_result(
351
+ _mutate_order(self._request, "/v2/orders/resend", order_id),
352
+ label="/v2 resend",
353
+ )
354
+
355
+ def wait_for_otp(
356
+ self,
357
+ order_id: int,
358
+ *,
359
+ after_code: str | None = None,
360
+ timeout_ms: int = DEFAULT_TIMEOUT_MS,
361
+ poll_interval_ms: int = DEFAULT_POLL_INTERVAL_MS,
362
+ sleep: SyncSleep | None = None,
363
+ now: SyncNow | None = None,
364
+ ) -> OtpResult:
365
+ return wait_for_otp(
366
+ lambda: self._request("GET", f"/v1/orders/{order_id}").data,
367
+ after_code=after_code,
368
+ timeout_ms=timeout_ms,
369
+ poll_interval_ms=poll_interval_ms,
370
+ sleep=sleep,
371
+ now=now,
372
+ )
373
+
374
+
375
+ class V1OrdersResource:
376
+ def __init__(self, request: SyncRequest) -> None:
377
+ self._request = request
378
+
379
+ def create(
380
+ self,
381
+ body: Mapping[str, Any] | None = None,
382
+ *,
383
+ idempotency_key: str | None = None,
384
+ **params: Any,
385
+ ) -> CreateOrderResultV1:
386
+ return _create_with_idempotency(
387
+ self._request,
388
+ "/v1/orders/create",
389
+ _create_body(body, params),
390
+ idempotency_key=idempotency_key,
391
+ decode=_decode_v1_create_result,
392
+ )
393
+
394
+ def get(self, order_id: int) -> Order:
395
+ return _decode_v1_order(
396
+ self._request("GET", f"/v1/orders/{order_id}").data,
397
+ label="/v1 order",
398
+ )
399
+
400
+ def list(
401
+ self,
402
+ *,
403
+ limit: int | None = None,
404
+ offset: int | None = None,
405
+ status: OrderStatus | None = None,
406
+ ) -> OrderList:
407
+ return _decode_v1_orders(
408
+ self._request(
409
+ "GET",
410
+ "/v1/orders",
411
+ params=_orders_params(limit=limit, offset=offset, status=status),
412
+ ).data,
413
+ label="/v1 orders",
414
+ )
415
+
416
+ def active(self) -> OrderList:
417
+ return _decode_v1_orders(
418
+ self._request("GET", "/v1/orders/active").data,
419
+ label="/v1 active orders",
420
+ )
421
+
422
+ def cancel(self, order_id: int) -> CancelResult:
423
+ return _decode_v1_cancel_result(_mutate_order(self._request, "/v1/orders/cancel", order_id))
424
+
425
+ def finish(self, order_id: int) -> FinishResult:
426
+ return _decode_finish_result(
427
+ _mutate_order(self._request, "/v1/orders/finish", order_id),
428
+ label="/v1 finish",
429
+ )
430
+
431
+ def resend(self, order_id: int) -> ResendResult:
432
+ return _decode_resend_result(
433
+ _mutate_order(self._request, "/v1/orders/resend", order_id),
434
+ label="/v1 resend",
435
+ )
436
+
437
+ def wait_for_otp(
438
+ self,
439
+ order_id: int,
440
+ *,
441
+ after_code: str | None = None,
442
+ timeout_ms: int = DEFAULT_TIMEOUT_MS,
443
+ poll_interval_ms: int = DEFAULT_POLL_INTERVAL_MS,
444
+ sleep: SyncSleep | None = None,
445
+ now: SyncNow | None = None,
446
+ ) -> OtpResult:
447
+ return wait_for_otp(
448
+ lambda: self.get(order_id),
449
+ after_code=after_code,
450
+ timeout_ms=timeout_ms,
451
+ poll_interval_ms=poll_interval_ms,
452
+ sleep=sleep,
453
+ now=now,
454
+ )
455
+
456
+
457
+ class AsyncV2OrdersResource:
458
+ def __init__(self, request: AsyncRequest) -> None:
459
+ self._request = request
460
+
461
+ async def create(
462
+ self,
463
+ body: Mapping[str, Any] | None = None,
464
+ *,
465
+ idempotency_key: str | None = None,
466
+ **params: Any,
467
+ ) -> CreateOrderResultV2:
468
+ return await _async_create_with_idempotency(
469
+ self._request,
470
+ "/v2/orders/create",
471
+ _create_body(body, params),
472
+ idempotency_key=idempotency_key,
473
+ decode=_decode_v2_create_result,
474
+ )
475
+
476
+ async def get(self, order_id: int) -> Order:
477
+ result = await self._request("GET", f"/v2/orders/{order_id}")
478
+ fx = parse_v2_fx((result.meta or {}).get("fx"))
479
+ return _decode_v2_order(result.data, fx)
480
+
481
+ async def list(
482
+ self,
483
+ *,
484
+ limit: int | None = None,
485
+ offset: int | None = None,
486
+ status: OrderStatus | None = None,
487
+ ) -> OrdersListV2:
488
+ result = await self._request(
489
+ "GET",
490
+ "/v2/orders",
491
+ params=_orders_params(limit=limit, offset=offset, status=status),
492
+ )
493
+ fx = parse_v2_fx((result.meta or {}).get("fx"))
494
+ return OrdersListV2(orders=_decode_v2_orders(result.data, fx), fx=fx)
495
+
496
+ async def active(self) -> OrderList:
497
+ return _decode_v1_orders(
498
+ (await self._request("GET", "/v2/orders/active")).data,
499
+ label="/v2 active orders",
500
+ )
501
+
502
+ async def cancel(self, order_id: int) -> CancelResultV2:
503
+ return _decode_v2_cancel_result(
504
+ await _async_mutate_order(self._request, "/v2/orders/cancel", order_id)
505
+ )
506
+
507
+ async def finish(self, order_id: int) -> FinishResult:
508
+ return _decode_finish_result(
509
+ await _async_mutate_order(self._request, "/v2/orders/finish", order_id),
510
+ label="/v2 finish",
511
+ )
512
+
513
+ async def resend(self, order_id: int) -> ResendResult:
514
+ return _decode_resend_result(
515
+ await _async_mutate_order(self._request, "/v2/orders/resend", order_id),
516
+ label="/v2 resend",
517
+ )
518
+
519
+ async def wait_for_otp(
520
+ self,
521
+ order_id: int,
522
+ *,
523
+ after_code: str | None = None,
524
+ timeout_ms: int = DEFAULT_TIMEOUT_MS,
525
+ poll_interval_ms: int = DEFAULT_POLL_INTERVAL_MS,
526
+ sleep: AsyncSleep | None = None,
527
+ now: SyncNow | None = None,
528
+ ) -> OtpResult:
529
+ async def poll() -> Any:
530
+ return (await self._request("GET", f"/v1/orders/{order_id}")).data
531
+
532
+ return await async_wait_for_otp(
533
+ poll,
534
+ after_code=after_code,
535
+ timeout_ms=timeout_ms,
536
+ poll_interval_ms=poll_interval_ms,
537
+ sleep=sleep,
538
+ now=now,
539
+ )
540
+
541
+
542
+ class AsyncV1OrdersResource:
543
+ def __init__(self, request: AsyncRequest) -> None:
544
+ self._request = request
545
+
546
+ async def create(
547
+ self,
548
+ body: Mapping[str, Any] | None = None,
549
+ *,
550
+ idempotency_key: str | None = None,
551
+ **params: Any,
552
+ ) -> CreateOrderResultV1:
553
+ return await _async_create_with_idempotency(
554
+ self._request,
555
+ "/v1/orders/create",
556
+ _create_body(body, params),
557
+ idempotency_key=idempotency_key,
558
+ decode=_decode_v1_create_result,
559
+ )
560
+
561
+ async def get(self, order_id: int) -> Order:
562
+ return _decode_v1_order(
563
+ (await self._request("GET", f"/v1/orders/{order_id}")).data,
564
+ label="/v1 order",
565
+ )
566
+
567
+ async def list(
568
+ self,
569
+ *,
570
+ limit: int | None = None,
571
+ offset: int | None = None,
572
+ status: OrderStatus | None = None,
573
+ ) -> OrderList:
574
+ return _decode_v1_orders(
575
+ (
576
+ await self._request(
577
+ "GET",
578
+ "/v1/orders",
579
+ params=_orders_params(limit=limit, offset=offset, status=status),
580
+ )
581
+ ).data,
582
+ label="/v1 orders",
583
+ )
584
+
585
+ async def active(self) -> OrderList:
586
+ return _decode_v1_orders(
587
+ (await self._request("GET", "/v1/orders/active")).data,
588
+ label="/v1 active orders",
589
+ )
590
+
591
+ async def cancel(self, order_id: int) -> CancelResult:
592
+ return _decode_v1_cancel_result(
593
+ await _async_mutate_order(self._request, "/v1/orders/cancel", order_id)
594
+ )
595
+
596
+ async def finish(self, order_id: int) -> FinishResult:
597
+ return _decode_finish_result(
598
+ await _async_mutate_order(self._request, "/v1/orders/finish", order_id),
599
+ label="/v1 finish",
600
+ )
601
+
602
+ async def resend(self, order_id: int) -> ResendResult:
603
+ return _decode_resend_result(
604
+ await _async_mutate_order(self._request, "/v1/orders/resend", order_id),
605
+ label="/v1 resend",
606
+ )
607
+
608
+ async def wait_for_otp(
609
+ self,
610
+ order_id: int,
611
+ *,
612
+ after_code: str | None = None,
613
+ timeout_ms: int = DEFAULT_TIMEOUT_MS,
614
+ poll_interval_ms: int = DEFAULT_POLL_INTERVAL_MS,
615
+ sleep: AsyncSleep | None = None,
616
+ now: SyncNow | None = None,
617
+ ) -> OtpResult:
618
+ return await async_wait_for_otp(
619
+ lambda: self.get(order_id),
620
+ after_code=after_code,
621
+ timeout_ms=timeout_ms,
622
+ poll_interval_ms=poll_interval_ms,
623
+ sleep=sleep,
624
+ now=now,
625
+ )