thunder-solution-sdk 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.
- thunder_slip/__init__.py +12 -0
- thunder_slip/_base_error.py +8 -0
- thunder_slip/_codec.py +102 -0
- thunder_slip/_transport.py +282 -0
- thunder_slip/client.py +860 -0
- thunder_slip/errors.py +367 -0
- thunder_slip/files.py +59 -0
- thunder_slip/models.py +600 -0
- thunder_slip/py.typed +0 -0
- thunder_slip/webhook.py +26 -0
- thunder_slip/webhooks.py +25 -0
- thunder_solution_sdk-1.0.0.dist-info/METADATA +452 -0
- thunder_solution_sdk-1.0.0.dist-info/RECORD +16 -0
- thunder_solution_sdk-1.0.0.dist-info/WHEEL +5 -0
- thunder_solution_sdk-1.0.0.dist-info/licenses/LICENSE +21 -0
- thunder_solution_sdk-1.0.0.dist-info/top_level.txt +1 -0
thunder_slip/client.py
ADDED
|
@@ -0,0 +1,860 @@
|
|
|
1
|
+
"""Resource clients with matching synchronous and asynchronous APIs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
import asyncio
|
|
5
|
+
import os
|
|
6
|
+
import time
|
|
7
|
+
import queue
|
|
8
|
+
import threading
|
|
9
|
+
from dataclasses import replace
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from collections.abc import Iterator, AsyncIterator
|
|
12
|
+
from typing import Any, BinaryIO, TypeVar, cast
|
|
13
|
+
import httpx
|
|
14
|
+
from . import models as m
|
|
15
|
+
from ._codec import decode, encode, UNSET
|
|
16
|
+
from ._transport import Transport, AsyncTransport
|
|
17
|
+
from .files import image_input
|
|
18
|
+
from .errors import (
|
|
19
|
+
InputError,
|
|
20
|
+
BatchSubmitError,
|
|
21
|
+
JobWaitTimeoutError,
|
|
22
|
+
RequestTimeoutError,
|
|
23
|
+
rate_limit,
|
|
24
|
+
redact,
|
|
25
|
+
redact_header,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
T = TypeVar("T", bound=m.Model)
|
|
29
|
+
FileInput = str | Path | bytes | BinaryIO
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _settings(api_key: str | None, v2: str | None, v1: str | None) -> tuple[str, str, str, str]:
|
|
33
|
+
key = api_key or os.environ.get("THUNDER_API_KEY")
|
|
34
|
+
if not key:
|
|
35
|
+
file = os.environ.get("THUNDER_API_KEY_FILE")
|
|
36
|
+
if file:
|
|
37
|
+
try:
|
|
38
|
+
key = Path(file).read_text().strip()
|
|
39
|
+
except OSError:
|
|
40
|
+
raise InputError("INVALID_ARGUMENT", "Cannot read API key file") from None
|
|
41
|
+
if not key:
|
|
42
|
+
raise InputError("INVALID_ARGUMENT", "API key is required")
|
|
43
|
+
return (
|
|
44
|
+
key,
|
|
45
|
+
v2 or os.environ.get("THUNDER_V2_BASE_URL") or "https://api.thunder.in.th/v2",
|
|
46
|
+
v1 or os.environ.get("THUNDER_V1_BASE_URL") or "https://api.thunder.in.th/v1",
|
|
47
|
+
"thunder-slip-python/1.0.0", # x-release-please-version
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _text(value: str, maximum: int) -> str:
|
|
52
|
+
if not isinstance(value, str) or not 1 <= len(value) <= maximum:
|
|
53
|
+
raise InputError("INVALID_ARGUMENT", "String argument has invalid length")
|
|
54
|
+
return value
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _id(value: int) -> str:
|
|
58
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
|
|
59
|
+
raise InputError("INVALID_ARGUMENT", "Account id must be positive")
|
|
60
|
+
return str(value)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _form(value: Any) -> str:
|
|
64
|
+
if isinstance(value, bool):
|
|
65
|
+
return "true" if value else "false"
|
|
66
|
+
if isinstance(value, float):
|
|
67
|
+
return format(value, ".15g")
|
|
68
|
+
return str(value)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _decode_response(model: type[T], response: httpx.Response, secret: str) -> T:
|
|
72
|
+
body = response.json()
|
|
73
|
+
data = body["data"]
|
|
74
|
+
result = (
|
|
75
|
+
cast(T, m.BankList(items=[decode(m.Bank, x) for x in data]))
|
|
76
|
+
if model is m.BankList
|
|
77
|
+
else decode(model, data)
|
|
78
|
+
)
|
|
79
|
+
headers = {k: redact_header(k, v, secret) for k, v in response.headers.items()}
|
|
80
|
+
result.meta = m.ResponseMeta(
|
|
81
|
+
status=response.status_code,
|
|
82
|
+
request_id=headers.get("x-request-id") or headers.get("cf-ray"),
|
|
83
|
+
message=redact(body["message"], secret) if isinstance(body.get("message"), str) else None,
|
|
84
|
+
rate_limit=rate_limit(headers),
|
|
85
|
+
headers=headers,
|
|
86
|
+
)
|
|
87
|
+
return result
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class Resource:
|
|
91
|
+
"""Internal resource base; transports remain owned by the parent client."""
|
|
92
|
+
|
|
93
|
+
def __init__(self, transport: Transport) -> None:
|
|
94
|
+
self._transport = transport
|
|
95
|
+
|
|
96
|
+
def _call(
|
|
97
|
+
self, model: type[T], method: str, path: str, *, safe: bool = False, **kwargs: Any
|
|
98
|
+
) -> T:
|
|
99
|
+
response = self._transport.request(method, path, safe=safe, **kwargs)
|
|
100
|
+
return _decode_response(model, response, self._transport._key)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class Verify(Resource):
|
|
104
|
+
"""Billed bank and wallet verification operations."""
|
|
105
|
+
|
|
106
|
+
def _verify(
|
|
107
|
+
self,
|
|
108
|
+
field: str,
|
|
109
|
+
value: str | FileInput,
|
|
110
|
+
options: m.VerifyOptions | None,
|
|
111
|
+
wallet: bool = False,
|
|
112
|
+
) -> m.BankSlipResult | m.TrueWalletResult:
|
|
113
|
+
body = encode(options) if options else {}
|
|
114
|
+
path = "/verify/truewallet" if wallet else "/verify/bank"
|
|
115
|
+
model: type[m.TrueWalletResult] | type[m.BankSlipResult] = (
|
|
116
|
+
m.TrueWalletResult if wallet else m.BankSlipResult
|
|
117
|
+
)
|
|
118
|
+
if field == "image":
|
|
119
|
+
image = image_input(value, max_bytes=4194304)
|
|
120
|
+
return self._call(
|
|
121
|
+
model,
|
|
122
|
+
"POST",
|
|
123
|
+
path,
|
|
124
|
+
data={k: _form(v) for k, v in body.items()},
|
|
125
|
+
files={"image": (image.filename, image.data, image.content_type)},
|
|
126
|
+
)
|
|
127
|
+
body[field] = value
|
|
128
|
+
return self._call(model, "POST", path, json=body)
|
|
129
|
+
|
|
130
|
+
def bank_by_payload(
|
|
131
|
+
self, payload: str, options: m.VerifyOptions | None = None
|
|
132
|
+
) -> m.BankSlipResult:
|
|
133
|
+
"""Verify one bank slip using payload; may spend quota."""
|
|
134
|
+
_text(payload, 128)
|
|
135
|
+
return cast(m.BankSlipResult, self._verify("payload", payload, options, False))
|
|
136
|
+
|
|
137
|
+
def bank_by_image(
|
|
138
|
+
self, file: FileInput, options: m.VerifyOptions | None = None
|
|
139
|
+
) -> m.BankSlipResult:
|
|
140
|
+
"""Verify one bank slip using image; may spend quota."""
|
|
141
|
+
return cast(m.BankSlipResult, self._verify("image", file, options, False))
|
|
142
|
+
|
|
143
|
+
def bank_by_base64(
|
|
144
|
+
self, base64: str, options: m.VerifyOptions | None = None
|
|
145
|
+
) -> m.BankSlipResult:
|
|
146
|
+
"""Verify one bank slip using base64; may spend quota."""
|
|
147
|
+
return cast(m.BankSlipResult, self._verify("base64", base64, options, False))
|
|
148
|
+
|
|
149
|
+
def bank_by_url(self, url: str, options: m.VerifyOptions | None = None) -> m.BankSlipResult:
|
|
150
|
+
"""Verify one bank slip using url; may spend quota."""
|
|
151
|
+
_text(url, 2048)
|
|
152
|
+
return cast(m.BankSlipResult, self._verify("url", url, options, False))
|
|
153
|
+
|
|
154
|
+
def truewallet_by_image(
|
|
155
|
+
self, file: FileInput, options: m.VerifyOptions | None = None
|
|
156
|
+
) -> m.TrueWalletResult:
|
|
157
|
+
"""Verify one wallet slip using image; may spend quota."""
|
|
158
|
+
return cast(m.TrueWalletResult, self._verify("image", file, options, True))
|
|
159
|
+
|
|
160
|
+
def truewallet_by_base64(
|
|
161
|
+
self, base64: str, options: m.VerifyOptions | None = None
|
|
162
|
+
) -> m.TrueWalletResult:
|
|
163
|
+
"""Verify one wallet slip using base64; may spend quota."""
|
|
164
|
+
return cast(m.TrueWalletResult, self._verify("base64", base64, options, True))
|
|
165
|
+
|
|
166
|
+
def truewallet_by_url(
|
|
167
|
+
self, url: str, options: m.VerifyOptions | None = None
|
|
168
|
+
) -> m.TrueWalletResult:
|
|
169
|
+
"""Verify one wallet slip using url; may spend quota."""
|
|
170
|
+
_text(url, 255)
|
|
171
|
+
return cast(m.TrueWalletResult, self._verify("url", url, options, True))
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class Banks(Resource):
|
|
175
|
+
"""Bank catalog."""
|
|
176
|
+
|
|
177
|
+
def list(self) -> m.BankList:
|
|
178
|
+
"""Return supported banks and their verification choices."""
|
|
179
|
+
return self._call(m.BankList, "GET", "/banks", safe=True)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class BankAccounts(Resource):
|
|
183
|
+
"""Receiving account management and lazy pagination."""
|
|
184
|
+
|
|
185
|
+
def _list(
|
|
186
|
+
self, params: m.ListBankAccountsParams | None, all_accounts: bool
|
|
187
|
+
) -> m.BankAccountPage:
|
|
188
|
+
query = encode(params) if params else {}
|
|
189
|
+
if "page" in query and (type(query["page"]) is not int or query["page"] < 1):
|
|
190
|
+
raise InputError("INVALID_ARGUMENT", "page must be positive")
|
|
191
|
+
if "limit" in query and (type(query["limit"]) is not int or not 1 <= query["limit"] <= 100):
|
|
192
|
+
raise InputError("INVALID_ARGUMENT", "limit must be between 1 and 100")
|
|
193
|
+
return self._call(
|
|
194
|
+
m.BankAccountPage,
|
|
195
|
+
"GET",
|
|
196
|
+
"/bank-accounts" + ("/all" if all_accounts else ""),
|
|
197
|
+
safe=True,
|
|
198
|
+
params={k: _form(v) for k, v in query.items()},
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
def list(self, params: m.ListBankAccountsParams | None = None) -> m.BankAccountPage:
|
|
202
|
+
"""Return one page linked to this branch."""
|
|
203
|
+
return self._list(params, False)
|
|
204
|
+
|
|
205
|
+
def list_all(self, params: m.ListBankAccountsParams | None = None) -> m.BankAccountPage:
|
|
206
|
+
"""Return one page across the service."""
|
|
207
|
+
return self._list(params, True)
|
|
208
|
+
|
|
209
|
+
def get(self, id: int) -> m.BankAccount:
|
|
210
|
+
"""Read one receiving account."""
|
|
211
|
+
return self._call(m.BankAccount, "GET", "/bank-accounts/" + _id(id), safe=True)
|
|
212
|
+
|
|
213
|
+
def create(self, params: m.CreateBankAccountParams) -> m.BankAccount:
|
|
214
|
+
"""Create an account; ambiguous failures are never retried."""
|
|
215
|
+
return self._call(m.BankAccount, "POST", "/bank-accounts", json=encode(params))
|
|
216
|
+
|
|
217
|
+
def update(self, id: int, params: m.UpdateBankAccountParams) -> m.BankAccount:
|
|
218
|
+
"""Update selected fields; explicit extra_verify=None clears that field."""
|
|
219
|
+
return self._call(m.BankAccount, "PATCH", "/bank-accounts/" + _id(id), json=encode(params))
|
|
220
|
+
|
|
221
|
+
def link(self, id: int) -> m.LinkResult:
|
|
222
|
+
"""Idempotently link an account to this branch."""
|
|
223
|
+
return self._call(m.LinkResult, "POST", "/bank-accounts/" + _id(id) + "/link", safe=True)
|
|
224
|
+
|
|
225
|
+
def unlink(self, id: int) -> m.UnlinkResult:
|
|
226
|
+
"""Unlink an account from this branch."""
|
|
227
|
+
return self._call(m.UnlinkResult, "DELETE", "/bank-accounts/" + _id(id) + "/link")
|
|
228
|
+
|
|
229
|
+
def _iterate(
|
|
230
|
+
self, params: m.ListBankAccountsParams | None, all_accounts: bool
|
|
231
|
+
) -> Iterator[m.BankAccount]:
|
|
232
|
+
params = params or m.ListBankAccountsParams()
|
|
233
|
+
page = params.page if params.page is not UNSET else 1
|
|
234
|
+
limit = params.limit if params.limit is not UNSET else 100
|
|
235
|
+
while True:
|
|
236
|
+
response = self._list(replace(params, page=page, limit=limit), all_accounts)
|
|
237
|
+
for item in response.items:
|
|
238
|
+
yield item
|
|
239
|
+
if not response.items or page * limit >= response.total:
|
|
240
|
+
break
|
|
241
|
+
page += 1
|
|
242
|
+
|
|
243
|
+
def iterate(self, params: m.ListBankAccountsParams | None = None) -> Iterator[m.BankAccount]:
|
|
244
|
+
"""Lazily walk every linked account, starting at params.page."""
|
|
245
|
+
for item in self._iterate(params, False):
|
|
246
|
+
yield item
|
|
247
|
+
|
|
248
|
+
def iterate_all(
|
|
249
|
+
self, params: m.ListBankAccountsParams | None = None
|
|
250
|
+
) -> Iterator[m.BankAccount]:
|
|
251
|
+
"""Lazily walk service accounts, requesting at most one page at a time."""
|
|
252
|
+
for item in self._iterate(params, True):
|
|
253
|
+
yield item
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
class Jobs(Resource):
|
|
257
|
+
"""Async verification, polling and independent batch submission."""
|
|
258
|
+
|
|
259
|
+
def submit_bank(
|
|
260
|
+
self, payload: str, options: m.AsyncVerifyOptions | None = None
|
|
261
|
+
) -> m.JobSubmission:
|
|
262
|
+
"""Enqueue one bank slip; successful verification spends quota."""
|
|
263
|
+
body = encode(options) if options else {}
|
|
264
|
+
body["payload"] = _text(payload, 128)
|
|
265
|
+
return self._call(m.JobSubmission, "POST", "/verify/bank/async", json=body)
|
|
266
|
+
|
|
267
|
+
def submit_bank_batch(
|
|
268
|
+
self, slips: list[m.BatchSlip], options: m.BatchOptions | None = None
|
|
269
|
+
) -> m.BatchSubmission:
|
|
270
|
+
"""Enqueue 1-100 slips; each successful slip spends quota separately."""
|
|
271
|
+
if not slips:
|
|
272
|
+
raise InputError("EMPTY_BATCH", "At least one slip is required")
|
|
273
|
+
if len(slips) > 100:
|
|
274
|
+
raise InputError("BATCH_TOO_LARGE", "At most 100 slips per batch")
|
|
275
|
+
body = encode(options) if options else {}
|
|
276
|
+
body["slips"] = encode(slips)
|
|
277
|
+
return self._call(m.BatchSubmission, "POST", "/verify/bank/batch", json=body)
|
|
278
|
+
|
|
279
|
+
def submit_bank_many(
|
|
280
|
+
self, slips: list[m.BatchSlip], options: m.BatchOptions | None = None
|
|
281
|
+
) -> list[m.BatchSubmission]:
|
|
282
|
+
"""Submit chunks of 100 in order, retaining receipts on partial failure."""
|
|
283
|
+
if not slips:
|
|
284
|
+
raise InputError("EMPTY_BATCH", "At least one slip is required")
|
|
285
|
+
submitted: list[m.BatchSubmission] = []
|
|
286
|
+
for index in range(0, len(slips), 100):
|
|
287
|
+
try:
|
|
288
|
+
submitted.append(self.submit_bank_batch(slips[index : index + 100], options))
|
|
289
|
+
except Exception as cause:
|
|
290
|
+
raise BatchSubmitError(submitted, index // 100, cause) from None
|
|
291
|
+
return submitted
|
|
292
|
+
|
|
293
|
+
def get(self, job_id: str) -> m.Job:
|
|
294
|
+
"""Read one job, including failed jobs without raising an API error."""
|
|
295
|
+
from urllib.parse import quote
|
|
296
|
+
|
|
297
|
+
return self._call(
|
|
298
|
+
m.Job, "GET", "/verify/bank/jobs/" + quote(_text(job_id, 255), safe=""), safe=True
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
def wait_for_job(self, job_id: str, *, interval: float = 2, timeout: float = 600) -> m.Job:
|
|
302
|
+
"""Poll until done or failed, bounded by an overall timeout."""
|
|
303
|
+
import math
|
|
304
|
+
|
|
305
|
+
if (
|
|
306
|
+
not math.isfinite(interval)
|
|
307
|
+
or interval < 0.5
|
|
308
|
+
or not math.isfinite(timeout)
|
|
309
|
+
or timeout <= 0
|
|
310
|
+
):
|
|
311
|
+
raise InputError("INVALID_ARGUMENT", "Invalid polling interval or timeout")
|
|
312
|
+
deadline = time.monotonic() + timeout
|
|
313
|
+
last: m.Job | None = None
|
|
314
|
+
while time.monotonic() < deadline:
|
|
315
|
+
# HTTPX phase timeouts alone do not bound a slow trickle or a custom
|
|
316
|
+
# transport. One worker bounds the caller's total wall-clock budget.
|
|
317
|
+
# No more polling/retries are started after the deadline.
|
|
318
|
+
replies: queue.Queue[httpx.Response | Exception] = queue.Queue(maxsize=1)
|
|
319
|
+
|
|
320
|
+
def fetch(replies: queue.Queue[httpx.Response | Exception] = replies) -> None:
|
|
321
|
+
try:
|
|
322
|
+
replies.put(
|
|
323
|
+
self._transport.request(
|
|
324
|
+
"GET",
|
|
325
|
+
"/verify/bank/jobs/"
|
|
326
|
+
+ __import__("urllib.parse", fromlist=["quote"]).quote(
|
|
327
|
+
_text(job_id, 255), safe=""
|
|
328
|
+
),
|
|
329
|
+
safe=True,
|
|
330
|
+
_deadline=deadline,
|
|
331
|
+
)
|
|
332
|
+
)
|
|
333
|
+
except Exception as error:
|
|
334
|
+
replies.put(error)
|
|
335
|
+
|
|
336
|
+
worker = threading.Thread(target=fetch, name="sdk-job-poll", daemon=True)
|
|
337
|
+
worker.start()
|
|
338
|
+
try:
|
|
339
|
+
response = replies.get(timeout=max(0, deadline - time.monotonic()))
|
|
340
|
+
except queue.Empty:
|
|
341
|
+
raise JobWaitTimeoutError(last) from None
|
|
342
|
+
if time.monotonic() >= deadline or isinstance(response, RequestTimeoutError):
|
|
343
|
+
raise JobWaitTimeoutError(last) from None
|
|
344
|
+
if isinstance(response, Exception):
|
|
345
|
+
raise response
|
|
346
|
+
job = _decode_response(m.Job, response, self._transport._key)
|
|
347
|
+
if time.monotonic() >= deadline:
|
|
348
|
+
raise JobWaitTimeoutError(last)
|
|
349
|
+
last = job
|
|
350
|
+
if last.status in ("done", "failed"):
|
|
351
|
+
return last
|
|
352
|
+
time.sleep(min(interval, max(0, deadline - time.monotonic())))
|
|
353
|
+
raise JobWaitTimeoutError(last)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
class V1(Resource):
|
|
357
|
+
"""Legacy API operations using the independent v1 base URL."""
|
|
358
|
+
|
|
359
|
+
def get_me(self) -> m.V1Me:
|
|
360
|
+
"""Read application quota and credit."""
|
|
361
|
+
return self._call(m.V1Me, "GET", "/me", safe=True)
|
|
362
|
+
|
|
363
|
+
def verify_bank_by_payload(
|
|
364
|
+
self, payload: str, options: m.V1VerifyOptions | None = None
|
|
365
|
+
) -> m.BankSlip:
|
|
366
|
+
"""Verify a payload through the billed legacy GET endpoint."""
|
|
367
|
+
query = encode(options) if options else {}
|
|
368
|
+
query["payload"] = _text(payload, 255)
|
|
369
|
+
return self._call(
|
|
370
|
+
m.BankSlip, "GET", "/verify", params={k: _form(v) for k, v in query.items()}
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
def _verify(
|
|
374
|
+
self,
|
|
375
|
+
field: str,
|
|
376
|
+
value: str | FileInput,
|
|
377
|
+
options: m.V1VerifyOptions | None,
|
|
378
|
+
wallet: bool = False,
|
|
379
|
+
) -> m.BankSlip | m.TrueWalletSlip:
|
|
380
|
+
body = encode(options) if options else {}
|
|
381
|
+
model: type[m.TrueWalletSlip] | type[m.BankSlip] = (
|
|
382
|
+
m.TrueWalletSlip if wallet else m.BankSlip
|
|
383
|
+
)
|
|
384
|
+
path = "/verify/truewallet" if wallet else "/verify"
|
|
385
|
+
if field == "file":
|
|
386
|
+
image = image_input(value, max_bytes=4194304)
|
|
387
|
+
return self._call(
|
|
388
|
+
model,
|
|
389
|
+
"POST",
|
|
390
|
+
path,
|
|
391
|
+
data={k: _form(v) for k, v in body.items()},
|
|
392
|
+
files={"file": (image.filename, image.data, image.content_type)},
|
|
393
|
+
)
|
|
394
|
+
body[field] = value
|
|
395
|
+
return self._call(model, "POST", path, json=body)
|
|
396
|
+
|
|
397
|
+
def verify_bank_by_image(
|
|
398
|
+
self, file: FileInput, options: m.V1VerifyOptions | None = None
|
|
399
|
+
) -> m.BankSlip:
|
|
400
|
+
"""Upload an image to v1 using multipart field file."""
|
|
401
|
+
return cast(m.BankSlip, self._verify("file", file, options))
|
|
402
|
+
|
|
403
|
+
def verify_bank_by_base64(
|
|
404
|
+
self, base64: str, options: m.V1VerifyOptions | None = None
|
|
405
|
+
) -> m.BankSlip:
|
|
406
|
+
"""Send base64 using the legacy JSON field image."""
|
|
407
|
+
return cast(m.BankSlip, self._verify("image", base64, options))
|
|
408
|
+
|
|
409
|
+
def verify_bank_by_url(self, url: str, options: m.V1VerifyOptions | None = None) -> m.BankSlip:
|
|
410
|
+
"""Verify a remote image through v1."""
|
|
411
|
+
return cast(m.BankSlip, self._verify("url", _text(url, 2048), options))
|
|
412
|
+
|
|
413
|
+
def verify_truemoney_by_image(
|
|
414
|
+
self, file: FileInput, options: m.V1VerifyOptions | None = None
|
|
415
|
+
) -> m.TrueWalletSlip:
|
|
416
|
+
"""Upload a wallet image to the legacy endpoint."""
|
|
417
|
+
return cast(m.TrueWalletSlip, self._verify("file", file, options, True))
|
|
418
|
+
|
|
419
|
+
def generate_qr(self, request: m.QrRequest) -> m.QrCode:
|
|
420
|
+
"""Generate a QR code without verification quota consumption."""
|
|
421
|
+
return self._call(m.QrCode, "POST", "/qr/generate", safe=True, json=encode(request))
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
class Client:
|
|
425
|
+
"""Credential-safe API client; use a context manager to close owned connections."""
|
|
426
|
+
|
|
427
|
+
def __init__(
|
|
428
|
+
self,
|
|
429
|
+
api_key: str | None = None,
|
|
430
|
+
*,
|
|
431
|
+
v2_base_url: str | None = None,
|
|
432
|
+
v1_base_url: str | None = None,
|
|
433
|
+
timeout: float = 30,
|
|
434
|
+
max_retries: int = 2,
|
|
435
|
+
http: httpx.Client | None = None,
|
|
436
|
+
) -> None:
|
|
437
|
+
key, v2, v1, agent = _settings(api_key, v2_base_url, v1_base_url)
|
|
438
|
+
try:
|
|
439
|
+
self._v2 = Transport(
|
|
440
|
+
key, v2, http=http, timeout=timeout, max_retries=max_retries, user_agent=agent
|
|
441
|
+
)
|
|
442
|
+
self._v1 = Transport(
|
|
443
|
+
key, v1, http=http, timeout=timeout, max_retries=max_retries, user_agent=agent
|
|
444
|
+
)
|
|
445
|
+
except ValueError:
|
|
446
|
+
raise InputError("INVALID_ARGUMENT", "Invalid client configuration") from None
|
|
447
|
+
self.verify = Verify(self._v2)
|
|
448
|
+
self.banks = Banks(self._v2)
|
|
449
|
+
self.bank_accounts = BankAccounts(self._v2)
|
|
450
|
+
self.jobs = Jobs(self._v2)
|
|
451
|
+
self.v1 = V1(self._v1)
|
|
452
|
+
|
|
453
|
+
def get_info(self) -> m.Info:
|
|
454
|
+
"""Read application, branch, quota and credit information."""
|
|
455
|
+
response = self._v2.request("GET", "/info", safe=True)
|
|
456
|
+
return _decode_response(m.Info, response, self._v2._key)
|
|
457
|
+
|
|
458
|
+
def close(self) -> None:
|
|
459
|
+
"""Close connections created by this client; injected HTTP clients remain open."""
|
|
460
|
+
self._v2.close()
|
|
461
|
+
self._v1.close()
|
|
462
|
+
|
|
463
|
+
def __enter__(self) -> Client:
|
|
464
|
+
return self
|
|
465
|
+
|
|
466
|
+
def __exit__(self, *_args: object) -> None:
|
|
467
|
+
self.close()
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
class AsyncResource:
|
|
471
|
+
"""Internal resource base; transports remain owned by the parent client."""
|
|
472
|
+
|
|
473
|
+
def __init__(self, transport: AsyncTransport) -> None:
|
|
474
|
+
self._transport = transport
|
|
475
|
+
|
|
476
|
+
async def _call(
|
|
477
|
+
self, model: type[T], method: str, path: str, *, safe: bool = False, **kwargs: Any
|
|
478
|
+
) -> T:
|
|
479
|
+
response = await self._transport.request(method, path, safe=safe, **kwargs)
|
|
480
|
+
return _decode_response(model, response, self._transport._key)
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
class AsyncVerify(AsyncResource):
|
|
484
|
+
"""Billed bank and wallet verification operations."""
|
|
485
|
+
|
|
486
|
+
async def _verify(
|
|
487
|
+
self,
|
|
488
|
+
field: str,
|
|
489
|
+
value: str | FileInput,
|
|
490
|
+
options: m.VerifyOptions | None,
|
|
491
|
+
wallet: bool = False,
|
|
492
|
+
) -> m.BankSlipResult | m.TrueWalletResult:
|
|
493
|
+
body = encode(options) if options else {}
|
|
494
|
+
path = "/verify/truewallet" if wallet else "/verify/bank"
|
|
495
|
+
model: type[m.TrueWalletResult] | type[m.BankSlipResult] = (
|
|
496
|
+
m.TrueWalletResult if wallet else m.BankSlipResult
|
|
497
|
+
)
|
|
498
|
+
if field == "image":
|
|
499
|
+
image = image_input(value, max_bytes=4194304)
|
|
500
|
+
return await self._call(
|
|
501
|
+
model,
|
|
502
|
+
"POST",
|
|
503
|
+
path,
|
|
504
|
+
data={k: _form(v) for k, v in body.items()},
|
|
505
|
+
files={"image": (image.filename, image.data, image.content_type)},
|
|
506
|
+
)
|
|
507
|
+
body[field] = value
|
|
508
|
+
return await self._call(model, "POST", path, json=body)
|
|
509
|
+
|
|
510
|
+
async def bank_by_payload(
|
|
511
|
+
self, payload: str, options: m.VerifyOptions | None = None
|
|
512
|
+
) -> m.BankSlipResult:
|
|
513
|
+
"""Verify one bank slip using payload; may spend quota."""
|
|
514
|
+
_text(payload, 128)
|
|
515
|
+
return cast(m.BankSlipResult, await self._verify("payload", payload, options, False))
|
|
516
|
+
|
|
517
|
+
async def bank_by_image(
|
|
518
|
+
self, file: FileInput, options: m.VerifyOptions | None = None
|
|
519
|
+
) -> m.BankSlipResult:
|
|
520
|
+
"""Verify one bank slip using image; may spend quota."""
|
|
521
|
+
return cast(m.BankSlipResult, await self._verify("image", file, options, False))
|
|
522
|
+
|
|
523
|
+
async def bank_by_base64(
|
|
524
|
+
self, base64: str, options: m.VerifyOptions | None = None
|
|
525
|
+
) -> m.BankSlipResult:
|
|
526
|
+
"""Verify one bank slip using base64; may spend quota."""
|
|
527
|
+
return cast(m.BankSlipResult, await self._verify("base64", base64, options, False))
|
|
528
|
+
|
|
529
|
+
async def bank_by_url(
|
|
530
|
+
self, url: str, options: m.VerifyOptions | None = None
|
|
531
|
+
) -> m.BankSlipResult:
|
|
532
|
+
"""Verify one bank slip using url; may spend quota."""
|
|
533
|
+
_text(url, 2048)
|
|
534
|
+
return cast(m.BankSlipResult, await self._verify("url", url, options, False))
|
|
535
|
+
|
|
536
|
+
async def truewallet_by_image(
|
|
537
|
+
self, file: FileInput, options: m.VerifyOptions | None = None
|
|
538
|
+
) -> m.TrueWalletResult:
|
|
539
|
+
"""Verify one wallet slip using image; may spend quota."""
|
|
540
|
+
return cast(m.TrueWalletResult, await self._verify("image", file, options, True))
|
|
541
|
+
|
|
542
|
+
async def truewallet_by_base64(
|
|
543
|
+
self, base64: str, options: m.VerifyOptions | None = None
|
|
544
|
+
) -> m.TrueWalletResult:
|
|
545
|
+
"""Verify one wallet slip using base64; may spend quota."""
|
|
546
|
+
return cast(m.TrueWalletResult, await self._verify("base64", base64, options, True))
|
|
547
|
+
|
|
548
|
+
async def truewallet_by_url(
|
|
549
|
+
self, url: str, options: m.VerifyOptions | None = None
|
|
550
|
+
) -> m.TrueWalletResult:
|
|
551
|
+
"""Verify one wallet slip using url; may spend quota."""
|
|
552
|
+
_text(url, 255)
|
|
553
|
+
return cast(m.TrueWalletResult, await self._verify("url", url, options, True))
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
class AsyncBanks(AsyncResource):
|
|
557
|
+
"""Bank catalog."""
|
|
558
|
+
|
|
559
|
+
async def list(self) -> m.BankList:
|
|
560
|
+
"""Return supported banks and their verification choices."""
|
|
561
|
+
return await self._call(m.BankList, "GET", "/banks", safe=True)
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
class AsyncBankAccounts(AsyncResource):
|
|
565
|
+
"""Receiving account management and lazy pagination."""
|
|
566
|
+
|
|
567
|
+
async def _list(
|
|
568
|
+
self, params: m.ListBankAccountsParams | None, all_accounts: bool
|
|
569
|
+
) -> m.BankAccountPage:
|
|
570
|
+
query = encode(params) if params else {}
|
|
571
|
+
if "page" in query and (type(query["page"]) is not int or query["page"] < 1):
|
|
572
|
+
raise InputError("INVALID_ARGUMENT", "page must be positive")
|
|
573
|
+
if "limit" in query and (type(query["limit"]) is not int or not 1 <= query["limit"] <= 100):
|
|
574
|
+
raise InputError("INVALID_ARGUMENT", "limit must be between 1 and 100")
|
|
575
|
+
return await self._call(
|
|
576
|
+
m.BankAccountPage,
|
|
577
|
+
"GET",
|
|
578
|
+
"/bank-accounts" + ("/all" if all_accounts else ""),
|
|
579
|
+
safe=True,
|
|
580
|
+
params={k: _form(v) for k, v in query.items()},
|
|
581
|
+
)
|
|
582
|
+
|
|
583
|
+
async def list(self, params: m.ListBankAccountsParams | None = None) -> m.BankAccountPage:
|
|
584
|
+
"""Return one page linked to this branch."""
|
|
585
|
+
return await self._list(params, False)
|
|
586
|
+
|
|
587
|
+
async def list_all(self, params: m.ListBankAccountsParams | None = None) -> m.BankAccountPage:
|
|
588
|
+
"""Return one page across the service."""
|
|
589
|
+
return await self._list(params, True)
|
|
590
|
+
|
|
591
|
+
async def get(self, id: int) -> m.BankAccount:
|
|
592
|
+
"""Read one receiving account."""
|
|
593
|
+
return await self._call(m.BankAccount, "GET", "/bank-accounts/" + _id(id), safe=True)
|
|
594
|
+
|
|
595
|
+
async def create(self, params: m.CreateBankAccountParams) -> m.BankAccount:
|
|
596
|
+
"""Create an account; ambiguous failures are never retried."""
|
|
597
|
+
return await self._call(m.BankAccount, "POST", "/bank-accounts", json=encode(params))
|
|
598
|
+
|
|
599
|
+
async def update(self, id: int, params: m.UpdateBankAccountParams) -> m.BankAccount:
|
|
600
|
+
"""Update selected fields; explicit extra_verify=None clears that field."""
|
|
601
|
+
return await self._call(
|
|
602
|
+
m.BankAccount, "PATCH", "/bank-accounts/" + _id(id), json=encode(params)
|
|
603
|
+
)
|
|
604
|
+
|
|
605
|
+
async def link(self, id: int) -> m.LinkResult:
|
|
606
|
+
"""Idempotently link an account to this branch."""
|
|
607
|
+
return await self._call(
|
|
608
|
+
m.LinkResult, "POST", "/bank-accounts/" + _id(id) + "/link", safe=True
|
|
609
|
+
)
|
|
610
|
+
|
|
611
|
+
async def unlink(self, id: int) -> m.UnlinkResult:
|
|
612
|
+
"""Unlink an account from this branch."""
|
|
613
|
+
return await self._call(m.UnlinkResult, "DELETE", "/bank-accounts/" + _id(id) + "/link")
|
|
614
|
+
|
|
615
|
+
async def _iterate(
|
|
616
|
+
self, params: m.ListBankAccountsParams | None, all_accounts: bool
|
|
617
|
+
) -> AsyncIterator[m.BankAccount]:
|
|
618
|
+
params = params or m.ListBankAccountsParams()
|
|
619
|
+
page = params.page if params.page is not UNSET else 1
|
|
620
|
+
limit = params.limit if params.limit is not UNSET else 100
|
|
621
|
+
while True:
|
|
622
|
+
response = await self._list(replace(params, page=page, limit=limit), all_accounts)
|
|
623
|
+
for item in response.items:
|
|
624
|
+
yield item
|
|
625
|
+
if not response.items or page * limit >= response.total:
|
|
626
|
+
break
|
|
627
|
+
page += 1
|
|
628
|
+
|
|
629
|
+
async def iterate(
|
|
630
|
+
self, params: m.ListBankAccountsParams | None = None
|
|
631
|
+
) -> AsyncIterator[m.BankAccount]:
|
|
632
|
+
"""Lazily walk every linked account, starting at params.page."""
|
|
633
|
+
async for item in self._iterate(params, False):
|
|
634
|
+
yield item
|
|
635
|
+
|
|
636
|
+
async def iterate_all(
|
|
637
|
+
self, params: m.ListBankAccountsParams | None = None
|
|
638
|
+
) -> AsyncIterator[m.BankAccount]:
|
|
639
|
+
"""Lazily walk service accounts, requesting at most one page at a time."""
|
|
640
|
+
async for item in self._iterate(params, True):
|
|
641
|
+
yield item
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
class AsyncJobs(AsyncResource):
|
|
645
|
+
"""Async verification, polling and independent batch submission."""
|
|
646
|
+
|
|
647
|
+
async def submit_bank(
|
|
648
|
+
self, payload: str, options: m.AsyncVerifyOptions | None = None
|
|
649
|
+
) -> m.JobSubmission:
|
|
650
|
+
"""Enqueue one bank slip; successful verification spends quota."""
|
|
651
|
+
body = encode(options) if options else {}
|
|
652
|
+
body["payload"] = _text(payload, 128)
|
|
653
|
+
return await self._call(m.JobSubmission, "POST", "/verify/bank/async", json=body)
|
|
654
|
+
|
|
655
|
+
async def submit_bank_batch(
|
|
656
|
+
self, slips: list[m.BatchSlip], options: m.BatchOptions | None = None
|
|
657
|
+
) -> m.BatchSubmission:
|
|
658
|
+
"""Enqueue 1-100 slips; each successful slip spends quota separately."""
|
|
659
|
+
if not slips:
|
|
660
|
+
raise InputError("EMPTY_BATCH", "At least one slip is required")
|
|
661
|
+
if len(slips) > 100:
|
|
662
|
+
raise InputError("BATCH_TOO_LARGE", "At most 100 slips per batch")
|
|
663
|
+
body = encode(options) if options else {}
|
|
664
|
+
body["slips"] = encode(slips)
|
|
665
|
+
return await self._call(m.BatchSubmission, "POST", "/verify/bank/batch", json=body)
|
|
666
|
+
|
|
667
|
+
async def submit_bank_many(
|
|
668
|
+
self, slips: list[m.BatchSlip], options: m.BatchOptions | None = None
|
|
669
|
+
) -> list[m.BatchSubmission]:
|
|
670
|
+
"""Submit chunks of 100 in order, retaining receipts on partial failure."""
|
|
671
|
+
if not slips:
|
|
672
|
+
raise InputError("EMPTY_BATCH", "At least one slip is required")
|
|
673
|
+
submitted: list[m.BatchSubmission] = []
|
|
674
|
+
for index in range(0, len(slips), 100):
|
|
675
|
+
try:
|
|
676
|
+
submitted.append(await self.submit_bank_batch(slips[index : index + 100], options))
|
|
677
|
+
except Exception as cause:
|
|
678
|
+
raise BatchSubmitError(submitted, index // 100, cause) from None
|
|
679
|
+
return submitted
|
|
680
|
+
|
|
681
|
+
async def get(self, job_id: str) -> m.Job:
|
|
682
|
+
"""Read one job, including failed jobs without raising an API error."""
|
|
683
|
+
from urllib.parse import quote
|
|
684
|
+
|
|
685
|
+
return await self._call(
|
|
686
|
+
m.Job, "GET", "/verify/bank/jobs/" + quote(_text(job_id, 255), safe=""), safe=True
|
|
687
|
+
)
|
|
688
|
+
|
|
689
|
+
async def wait_for_job(
|
|
690
|
+
self, job_id: str, *, interval: float = 2, timeout: float = 600
|
|
691
|
+
) -> m.Job:
|
|
692
|
+
"""Poll until done or failed, bounded by an overall timeout."""
|
|
693
|
+
import math
|
|
694
|
+
|
|
695
|
+
if (
|
|
696
|
+
not math.isfinite(interval)
|
|
697
|
+
or interval < 0.5
|
|
698
|
+
or not math.isfinite(timeout)
|
|
699
|
+
or timeout <= 0
|
|
700
|
+
):
|
|
701
|
+
raise InputError("INVALID_ARGUMENT", "Invalid polling interval or timeout")
|
|
702
|
+
deadline = time.monotonic() + timeout
|
|
703
|
+
last: m.Job | None = None
|
|
704
|
+
while time.monotonic() < deadline:
|
|
705
|
+
|
|
706
|
+
async def fetch() -> m.Job:
|
|
707
|
+
response = await self._transport.request(
|
|
708
|
+
"GET",
|
|
709
|
+
"/verify/bank/jobs/"
|
|
710
|
+
+ __import__("urllib.parse", fromlist=["quote"]).quote(
|
|
711
|
+
_text(job_id, 255), safe=""
|
|
712
|
+
),
|
|
713
|
+
safe=True,
|
|
714
|
+
_deadline=deadline,
|
|
715
|
+
)
|
|
716
|
+
return _decode_response(m.Job, response, self._transport._key)
|
|
717
|
+
|
|
718
|
+
task = asyncio.create_task(fetch())
|
|
719
|
+
# Retrieve an eventual failure even if an injected transport suppresses
|
|
720
|
+
# cancellation. Such a late result never becomes last_job.
|
|
721
|
+
task.add_done_callback(
|
|
722
|
+
lambda finished: None if finished.cancelled() else finished.exception()
|
|
723
|
+
)
|
|
724
|
+
try:
|
|
725
|
+
done, _ = await asyncio.wait({task}, timeout=max(0, deadline - time.monotonic()))
|
|
726
|
+
except asyncio.CancelledError:
|
|
727
|
+
task.cancel()
|
|
728
|
+
raise
|
|
729
|
+
if not done:
|
|
730
|
+
task.cancel()
|
|
731
|
+
raise JobWaitTimeoutError(last)
|
|
732
|
+
if time.monotonic() >= deadline:
|
|
733
|
+
raise JobWaitTimeoutError(last)
|
|
734
|
+
try:
|
|
735
|
+
job = task.result()
|
|
736
|
+
except RequestTimeoutError:
|
|
737
|
+
raise JobWaitTimeoutError(last) from None
|
|
738
|
+
if time.monotonic() >= deadline:
|
|
739
|
+
raise JobWaitTimeoutError(last)
|
|
740
|
+
last = job
|
|
741
|
+
if last.status in ("done", "failed"):
|
|
742
|
+
return last
|
|
743
|
+
await asyncio.sleep(min(interval, max(0, deadline - time.monotonic())))
|
|
744
|
+
raise JobWaitTimeoutError(last)
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
class AsyncV1(AsyncResource):
|
|
748
|
+
"""Legacy API operations using the independent v1 base URL."""
|
|
749
|
+
|
|
750
|
+
async def get_me(self) -> m.V1Me:
|
|
751
|
+
"""Read application quota and credit."""
|
|
752
|
+
return await self._call(m.V1Me, "GET", "/me", safe=True)
|
|
753
|
+
|
|
754
|
+
async def verify_bank_by_payload(
|
|
755
|
+
self, payload: str, options: m.V1VerifyOptions | None = None
|
|
756
|
+
) -> m.BankSlip:
|
|
757
|
+
"""Verify a payload through the billed legacy GET endpoint."""
|
|
758
|
+
query = encode(options) if options else {}
|
|
759
|
+
query["payload"] = _text(payload, 255)
|
|
760
|
+
return await self._call(
|
|
761
|
+
m.BankSlip, "GET", "/verify", params={k: _form(v) for k, v in query.items()}
|
|
762
|
+
)
|
|
763
|
+
|
|
764
|
+
async def _verify(
|
|
765
|
+
self,
|
|
766
|
+
field: str,
|
|
767
|
+
value: str | FileInput,
|
|
768
|
+
options: m.V1VerifyOptions | None,
|
|
769
|
+
wallet: bool = False,
|
|
770
|
+
) -> m.BankSlip | m.TrueWalletSlip:
|
|
771
|
+
body = encode(options) if options else {}
|
|
772
|
+
model: type[m.TrueWalletSlip] | type[m.BankSlip] = (
|
|
773
|
+
m.TrueWalletSlip if wallet else m.BankSlip
|
|
774
|
+
)
|
|
775
|
+
path = "/verify/truewallet" if wallet else "/verify"
|
|
776
|
+
if field == "file":
|
|
777
|
+
image = image_input(value, max_bytes=4194304)
|
|
778
|
+
return await self._call(
|
|
779
|
+
model,
|
|
780
|
+
"POST",
|
|
781
|
+
path,
|
|
782
|
+
data={k: _form(v) for k, v in body.items()},
|
|
783
|
+
files={"file": (image.filename, image.data, image.content_type)},
|
|
784
|
+
)
|
|
785
|
+
body[field] = value
|
|
786
|
+
return await self._call(model, "POST", path, json=body)
|
|
787
|
+
|
|
788
|
+
async def verify_bank_by_image(
|
|
789
|
+
self, file: FileInput, options: m.V1VerifyOptions | None = None
|
|
790
|
+
) -> m.BankSlip:
|
|
791
|
+
"""Upload an image to v1 using multipart field file."""
|
|
792
|
+
return cast(m.BankSlip, await self._verify("file", file, options))
|
|
793
|
+
|
|
794
|
+
async def verify_bank_by_base64(
|
|
795
|
+
self, base64: str, options: m.V1VerifyOptions | None = None
|
|
796
|
+
) -> m.BankSlip:
|
|
797
|
+
"""Send base64 using the legacy JSON field image."""
|
|
798
|
+
return cast(m.BankSlip, await self._verify("image", base64, options))
|
|
799
|
+
|
|
800
|
+
async def verify_bank_by_url(
|
|
801
|
+
self, url: str, options: m.V1VerifyOptions | None = None
|
|
802
|
+
) -> m.BankSlip:
|
|
803
|
+
"""Verify a remote image through v1."""
|
|
804
|
+
return cast(m.BankSlip, await self._verify("url", _text(url, 2048), options))
|
|
805
|
+
|
|
806
|
+
async def verify_truemoney_by_image(
|
|
807
|
+
self, file: FileInput, options: m.V1VerifyOptions | None = None
|
|
808
|
+
) -> m.TrueWalletSlip:
|
|
809
|
+
"""Upload a wallet image to the legacy endpoint."""
|
|
810
|
+
return cast(m.TrueWalletSlip, await self._verify("file", file, options, True))
|
|
811
|
+
|
|
812
|
+
async def generate_qr(self, request: m.QrRequest) -> m.QrCode:
|
|
813
|
+
"""Generate a QR code without verification quota consumption."""
|
|
814
|
+
return await self._call(m.QrCode, "POST", "/qr/generate", safe=True, json=encode(request))
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
class AsyncClient:
|
|
818
|
+
"""Credential-safe API client; use a context manager to close owned connections."""
|
|
819
|
+
|
|
820
|
+
def __init__(
|
|
821
|
+
self,
|
|
822
|
+
api_key: str | None = None,
|
|
823
|
+
*,
|
|
824
|
+
v2_base_url: str | None = None,
|
|
825
|
+
v1_base_url: str | None = None,
|
|
826
|
+
timeout: float = 30,
|
|
827
|
+
max_retries: int = 2,
|
|
828
|
+
http: httpx.AsyncClient | None = None,
|
|
829
|
+
) -> None:
|
|
830
|
+
key, v2, v1, agent = _settings(api_key, v2_base_url, v1_base_url)
|
|
831
|
+
try:
|
|
832
|
+
self._v2 = AsyncTransport(
|
|
833
|
+
key, v2, http=http, timeout=timeout, max_retries=max_retries, user_agent=agent
|
|
834
|
+
)
|
|
835
|
+
self._v1 = AsyncTransport(
|
|
836
|
+
key, v1, http=http, timeout=timeout, max_retries=max_retries, user_agent=agent
|
|
837
|
+
)
|
|
838
|
+
except ValueError:
|
|
839
|
+
raise InputError("INVALID_ARGUMENT", "Invalid client configuration") from None
|
|
840
|
+
self.verify = AsyncVerify(self._v2)
|
|
841
|
+
self.banks = AsyncBanks(self._v2)
|
|
842
|
+
self.bank_accounts = AsyncBankAccounts(self._v2)
|
|
843
|
+
self.jobs = AsyncJobs(self._v2)
|
|
844
|
+
self.v1 = AsyncV1(self._v1)
|
|
845
|
+
|
|
846
|
+
async def get_info(self) -> m.Info:
|
|
847
|
+
"""Read application, branch, quota and credit information."""
|
|
848
|
+
response = await self._v2.request("GET", "/info", safe=True)
|
|
849
|
+
return _decode_response(m.Info, response, self._v2._key)
|
|
850
|
+
|
|
851
|
+
async def close(self) -> None:
|
|
852
|
+
"""Close connections created by this client; injected HTTP clients remain open."""
|
|
853
|
+
await self._v2.close()
|
|
854
|
+
await self._v1.close()
|
|
855
|
+
|
|
856
|
+
async def __aenter__(self) -> AsyncClient:
|
|
857
|
+
return self
|
|
858
|
+
|
|
859
|
+
async def __aexit__(self, *_args: object) -> None:
|
|
860
|
+
await self.close()
|