python-myanmar-payments 4.0.0a1__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.
- python_myanmar_payments/__init__.py +111 -0
- python_myanmar_payments/_amount.py +178 -0
- python_myanmar_payments/_cache.py +80 -0
- python_myanmar_payments/_callback.py +188 -0
- python_myanmar_payments/_errors.py +94 -0
- python_myanmar_payments/_facade.py +271 -0
- python_myanmar_payments/_http.py +201 -0
- python_myanmar_payments/_json.py +94 -0
- python_myanmar_payments/_results.py +265 -0
- python_myanmar_payments/_status.py +66 -0
- python_myanmar_payments/_support.py +172 -0
- python_myanmar_payments/_validate.py +157 -0
- python_myanmar_payments/_values.py +59 -0
- python_myanmar_payments/_version.py +1 -0
- python_myanmar_payments/aya_pay.py +509 -0
- python_myanmar_payments/cyber_source.py +310 -0
- python_myanmar_payments/kbz_pay.py +556 -0
- python_myanmar_payments/py.typed +0 -0
- python_myanmar_payments/wave_money.py +431 -0
- python_myanmar_payments/yoma_mmqr.py +526 -0
- python_myanmar_payments-4.0.0a1.dist-info/METADATA +43 -0
- python_myanmar_payments-4.0.0a1.dist-info/RECORD +24 -0
- python_myanmar_payments-4.0.0a1.dist-info/WHEEL +4 -0
- python_myanmar_payments-4.0.0a1.dist-info/licenses/LICENSE.md +21 -0
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""Builds every gateway from one configuration (or the environment)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Mapping
|
|
6
|
+
from types import TracebackType
|
|
7
|
+
from typing import Any, TypeVar
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from ._cache import AsyncTokenCache, MemoryTokenCache, TokenCache
|
|
12
|
+
from ._errors import ConfigurationError
|
|
13
|
+
from ._http import DEFAULT_TIMEOUT
|
|
14
|
+
from ._support import EnvSource, config_of
|
|
15
|
+
from .aya_pay import AsyncAyaPay, AyaPay, AyaPayConfig
|
|
16
|
+
from .cyber_source import CyberSource, CyberSourceConfig
|
|
17
|
+
from .kbz_pay import AsyncKbzPay, KbzPay, KbzPayConfig
|
|
18
|
+
from .wave_money import AsyncWaveMoney, WaveMoney, WaveMoneyConfig
|
|
19
|
+
from .yoma_mmqr import AsyncYomaMmqr, YomaMmqr, YomaMmqrConfig
|
|
20
|
+
|
|
21
|
+
__all__ = ["AsyncMyanmarPayments", "MyanmarPayments"]
|
|
22
|
+
|
|
23
|
+
_C = TypeVar("_C")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _lazy(config_class: type[_C], config: _C | Mapping[str, Any] | None) -> Callable[[], _C] | None:
|
|
27
|
+
return None if config is None else lambda: config_of(config_class, config)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _resolve(source: Callable[[], _C] | None, gateway: str, first_key: str) -> _C:
|
|
31
|
+
if source is None:
|
|
32
|
+
raise ConfigurationError(gateway, first_key)
|
|
33
|
+
return source()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class _Payments:
|
|
37
|
+
"""Holds each gateway's configuration until the gateway is first used."""
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
kbz_pay: KbzPayConfig | Mapping[str, Any] | None,
|
|
42
|
+
wave_money: WaveMoneyConfig | Mapping[str, Any] | None,
|
|
43
|
+
aya_pay: AyaPayConfig | Mapping[str, Any] | None,
|
|
44
|
+
yoma_mmqr: YomaMmqrConfig | Mapping[str, Any] | None,
|
|
45
|
+
cyber_source: CyberSourceConfig | Mapping[str, Any] | None,
|
|
46
|
+
timeout: float | None,
|
|
47
|
+
) -> None:
|
|
48
|
+
self._kbz_pay_config = _lazy(KbzPayConfig, kbz_pay)
|
|
49
|
+
self._wave_money_config = _lazy(WaveMoneyConfig, wave_money)
|
|
50
|
+
self._aya_pay_config = _lazy(AyaPayConfig, aya_pay)
|
|
51
|
+
self._yoma_mmqr_config = _lazy(YomaMmqrConfig, yoma_mmqr)
|
|
52
|
+
self._cyber_source_config = _lazy(CyberSourceConfig, cyber_source)
|
|
53
|
+
self._timeout = timeout
|
|
54
|
+
self._cyber_source: CyberSource | None = None
|
|
55
|
+
|
|
56
|
+
def _read_env(self, env: EnvSource | None) -> None:
|
|
57
|
+
self._kbz_pay_config = lambda: KbzPayConfig.from_env(env)
|
|
58
|
+
self._wave_money_config = lambda: WaveMoneyConfig.from_env(env)
|
|
59
|
+
self._aya_pay_config = lambda: AyaPayConfig.from_env(env)
|
|
60
|
+
self._yoma_mmqr_config = lambda: YomaMmqrConfig.from_env(env)
|
|
61
|
+
self._cyber_source_config = lambda: CyberSourceConfig.from_env(env)
|
|
62
|
+
|
|
63
|
+
def cyber_source(self) -> CyberSource:
|
|
64
|
+
"""The CyberSource gateway. Raises a ``ConfigurationError`` when not configured."""
|
|
65
|
+
if self._cyber_source is None:
|
|
66
|
+
config = _resolve(self._cyber_source_config, "cyber_source", "profile_id")
|
|
67
|
+
self._cyber_source = CyberSource(config)
|
|
68
|
+
return self._cyber_source
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class MyanmarPayments(_Payments):
|
|
72
|
+
"""Builds each gateway (sync clients) from one configuration or the environment.
|
|
73
|
+
|
|
74
|
+
Gateways are created on first use and reused, so only the gateways you call
|
|
75
|
+
need to be configured. Create it once and share it; :meth:`close` (or a
|
|
76
|
+
``with`` block) closes the HTTP clients it created.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
def __init__(
|
|
80
|
+
self,
|
|
81
|
+
*,
|
|
82
|
+
kbz_pay: KbzPayConfig | Mapping[str, Any] | None = None,
|
|
83
|
+
wave_money: WaveMoneyConfig | Mapping[str, Any] | None = None,
|
|
84
|
+
aya_pay: AyaPayConfig | Mapping[str, Any] | None = None,
|
|
85
|
+
yoma_mmqr: YomaMmqrConfig | Mapping[str, Any] | None = None,
|
|
86
|
+
cyber_source: CyberSourceConfig | Mapping[str, Any] | None = None,
|
|
87
|
+
token_cache: TokenCache | None = None,
|
|
88
|
+
http_client: httpx.Client | None = None,
|
|
89
|
+
timeout: float | None = DEFAULT_TIMEOUT,
|
|
90
|
+
) -> None:
|
|
91
|
+
super().__init__(kbz_pay, wave_money, aya_pay, yoma_mmqr, cyber_source, timeout)
|
|
92
|
+
self._http_client = http_client
|
|
93
|
+
self._token_cache: TokenCache = (
|
|
94
|
+
token_cache if token_cache is not None else MemoryTokenCache()
|
|
95
|
+
)
|
|
96
|
+
self._kbz_pay: KbzPay | None = None
|
|
97
|
+
self._wave_money: WaveMoney | None = None
|
|
98
|
+
self._aya_pay: AyaPay | None = None
|
|
99
|
+
self._yoma_mmqr: YomaMmqr | None = None
|
|
100
|
+
|
|
101
|
+
@classmethod
|
|
102
|
+
def from_env(
|
|
103
|
+
cls,
|
|
104
|
+
env: EnvSource | None = None,
|
|
105
|
+
*,
|
|
106
|
+
token_cache: TokenCache | None = None,
|
|
107
|
+
http_client: httpx.Client | None = None,
|
|
108
|
+
timeout: float | None = DEFAULT_TIMEOUT,
|
|
109
|
+
) -> MyanmarPayments:
|
|
110
|
+
"""Reads every gateway's configuration from environment variables.
|
|
111
|
+
|
|
112
|
+
``KBZ_PAY_*``, ``WAVE_MONEY_*``, ``AYA_PAY_*``, ``YOMA_MMQR_*`` and
|
|
113
|
+
``CYBER_SOURCE_*`` are read when the gateway is first used. Defaults to
|
|
114
|
+
``os.environ``.
|
|
115
|
+
"""
|
|
116
|
+
payments = cls(token_cache=token_cache, http_client=http_client, timeout=timeout)
|
|
117
|
+
payments._read_env(env)
|
|
118
|
+
return payments
|
|
119
|
+
|
|
120
|
+
def kbz_pay(self) -> KbzPay:
|
|
121
|
+
"""The KBZ Pay gateway. Raises a ``ConfigurationError`` when not configured."""
|
|
122
|
+
if self._kbz_pay is None:
|
|
123
|
+
config = _resolve(self._kbz_pay_config, "kbz_pay", "app_id")
|
|
124
|
+
self._kbz_pay = KbzPay(config, http_client=self._http_client, timeout=self._timeout)
|
|
125
|
+
return self._kbz_pay
|
|
126
|
+
|
|
127
|
+
def wave_money(self) -> WaveMoney:
|
|
128
|
+
"""The Wave Money gateway. Raises a ``ConfigurationError`` when not configured."""
|
|
129
|
+
if self._wave_money is None:
|
|
130
|
+
config = _resolve(self._wave_money_config, "wave_money", "merchant_id")
|
|
131
|
+
self._wave_money = WaveMoney(
|
|
132
|
+
config, http_client=self._http_client, timeout=self._timeout
|
|
133
|
+
)
|
|
134
|
+
return self._wave_money
|
|
135
|
+
|
|
136
|
+
def aya_pay(self) -> AyaPay:
|
|
137
|
+
"""The AYA Payment Gateway. Raises a ``ConfigurationError`` when not configured."""
|
|
138
|
+
if self._aya_pay is None:
|
|
139
|
+
config = _resolve(self._aya_pay_config, "aya_pay", "app_key")
|
|
140
|
+
self._aya_pay = AyaPay(config, http_client=self._http_client, timeout=self._timeout)
|
|
141
|
+
return self._aya_pay
|
|
142
|
+
|
|
143
|
+
def yoma_mmqr(self) -> YomaMmqr:
|
|
144
|
+
"""The Yoma MMQR gateway. Raises a ``ConfigurationError`` when not configured."""
|
|
145
|
+
if self._yoma_mmqr is None:
|
|
146
|
+
config = _resolve(self._yoma_mmqr_config, "yoma_mmqr", "merchant_id")
|
|
147
|
+
self._yoma_mmqr = YomaMmqr(
|
|
148
|
+
config,
|
|
149
|
+
token_cache=self._token_cache,
|
|
150
|
+
http_client=self._http_client,
|
|
151
|
+
timeout=self._timeout,
|
|
152
|
+
)
|
|
153
|
+
return self._yoma_mmqr
|
|
154
|
+
|
|
155
|
+
def close(self) -> None:
|
|
156
|
+
"""Closes the HTTP clients the gateways created. A client you passed stays open."""
|
|
157
|
+
for gateway in (self._kbz_pay, self._wave_money, self._aya_pay, self._yoma_mmqr):
|
|
158
|
+
if gateway is not None:
|
|
159
|
+
gateway.close()
|
|
160
|
+
|
|
161
|
+
def __enter__(self) -> MyanmarPayments:
|
|
162
|
+
return self
|
|
163
|
+
|
|
164
|
+
def __exit__(
|
|
165
|
+
self,
|
|
166
|
+
exc_type: type[BaseException] | None,
|
|
167
|
+
exc: BaseException | None,
|
|
168
|
+
traceback: TracebackType | None,
|
|
169
|
+
) -> None:
|
|
170
|
+
self.close()
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class AsyncMyanmarPayments(_Payments):
|
|
174
|
+
"""Builds each gateway (async clients) from one configuration or the environment.
|
|
175
|
+
|
|
176
|
+
The async twin of :class:`MyanmarPayments`: ``kbz_pay()`` returns an
|
|
177
|
+
:class:`AsyncKbzPay` and so on. :meth:`aclose` (or an ``async with`` block)
|
|
178
|
+
closes the HTTP clients it created.
|
|
179
|
+
"""
|
|
180
|
+
|
|
181
|
+
def __init__(
|
|
182
|
+
self,
|
|
183
|
+
*,
|
|
184
|
+
kbz_pay: KbzPayConfig | Mapping[str, Any] | None = None,
|
|
185
|
+
wave_money: WaveMoneyConfig | Mapping[str, Any] | None = None,
|
|
186
|
+
aya_pay: AyaPayConfig | Mapping[str, Any] | None = None,
|
|
187
|
+
yoma_mmqr: YomaMmqrConfig | Mapping[str, Any] | None = None,
|
|
188
|
+
cyber_source: CyberSourceConfig | Mapping[str, Any] | None = None,
|
|
189
|
+
token_cache: TokenCache | AsyncTokenCache | None = None,
|
|
190
|
+
http_client: httpx.AsyncClient | None = None,
|
|
191
|
+
timeout: float | None = DEFAULT_TIMEOUT,
|
|
192
|
+
) -> None:
|
|
193
|
+
super().__init__(kbz_pay, wave_money, aya_pay, yoma_mmqr, cyber_source, timeout)
|
|
194
|
+
self._http_client = http_client
|
|
195
|
+
self._token_cache: TokenCache | AsyncTokenCache = (
|
|
196
|
+
token_cache if token_cache is not None else MemoryTokenCache()
|
|
197
|
+
)
|
|
198
|
+
self._kbz_pay: AsyncKbzPay | None = None
|
|
199
|
+
self._wave_money: AsyncWaveMoney | None = None
|
|
200
|
+
self._aya_pay: AsyncAyaPay | None = None
|
|
201
|
+
self._yoma_mmqr: AsyncYomaMmqr | None = None
|
|
202
|
+
|
|
203
|
+
@classmethod
|
|
204
|
+
def from_env(
|
|
205
|
+
cls,
|
|
206
|
+
env: EnvSource | None = None,
|
|
207
|
+
*,
|
|
208
|
+
token_cache: TokenCache | AsyncTokenCache | None = None,
|
|
209
|
+
http_client: httpx.AsyncClient | None = None,
|
|
210
|
+
timeout: float | None = DEFAULT_TIMEOUT,
|
|
211
|
+
) -> AsyncMyanmarPayments:
|
|
212
|
+
"""Reads every gateway's configuration from environment variables on first use."""
|
|
213
|
+
payments = cls(token_cache=token_cache, http_client=http_client, timeout=timeout)
|
|
214
|
+
payments._read_env(env)
|
|
215
|
+
return payments
|
|
216
|
+
|
|
217
|
+
def kbz_pay(self) -> AsyncKbzPay:
|
|
218
|
+
"""The KBZ Pay gateway. Raises a ``ConfigurationError`` when not configured."""
|
|
219
|
+
if self._kbz_pay is None:
|
|
220
|
+
config = _resolve(self._kbz_pay_config, "kbz_pay", "app_id")
|
|
221
|
+
self._kbz_pay = AsyncKbzPay(
|
|
222
|
+
config, http_client=self._http_client, timeout=self._timeout
|
|
223
|
+
)
|
|
224
|
+
return self._kbz_pay
|
|
225
|
+
|
|
226
|
+
def wave_money(self) -> AsyncWaveMoney:
|
|
227
|
+
"""The Wave Money gateway. Raises a ``ConfigurationError`` when not configured."""
|
|
228
|
+
if self._wave_money is None:
|
|
229
|
+
config = _resolve(self._wave_money_config, "wave_money", "merchant_id")
|
|
230
|
+
self._wave_money = AsyncWaveMoney(
|
|
231
|
+
config, http_client=self._http_client, timeout=self._timeout
|
|
232
|
+
)
|
|
233
|
+
return self._wave_money
|
|
234
|
+
|
|
235
|
+
def aya_pay(self) -> AsyncAyaPay:
|
|
236
|
+
"""The AYA Payment Gateway. Raises a ``ConfigurationError`` when not configured."""
|
|
237
|
+
if self._aya_pay is None:
|
|
238
|
+
config = _resolve(self._aya_pay_config, "aya_pay", "app_key")
|
|
239
|
+
self._aya_pay = AsyncAyaPay(
|
|
240
|
+
config, http_client=self._http_client, timeout=self._timeout
|
|
241
|
+
)
|
|
242
|
+
return self._aya_pay
|
|
243
|
+
|
|
244
|
+
def yoma_mmqr(self) -> AsyncYomaMmqr:
|
|
245
|
+
"""The Yoma MMQR gateway. Raises a ``ConfigurationError`` when not configured."""
|
|
246
|
+
if self._yoma_mmqr is None:
|
|
247
|
+
config = _resolve(self._yoma_mmqr_config, "yoma_mmqr", "merchant_id")
|
|
248
|
+
self._yoma_mmqr = AsyncYomaMmqr(
|
|
249
|
+
config,
|
|
250
|
+
token_cache=self._token_cache,
|
|
251
|
+
http_client=self._http_client,
|
|
252
|
+
timeout=self._timeout,
|
|
253
|
+
)
|
|
254
|
+
return self._yoma_mmqr
|
|
255
|
+
|
|
256
|
+
async def aclose(self) -> None:
|
|
257
|
+
"""Closes the HTTP clients the gateways created. A client you passed stays open."""
|
|
258
|
+
for gateway in (self._kbz_pay, self._wave_money, self._aya_pay, self._yoma_mmqr):
|
|
259
|
+
if gateway is not None:
|
|
260
|
+
await gateway.aclose()
|
|
261
|
+
|
|
262
|
+
async def __aenter__(self) -> AsyncMyanmarPayments:
|
|
263
|
+
return self
|
|
264
|
+
|
|
265
|
+
async def __aexit__(
|
|
266
|
+
self,
|
|
267
|
+
exc_type: type[BaseException] | None,
|
|
268
|
+
exc: BaseException | None,
|
|
269
|
+
traceback: TracebackType | None,
|
|
270
|
+
) -> None:
|
|
271
|
+
await self.aclose()
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Sends the gateways' HTTP requests with httpx, sync or async."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import threading
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from types import TracebackType
|
|
9
|
+
from typing import Any, TypeVar
|
|
10
|
+
from urllib.parse import urlencode
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
from ._errors import ApiError
|
|
15
|
+
from ._json import LosslessObject, dumps, parse_object
|
|
16
|
+
|
|
17
|
+
DEFAULT_TIMEOUT = 30.0
|
|
18
|
+
"""The default request timeout, in seconds."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class HttpRequest:
|
|
23
|
+
"""A request a gateway sends. Gateways only send ``POST`` requests."""
|
|
24
|
+
|
|
25
|
+
url: str
|
|
26
|
+
body: str
|
|
27
|
+
headers: Mapping[str, str] = field(default_factory=dict)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class GatewayResponse:
|
|
32
|
+
"""A gateway's response: its status and raw body text."""
|
|
33
|
+
|
|
34
|
+
status: int
|
|
35
|
+
body: str
|
|
36
|
+
|
|
37
|
+
def successful(self) -> bool:
|
|
38
|
+
return 200 <= self.status < 300
|
|
39
|
+
|
|
40
|
+
def json(self) -> LosslessObject:
|
|
41
|
+
"""The body as a JSON object with exact numbers, or ``{}`` when it is not one."""
|
|
42
|
+
return parse_object(self.body) or {}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def json_request(
|
|
46
|
+
url: str, data: Mapping[str, Any], headers: Mapping[str, str] | None = None
|
|
47
|
+
) -> HttpRequest:
|
|
48
|
+
return HttpRequest(
|
|
49
|
+
url=url,
|
|
50
|
+
body=dumps(data),
|
|
51
|
+
headers={
|
|
52
|
+
"Content-Type": "application/json",
|
|
53
|
+
"Accept": "application/json",
|
|
54
|
+
**(headers or {}),
|
|
55
|
+
},
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def form_request(
|
|
60
|
+
url: str, data: Mapping[str, str], headers: Mapping[str, str] | None = None
|
|
61
|
+
) -> HttpRequest:
|
|
62
|
+
return HttpRequest(
|
|
63
|
+
url=url,
|
|
64
|
+
body=urlencode(data),
|
|
65
|
+
headers={
|
|
66
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
67
|
+
"Accept": "application/json",
|
|
68
|
+
**(headers or {}),
|
|
69
|
+
},
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _unreachable(request: HttpRequest, error: Exception) -> ApiError:
|
|
74
|
+
return ApiError(f"Could not reach {request.url}: {str(error) or type(error).__name__}")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _response(response: httpx.Response) -> GatewayResponse:
|
|
78
|
+
return GatewayResponse(response.status_code, response.content.decode("utf-8", "replace"))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class SyncTransport:
|
|
82
|
+
"""Posts requests with an ``httpx.Client``, created on first use unless given."""
|
|
83
|
+
|
|
84
|
+
def __init__(self, client: httpx.Client | None, timeout: float | None) -> None:
|
|
85
|
+
self._client = client
|
|
86
|
+
self._owns_client = client is None
|
|
87
|
+
self._timeout = timeout
|
|
88
|
+
self._lock = threading.Lock()
|
|
89
|
+
|
|
90
|
+
def send(self, request: HttpRequest) -> GatewayResponse:
|
|
91
|
+
try:
|
|
92
|
+
response = self._http().post(
|
|
93
|
+
request.url, content=request.body.encode(), headers=dict(request.headers)
|
|
94
|
+
)
|
|
95
|
+
except Exception as error:
|
|
96
|
+
raise _unreachable(request, error) from error
|
|
97
|
+
return _response(response)
|
|
98
|
+
|
|
99
|
+
def _http(self) -> httpx.Client:
|
|
100
|
+
with self._lock:
|
|
101
|
+
if self._client is None:
|
|
102
|
+
self._client = httpx.Client(timeout=self._timeout)
|
|
103
|
+
return self._client
|
|
104
|
+
|
|
105
|
+
def close(self) -> None:
|
|
106
|
+
"""Closes the client this transport created; a client you passed stays open."""
|
|
107
|
+
with self._lock:
|
|
108
|
+
if self._owns_client and self._client is not None:
|
|
109
|
+
self._client.close()
|
|
110
|
+
self._client = None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class AsyncTransport:
|
|
114
|
+
"""Posts requests with an ``httpx.AsyncClient``, created on first use unless given."""
|
|
115
|
+
|
|
116
|
+
def __init__(self, client: httpx.AsyncClient | None, timeout: float | None) -> None:
|
|
117
|
+
self._client = client
|
|
118
|
+
self._owns_client = client is None
|
|
119
|
+
self._timeout = timeout
|
|
120
|
+
|
|
121
|
+
async def send(self, request: HttpRequest) -> GatewayResponse:
|
|
122
|
+
try:
|
|
123
|
+
response = await self._http().post(
|
|
124
|
+
request.url, content=request.body.encode(), headers=dict(request.headers)
|
|
125
|
+
)
|
|
126
|
+
except Exception as error:
|
|
127
|
+
raise _unreachable(request, error) from error
|
|
128
|
+
return _response(response)
|
|
129
|
+
|
|
130
|
+
def _http(self) -> httpx.AsyncClient:
|
|
131
|
+
if self._client is None:
|
|
132
|
+
self._client = httpx.AsyncClient(timeout=self._timeout)
|
|
133
|
+
return self._client
|
|
134
|
+
|
|
135
|
+
async def aclose(self) -> None:
|
|
136
|
+
"""Closes the client this transport created; a client you passed stays open."""
|
|
137
|
+
if self._owns_client and self._client is not None:
|
|
138
|
+
client, self._client = self._client, None
|
|
139
|
+
await client.aclose()
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
_S = TypeVar("_S", bound="SyncGateway")
|
|
143
|
+
_A = TypeVar("_A", bound="AsyncGateway")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class SyncGateway:
|
|
147
|
+
"""A gateway that calls its API with a synchronous ``httpx.Client``.
|
|
148
|
+
|
|
149
|
+
Pass ``http_client`` to share a client (proxies, tracing, test transports);
|
|
150
|
+
otherwise one is created on first use with ``timeout`` seconds (default 30)
|
|
151
|
+
and closed by :meth:`close` or a ``with`` block.
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
_transport: SyncTransport
|
|
155
|
+
|
|
156
|
+
def _init_transport(self, http_client: httpx.Client | None, timeout: float | None) -> None:
|
|
157
|
+
self._transport = SyncTransport(http_client, timeout)
|
|
158
|
+
|
|
159
|
+
def close(self) -> None:
|
|
160
|
+
"""Closes the HTTP client the gateway created. A client you passed stays open."""
|
|
161
|
+
self._transport.close()
|
|
162
|
+
|
|
163
|
+
def __enter__(self: _S) -> _S:
|
|
164
|
+
return self
|
|
165
|
+
|
|
166
|
+
def __exit__(
|
|
167
|
+
self,
|
|
168
|
+
exc_type: type[BaseException] | None,
|
|
169
|
+
exc: BaseException | None,
|
|
170
|
+
traceback: TracebackType | None,
|
|
171
|
+
) -> None:
|
|
172
|
+
self.close()
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class AsyncGateway:
|
|
176
|
+
"""A gateway that calls its API with an ``httpx.AsyncClient``.
|
|
177
|
+
|
|
178
|
+
Pass ``http_client`` to share a client; otherwise one is created on first use
|
|
179
|
+
with ``timeout`` seconds (default 30) and closed by :meth:`aclose` or an
|
|
180
|
+
``async with`` block.
|
|
181
|
+
"""
|
|
182
|
+
|
|
183
|
+
_transport: AsyncTransport
|
|
184
|
+
|
|
185
|
+
def _init_transport(self, http_client: httpx.AsyncClient | None, timeout: float | None) -> None:
|
|
186
|
+
self._transport = AsyncTransport(http_client, timeout)
|
|
187
|
+
|
|
188
|
+
async def aclose(self) -> None:
|
|
189
|
+
"""Closes the HTTP client the gateway created. A client you passed stays open."""
|
|
190
|
+
await self._transport.aclose()
|
|
191
|
+
|
|
192
|
+
async def __aenter__(self: _A) -> _A:
|
|
193
|
+
return self
|
|
194
|
+
|
|
195
|
+
async def __aexit__(
|
|
196
|
+
self,
|
|
197
|
+
exc_type: type[BaseException] | None,
|
|
198
|
+
exc: BaseException | None,
|
|
199
|
+
traceback: TracebackType | None,
|
|
200
|
+
) -> None:
|
|
201
|
+
await self.aclose()
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Lossless JSON: numbers keep the exact text the gateway sent."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any, Union
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"JsonNumber",
|
|
10
|
+
"LosslessObject",
|
|
11
|
+
"LosslessValue",
|
|
12
|
+
"dumps",
|
|
13
|
+
"parse_object",
|
|
14
|
+
"to_plain",
|
|
15
|
+
"to_plain_object",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class JsonNumber:
|
|
20
|
+
"""A JSON number kept as the exact text the gateway sent.
|
|
21
|
+
|
|
22
|
+
``1000.50`` stays ``1000.50`` and large integers keep every digit. Only used
|
|
23
|
+
internally while verifying signatures and reading values.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
__slots__ = ("text",)
|
|
27
|
+
|
|
28
|
+
def __init__(self, text: str) -> None:
|
|
29
|
+
self.text = text
|
|
30
|
+
|
|
31
|
+
def __eq__(self, other: object) -> bool:
|
|
32
|
+
return isinstance(other, JsonNumber) and other.text == self.text
|
|
33
|
+
|
|
34
|
+
def __hash__(self) -> int:
|
|
35
|
+
return hash(self.text)
|
|
36
|
+
|
|
37
|
+
def __repr__(self) -> str:
|
|
38
|
+
return f"JsonNumber({self.text!r})"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
LosslessValue = Union[
|
|
42
|
+
str, bool, None, JsonNumber, "list[LosslessValue]", "dict[str, LosslessValue]"
|
|
43
|
+
]
|
|
44
|
+
LosslessObject = dict[str, LosslessValue]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _reject_constant(name: str) -> Any:
|
|
48
|
+
raise ValueError(f"{name} is not valid JSON")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def parse_object(text: str) -> LosslessObject | None:
|
|
52
|
+
"""Parses strict JSON (RFC 8259) without passing numbers through a float.
|
|
53
|
+
|
|
54
|
+
Returns the object, or ``None`` when the text is not valid JSON or not an
|
|
55
|
+
object (an array, a string or ``null``).
|
|
56
|
+
"""
|
|
57
|
+
try:
|
|
58
|
+
value = json.loads(
|
|
59
|
+
text,
|
|
60
|
+
parse_int=JsonNumber,
|
|
61
|
+
parse_float=JsonNumber,
|
|
62
|
+
parse_constant=_reject_constant,
|
|
63
|
+
)
|
|
64
|
+
except (ValueError, RecursionError):
|
|
65
|
+
return None
|
|
66
|
+
return value if isinstance(value, dict) else None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def to_plain(value: LosslessValue) -> Any:
|
|
70
|
+
"""Converts a lossless value to plain Python values for the ``raw`` of results.
|
|
71
|
+
|
|
72
|
+
Numbers become their exact text as a ``str`` (``1000.50`` stays ``"1000.50"``),
|
|
73
|
+
never a float.
|
|
74
|
+
"""
|
|
75
|
+
if isinstance(value, JsonNumber):
|
|
76
|
+
return value.text
|
|
77
|
+
if isinstance(value, list):
|
|
78
|
+
return [to_plain(item) for item in value]
|
|
79
|
+
if isinstance(value, dict):
|
|
80
|
+
return to_plain_object(value)
|
|
81
|
+
return value
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def to_plain_object(value: LosslessObject) -> dict[str, Any]:
|
|
85
|
+
"""Converts a lossless object to plain Python values."""
|
|
86
|
+
return {key: to_plain(item) for key, item in value.items()}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def dumps(value: Any) -> str:
|
|
90
|
+
"""Compact JSON like JavaScript's ``JSON.stringify``.
|
|
91
|
+
|
|
92
|
+
Decimals and amounts are written as strings, so no float is involved.
|
|
93
|
+
"""
|
|
94
|
+
return json.dumps(value, separators=(",", ":"), ensure_ascii=False, default=str)
|