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.
@@ -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)