saman-api 0.1.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.
- saman_api-0.1.0.dist-info/METADATA +242 -0
- saman_api-0.1.0.dist-info/RECORD +14 -0
- saman_api-0.1.0.dist-info/WHEEL +4 -0
- saman_api-0.1.0.dist-info/entry_points.txt +2 -0
- saman_api-0.1.0.dist-info/licenses/LICENSE +21 -0
- samanapi/__init__.py +36 -0
- samanapi/__main__.py +3 -0
- samanapi/_transport.py +188 -0
- samanapi/async_client.py +433 -0
- samanapi/cli.py +222 -0
- samanapi/client.py +857 -0
- samanapi/errors.py +94 -0
- samanapi/py.typed +0 -0
- samanapi/types.py +52 -0
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: saman-api
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Typed sync and async Python clients for the Saman Mobillet HTTP API
|
|
5
|
+
Project-URL: Documentation, https://apibank.heydari.workers.dev/saman/
|
|
6
|
+
Project-URL: Repository, https://github.com/DevURANIUM/saman-api
|
|
7
|
+
Project-URL: Issues, https://github.com/DevURANIUM/saman-api/issues
|
|
8
|
+
Author: APIBANK contributors
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: api,banking,client,iran,mobillet,saman
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Requires-Dist: httpx<1,>=0.28.1
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# saman-api
|
|
27
|
+
|
|
28
|
+
A typed, synchronous and asynchronous Python client for the **Saman Mobillet HTTP API**.
|
|
29
|
+
|
|
30
|
+
This package talks to your running Saman API service. It **does not connect directly
|
|
31
|
+
to the bank**, replace the Node.js server, or execute requests through the documentation
|
|
32
|
+
website. The service manages bank encryption, cookies and the bank session.
|
|
33
|
+
|
|
34
|
+
Requires Python 3.10+ and API service 0.3+. All SDK prompts and messages are English.
|
|
35
|
+
|
|
36
|
+
## Installation
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
pip install saman-api
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Until the first PyPI release, install from the source package directory:
|
|
43
|
+
|
|
44
|
+
```sh
|
|
45
|
+
pip install .
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Start the accompanying Node.js API server with `npm install` and `npm start`.
|
|
49
|
+
Configure `SAMAN_API_KEY` on the server and provide the same key to this client.
|
|
50
|
+
The default API URL is `http://127.0.0.1:8089/v1`; a supplied URL without `/v1`
|
|
51
|
+
has that suffix appended. Production connections should use HTTPS.
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from samanapi import Client
|
|
55
|
+
|
|
56
|
+
with Client(base_url="http://127.0.0.1:8089/v1", api_key="YOUR_SERVICE_API_KEY") as client:
|
|
57
|
+
result = client.sign_in() # Username, hidden password, then hidden SMS code if needed
|
|
58
|
+
print(result["bankVerified"])
|
|
59
|
+
print(client.deposits())
|
|
60
|
+
print(client.cards())
|
|
61
|
+
print(client.balance())
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
The constructor also reads `SAMAN_API_URL` and `SAMAN_API_KEY`. Bank credentials
|
|
65
|
+
are not stored by this Python package. One API server owns one bank session:
|
|
66
|
+
creating another Python client does **not** create an independent bank login.
|
|
67
|
+
`close()` closes HTTP connections; `logout()` clears the shared server session.
|
|
68
|
+
|
|
69
|
+
## Login in a web application
|
|
70
|
+
|
|
71
|
+
Use explicit stages rather than an interactive prompt:
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
from samanapi import Client
|
|
75
|
+
|
|
76
|
+
with Client() as client:
|
|
77
|
+
result = client.login("YOUR_USERNAME", "YOUR_PASSWORD")
|
|
78
|
+
if result.get("otpRequired"):
|
|
79
|
+
if result.get("otpSent"):
|
|
80
|
+
# Collect the code in your application's UI, then call:
|
|
81
|
+
result = client.verify_otp("123456") # synthetic example code
|
|
82
|
+
else:
|
|
83
|
+
print(result.get("otpError"))
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
The service requests the device SMS automatically. Do not call `request_otp()`
|
|
87
|
+
after every login: it is an explicit resend. `sign_in()` accepts `otp_callback`
|
|
88
|
+
for integration, and resumes a pending code step when called without credentials.
|
|
89
|
+
Return `resend` from the callback to request another code. Wrong codes leave the
|
|
90
|
+
pending server session available for another attempt. Use `login(..., request_otp=False)`
|
|
91
|
+
only when you deliberately want the service's legacy manual-SMS workflow.
|
|
92
|
+
|
|
93
|
+
`ok=True` can mean that the initial credential step succeeded. A completed login
|
|
94
|
+
has `loggedIn=True` and `bankVerified=True`. Password-change requirements must be
|
|
95
|
+
completed in the bank app. Device-verification, card PIN2 and digital-rial activation
|
|
96
|
+
codes belong to different workflows.
|
|
97
|
+
|
|
98
|
+
## Async client
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
import asyncio
|
|
102
|
+
from samanapi import AsyncClient
|
|
103
|
+
|
|
104
|
+
async def main():
|
|
105
|
+
async with AsyncClient() as client:
|
|
106
|
+
print(await client.check_session())
|
|
107
|
+
print(await client.latest_transactions("123-456-0000001-2", limit=10))
|
|
108
|
+
|
|
109
|
+
asyncio.run(main())
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Every endpoint has the same name and parameters in `Client` and `AsyncClient`.
|
|
113
|
+
`AsyncClient.sign_in` accepts synchronous or asynchronous callbacks. Default
|
|
114
|
+
interactive callbacks run in a worker thread. Transaction iteration uses
|
|
115
|
+
`for row in client.iter_transactions(...)` or `async for` with the async client.
|
|
116
|
+
|
|
117
|
+
## History, details and exact search
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
with Client() as client:
|
|
121
|
+
history = client.transactions(
|
|
122
|
+
"123-456-0000001-2", page=0, size=20,
|
|
123
|
+
details=True, is_withdrawal=False,
|
|
124
|
+
from_date=1700000000, to_date=1700086400,
|
|
125
|
+
)
|
|
126
|
+
result = client.find_transaction(
|
|
127
|
+
"000000000001", "123-456-0000001-2",
|
|
128
|
+
match_by="reference", pages=10, max_transactions=200,
|
|
129
|
+
)
|
|
130
|
+
if not result["found"]:
|
|
131
|
+
print(result["searchComplete"], result["stopReason"])
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Pages start at **0**. Transaction dates are Unix **seconds**; merchant summary
|
|
135
|
+
`from_date` is Unix **milliseconds**. Identifiers, tracking/reference numbers,
|
|
136
|
+
cards, deposit numbers and IBANs stay strings, preserving leading zeros.
|
|
137
|
+
Transaction IDs are encoded as one URL segment, including embedded slashes.
|
|
138
|
+
|
|
139
|
+
`details=True` lets the server include full bank detail/receipt objects in each
|
|
140
|
+
row's `detail`. Unknown fields are preserved. The SDK does not infer a sender's
|
|
141
|
+
name or card number from free text. JSON null balances remain `None`, and absent
|
|
142
|
+
fields remain absent; neither is converted to zero. Card and deposit balances
|
|
143
|
+
are not added together.
|
|
144
|
+
|
|
145
|
+
`iter_transactions(..., size=100, max_pages=10, details=False)` is bounded. It raises
|
|
146
|
+
`PaginationError` when a page repeats or the budget runs out before the end of
|
|
147
|
+
history. It does not silently claim that a bounded search covered the whole account.
|
|
148
|
+
|
|
149
|
+
## Transfers and digital rial
|
|
150
|
+
|
|
151
|
+
```python
|
|
152
|
+
with Client() as client:
|
|
153
|
+
reasons = client.transfer_reasons()
|
|
154
|
+
owner = client.iban_owner("IR000000000000000000000000") # synthetic, replace it
|
|
155
|
+
# Programmatic money movement is explicit; no confirmation dialog is inserted:
|
|
156
|
+
# receipt = client.transfer_iban(source, destination, amount_irr, reason_code)
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Transfer amounts are positive integer **IRR**, capped at JavaScript's safe integer
|
|
160
|
+
range. Card expiry is Jalali `YYYYMM`. `transfer_card` needs `cvv2`, `pin2`, and
|
|
161
|
+
`expire_date`. The server encrypts these fields. PAYA takes a deposit as source
|
|
162
|
+
and an IBAN as destination. Select `reason_code` from `transfer_reasons()`.
|
|
163
|
+
The current service implements CARD, DEPOSIT and PAYA, not SATNA/POL submission.
|
|
164
|
+
|
|
165
|
+
Digital rial has separate eligibility, SMS, activation, balance, transaction,
|
|
166
|
+
charge, discharge, deactivation and contact methods. Wallet history IDs differ
|
|
167
|
+
from deposit-history IDs. Activation currently supports CUSTOMER wallets.
|
|
168
|
+
|
|
169
|
+
The SDK never retries or follows redirects automatically. On a timeout or a
|
|
170
|
+
cancelled payment request, the operation may already have succeeded: inspect
|
|
171
|
+
history before submitting another instruction. API-server renewal behavior is
|
|
172
|
+
separate from the SDK; the companion server never automatically replays transfers.
|
|
173
|
+
|
|
174
|
+
## Errors and response preservation
|
|
175
|
+
|
|
176
|
+
```python
|
|
177
|
+
from samanapi import Client, ApiError, TransportError
|
|
178
|
+
|
|
179
|
+
try:
|
|
180
|
+
with Client() as client:
|
|
181
|
+
print(client.cards())
|
|
182
|
+
except ApiError as exc:
|
|
183
|
+
print(exc.status_code, exc.code, exc.next_action)
|
|
184
|
+
# exc.payload contains the full response; inspect it only where appropriate.
|
|
185
|
+
except TransportError as exc:
|
|
186
|
+
print(exc.request_may_have_succeeded)
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Exception types include `AuthenticationError`, `DeviceVerificationRequired`,
|
|
190
|
+
`PasswordChangeRequired`, `ValidationError`, `BankError`, `SmsDeliveryError`,
|
|
191
|
+
`TransportError`, `ProtocolError` and `PaginationError`. HTTP errors preserve
|
|
192
|
+
`payload`, `bank_status`, `auth`, `next_action`, `method` and `path`. Error string
|
|
193
|
+
representations do not print server payloads or credentials. An HTTP 200 body
|
|
194
|
+
containing an application `error` is not silently accepted.
|
|
195
|
+
|
|
196
|
+
The default response limit is 20 MiB; larger responses raise `ProtocolError`
|
|
197
|
+
rather than being truncated. Configure `max_response_bytes` explicitly if needed.
|
|
198
|
+
`timeout` defaults to 120 seconds. Longer bounded searches may need a larger value.
|
|
199
|
+
Custom HTTPX transports are supported for tests and advanced use; their own retry
|
|
200
|
+
or security behavior is the caller's responsibility. `trust_env` defaults to false.
|
|
201
|
+
|
|
202
|
+
## CLI
|
|
203
|
+
|
|
204
|
+
```sh
|
|
205
|
+
saman-api login
|
|
206
|
+
saman-api status
|
|
207
|
+
saman-api deposits
|
|
208
|
+
saman-api balance
|
|
209
|
+
saman-api transactions --deposit-number 123-456-0000001-2 --page 0 --size 20 --details false
|
|
210
|
+
saman-api find-transaction 000000000001 --deposit-number 123-456-0000001-2 --match-by reference
|
|
211
|
+
saman-api --help
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
The API key is read from `SAMAN_API_KEY`. `SAMAN_USER` and `SAMAN_PASS` may supply
|
|
215
|
+
credentials to CLI login. All endpoint methods have hyphenated CLI commands;
|
|
216
|
+
`--help` lists their arguments. Boolean arguments take `true` or `false`.
|
|
217
|
+
Wallet filter lists are comma-separated. Use `python -m samanapi` as an
|
|
218
|
+
alternative entry point. The CLI writes JSON results to stdout and errors to
|
|
219
|
+
stderr; `--json-errors` gives structured errors and exit code 1. Cancellation
|
|
220
|
+
uses exit code 130. Financial commands require `TRANSFER`, wallet activation
|
|
221
|
+
`ACTIVATE`, and deactivation `DEACTIVATE`; noninteractive callers must pass the
|
|
222
|
+
matching `--confirm` value. Required card secrets are prompted without echo
|
|
223
|
+
when omitted. Use `import-har FILE` for a local private capture, never a public example.
|
|
224
|
+
|
|
225
|
+
## API reference and development
|
|
226
|
+
|
|
227
|
+
See [the complete method reference](docs/reference.md) and the
|
|
228
|
+
[HTTP service documentation](https://apibank.heydari.workers.dev/saman/).
|
|
229
|
+
`request("GET", "cards", query={...})` is available for future relative routes;
|
|
230
|
+
absolute/cross-origin paths and traversal are rejected before any I/O.
|
|
231
|
+
|
|
232
|
+
```sh
|
|
233
|
+
pip install -e .
|
|
234
|
+
python -m unittest discover -s tests -v
|
|
235
|
+
python tools/generate_async.py
|
|
236
|
+
python -m build
|
|
237
|
+
python -m twine check dist/*
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
Tests use mock transports and a fake bank behind the real Node server. They do
|
|
241
|
+
not send money or verify a current live bank session. The source distribution
|
|
242
|
+
and wheel use explicit include rules and contain no sessions, captures or API keys.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
samanapi/__init__.py,sha256=u1J4-k3qCn6EwMpfVlxvGFw5dokmGrQFqW0g58UbK8s,757
|
|
2
|
+
samanapi/__main__.py,sha256=k1ocEWawweo1qCJWNFAAvyxz3tcY13dzvCenHszij30,48
|
|
3
|
+
samanapi/_transport.py,sha256=RMvoeN9BqPgUBI9yiYjnUHrvp64ooGgach_dZe4c97k,5798
|
|
4
|
+
samanapi/async_client.py,sha256=F46QAegIKPARrcm_7FMk1lyiZllXETpsIHVuads-qeI,27087
|
|
5
|
+
samanapi/cli.py,sha256=IW48iWAwkUC9n75_nzUZ5GhNlTDhhxdHiITDRAecKTI,7961
|
|
6
|
+
samanapi/client.py,sha256=rS2DRTRJj7Tb0vQeFCYwaTEjISTf6OdT_yE_r23GoHw,31079
|
|
7
|
+
samanapi/errors.py,sha256=-BDwqOMySXYKKPvRTIyiDLxYLatfyTStsRLck4sKKLI,2899
|
|
8
|
+
samanapi/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
samanapi/types.py,sha256=Z2OeH_B5ylsQ9gCtnqunlv--Zkr3D-H18mIU1lgmK0o,1347
|
|
10
|
+
saman_api-0.1.0.dist-info/METADATA,sha256=JZbAClcV0suw5Vl1SY9YsZWmbO2em2vHBa7TxVgc5U4,10204
|
|
11
|
+
saman_api-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
12
|
+
saman_api-0.1.0.dist-info/entry_points.txt,sha256=rDqaU5NGh7h5yn2YVmOZEIouDzkfNC3W8mukU1YN8yU,48
|
|
13
|
+
saman_api-0.1.0.dist-info/licenses/LICENSE,sha256=nDHGbXS92BIngIAo65N-1qztxjutlvwIVA1y84lWFk4,1077
|
|
14
|
+
saman_api-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 APIBANK contributors
|
|
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.
|
samanapi/__init__.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Saman Mobillet HTTP SDK. Requires a separately running API service."""
|
|
2
|
+
|
|
3
|
+
from .client import Client
|
|
4
|
+
from .async_client import AsyncClient
|
|
5
|
+
from . import errors
|
|
6
|
+
from .errors import (
|
|
7
|
+
SamanError,
|
|
8
|
+
ApiError,
|
|
9
|
+
AuthenticationError,
|
|
10
|
+
DeviceVerificationRequired,
|
|
11
|
+
PasswordChangeRequired,
|
|
12
|
+
ValidationError,
|
|
13
|
+
BankError,
|
|
14
|
+
SmsDeliveryError,
|
|
15
|
+
ProtocolError,
|
|
16
|
+
TransportError,
|
|
17
|
+
PaginationError,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
__version__ = "0.1.0"
|
|
21
|
+
__all__ = [
|
|
22
|
+
"Client",
|
|
23
|
+
"AsyncClient",
|
|
24
|
+
"errors",
|
|
25
|
+
"SamanError",
|
|
26
|
+
"ApiError",
|
|
27
|
+
"AuthenticationError",
|
|
28
|
+
"DeviceVerificationRequired",
|
|
29
|
+
"PasswordChangeRequired",
|
|
30
|
+
"ValidationError",
|
|
31
|
+
"BankError",
|
|
32
|
+
"SmsDeliveryError",
|
|
33
|
+
"ProtocolError",
|
|
34
|
+
"TransportError",
|
|
35
|
+
"PaginationError",
|
|
36
|
+
]
|
samanapi/__main__.py
ADDED
samanapi/_transport.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""One request per call: no redirect following and no automatic retries."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import inspect
|
|
7
|
+
import json
|
|
8
|
+
from typing import Any, Callable, Mapping
|
|
9
|
+
from urllib.parse import quote, unquote, urlsplit, urlunsplit
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from .errors import ProtocolError, TransportError, response_error
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def base_url(value: str) -> str:
|
|
17
|
+
parts = urlsplit(value)
|
|
18
|
+
if (
|
|
19
|
+
parts.scheme not in {"http", "https"}
|
|
20
|
+
or not parts.hostname
|
|
21
|
+
or parts.username
|
|
22
|
+
or parts.password
|
|
23
|
+
or parts.query
|
|
24
|
+
or parts.fragment
|
|
25
|
+
):
|
|
26
|
+
raise ValueError(
|
|
27
|
+
"base_url must be an HTTP(S) URL without credentials, query, or fragment."
|
|
28
|
+
)
|
|
29
|
+
prefix = parts.path.rstrip("/")
|
|
30
|
+
if not prefix.endswith("/v1"):
|
|
31
|
+
prefix += "/v1"
|
|
32
|
+
return urlunsplit((parts.scheme, parts.netloc, prefix, "", ""))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def identifier(value: str) -> str:
|
|
36
|
+
if (
|
|
37
|
+
not isinstance(value, str)
|
|
38
|
+
or not value
|
|
39
|
+
or value in {".", ".."}
|
|
40
|
+
or any(ord(c) < 32 for c in value)
|
|
41
|
+
):
|
|
42
|
+
raise ValueError(
|
|
43
|
+
"Identifiers must be non-empty strings, preserving leading zeros."
|
|
44
|
+
)
|
|
45
|
+
return quote(value, safe="")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def integer(
|
|
49
|
+
value: int, name: str, minimum: int = 0, maximum: int = 9007199254740991
|
|
50
|
+
) -> int:
|
|
51
|
+
if (
|
|
52
|
+
isinstance(value, bool)
|
|
53
|
+
or not isinstance(value, int)
|
|
54
|
+
or not minimum <= value <= maximum
|
|
55
|
+
):
|
|
56
|
+
raise ValueError(f"{name} must be an integer between {minimum} and {maximum}.")
|
|
57
|
+
return value
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def params(values: Mapping[str, Any] | None) -> list[tuple[str, str]]:
|
|
61
|
+
result = []
|
|
62
|
+
for key, value in (values or {}).items():
|
|
63
|
+
if value is None:
|
|
64
|
+
continue
|
|
65
|
+
for item in value if isinstance(value, (list, tuple)) else [value]:
|
|
66
|
+
if not isinstance(item, (str, int, float, bool)):
|
|
67
|
+
raise ValueError(f"Unsupported query value for {key}.")
|
|
68
|
+
result.append(
|
|
69
|
+
(key, str(item).lower() if isinstance(item, bool) else str(item))
|
|
70
|
+
)
|
|
71
|
+
return result
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def route_path(route: str) -> str:
|
|
75
|
+
parts = urlsplit(route)
|
|
76
|
+
if parts.scheme or parts.netloc or parts.query or parts.fragment or "\\" in route:
|
|
77
|
+
raise ValueError(
|
|
78
|
+
"Request paths must be relative API routes without a query or fragment."
|
|
79
|
+
)
|
|
80
|
+
if any(segment in {".", ".."} for segment in unquote(parts.path).split("/")):
|
|
81
|
+
raise ValueError("Path traversal is not allowed.")
|
|
82
|
+
return route.lstrip("/")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def compact(values: Mapping[str, Any]) -> dict[str, Any]:
|
|
86
|
+
return {key: value for key, value in values.items() if value is not None}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def decode(status: int, raw: bytes, method: str, path: str) -> dict[str, Any]:
|
|
90
|
+
try:
|
|
91
|
+
payload = json.loads(raw)
|
|
92
|
+
except (ValueError, UnicodeError, RecursionError):
|
|
93
|
+
if status >= 300:
|
|
94
|
+
raise response_error(
|
|
95
|
+
status, raw.decode("utf-8", "replace"), method, path
|
|
96
|
+
) from None
|
|
97
|
+
raise ProtocolError("The API service returned invalid JSON.") from None
|
|
98
|
+
if not 200 <= status < 300:
|
|
99
|
+
raise response_error(status, payload, method, path)
|
|
100
|
+
if not isinstance(payload, dict):
|
|
101
|
+
raise ProtocolError("Expected a JSON object from the API service.")
|
|
102
|
+
if isinstance(payload.get("error"), str):
|
|
103
|
+
raise response_error(status, payload, method, path)
|
|
104
|
+
return payload
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def send(
|
|
108
|
+
client: httpx.Client,
|
|
109
|
+
method: str,
|
|
110
|
+
url: str,
|
|
111
|
+
*,
|
|
112
|
+
headers: Mapping[str, str],
|
|
113
|
+
query: Any,
|
|
114
|
+
body: Any,
|
|
115
|
+
limit: int,
|
|
116
|
+
) -> dict[str, Any]:
|
|
117
|
+
try:
|
|
118
|
+
with client.stream(
|
|
119
|
+
method,
|
|
120
|
+
url,
|
|
121
|
+
headers=headers,
|
|
122
|
+
params=query,
|
|
123
|
+
json=body,
|
|
124
|
+
follow_redirects=False,
|
|
125
|
+
) as response:
|
|
126
|
+
chunks, size = [], 0
|
|
127
|
+
for chunk in response.iter_bytes():
|
|
128
|
+
size += len(chunk)
|
|
129
|
+
if size > limit:
|
|
130
|
+
raise ProtocolError("The API response exceeded max_response_bytes.")
|
|
131
|
+
chunks.append(chunk)
|
|
132
|
+
return decode(
|
|
133
|
+
response.status_code, b"".join(chunks), method, urlsplit(url).path
|
|
134
|
+
)
|
|
135
|
+
except httpx.RequestError:
|
|
136
|
+
raise TransportError(method, urlsplit(url).path) from None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
async def async_send(
|
|
140
|
+
client: httpx.AsyncClient,
|
|
141
|
+
method: str,
|
|
142
|
+
url: str,
|
|
143
|
+
*,
|
|
144
|
+
headers: Mapping[str, str],
|
|
145
|
+
query: Any,
|
|
146
|
+
body: Any,
|
|
147
|
+
limit: int,
|
|
148
|
+
) -> dict[str, Any]:
|
|
149
|
+
try:
|
|
150
|
+
async with client.stream(
|
|
151
|
+
method,
|
|
152
|
+
url,
|
|
153
|
+
headers=headers,
|
|
154
|
+
params=query,
|
|
155
|
+
json=body,
|
|
156
|
+
follow_redirects=False,
|
|
157
|
+
) as response:
|
|
158
|
+
chunks, size = [], 0
|
|
159
|
+
async for chunk in response.aiter_bytes():
|
|
160
|
+
size += len(chunk)
|
|
161
|
+
if size > limit:
|
|
162
|
+
raise ProtocolError("The API response exceeded max_response_bytes.")
|
|
163
|
+
chunks.append(chunk)
|
|
164
|
+
return decode(
|
|
165
|
+
response.status_code, b"".join(chunks), method, urlsplit(url).path
|
|
166
|
+
)
|
|
167
|
+
except httpx.RequestError:
|
|
168
|
+
raise TransportError(method, urlsplit(url).path) from None
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def collect(callback: Callable[[], str]) -> str:
|
|
172
|
+
result = callback()
|
|
173
|
+
if not isinstance(result, str):
|
|
174
|
+
raise ValueError("The input callback must return a string.")
|
|
175
|
+
return result
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
async def async_collect(callback: Callable[..., Any]) -> str:
|
|
179
|
+
result = (
|
|
180
|
+
await callback()
|
|
181
|
+
if inspect.iscoroutinefunction(callback)
|
|
182
|
+
else await asyncio.to_thread(callback)
|
|
183
|
+
)
|
|
184
|
+
if inspect.isawaitable(result):
|
|
185
|
+
result = await result
|
|
186
|
+
if not isinstance(result, str):
|
|
187
|
+
raise ValueError("The input callback must return a string.")
|
|
188
|
+
return result
|