inflowpay 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.
inflowpay/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ """InFlow Python SDK."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ from .errors import InflowApiError
6
+ from .options import ClientOptions
7
+
8
+ __version__ = version("inflowpay")
9
+
10
+ __all__ = ["ClientOptions", "InflowApiError", "__version__"]
inflowpay/_runtime.py ADDED
@@ -0,0 +1,271 @@
1
+ import asyncio
2
+ import json
3
+ import math
4
+ import random
5
+ from collections.abc import Awaitable, Callable, Mapping
6
+ from types import TracebackType
7
+ from typing import Self, TypeVar
8
+ from urllib.parse import quote, urlsplit
9
+
10
+ import httpx
11
+
12
+ from .errors import InflowApiError
13
+ from .options import ClientOptions
14
+
15
+ _RETRY_STATUSES = {429, 502, 503, 504}
16
+ _SENSITIVE = {
17
+ "authorization",
18
+ "proxyauthorization",
19
+ "cookie",
20
+ "setcookie",
21
+ "xapikey",
22
+ "apikey",
23
+ "accesstoken",
24
+ "refreshtoken",
25
+ "privatekey",
26
+ "secretkey",
27
+ "password",
28
+ "credential",
29
+ "signature",
30
+ "paymentsignature",
31
+ "xpayment",
32
+ }
33
+ _T = TypeVar("_T")
34
+
35
+
36
+ def _sensitive(key: str) -> bool:
37
+ return key.lower().replace("-", "").replace("_", "") in _SENSITIVE
38
+
39
+
40
+ def _redact(value: object, secrets: tuple[str, ...]) -> object:
41
+ if isinstance(value, str):
42
+ for secret in secrets:
43
+ value = value.replace(secret, "[REDACTED]")
44
+ return value
45
+ if isinstance(value, dict):
46
+ return {
47
+ key: "[REDACTED]" if _sensitive(key) else _redact(item, secrets)
48
+ for key, item in value.items()
49
+ }
50
+ if isinstance(value, list):
51
+ return [_redact(item, secrets) for item in value]
52
+ return value
53
+
54
+
55
+ def _text(body: Mapping[str, object], key: str, fallback: str) -> str:
56
+ value = body.get(key)
57
+ return value if isinstance(value, str) and value else fallback
58
+
59
+
60
+ def _error(path: str, response: httpx.Response, secrets: tuple[str, ...]) -> InflowApiError:
61
+ body = _redact(_parse(response), secrets)
62
+ code, message = "UNEXPECTED_ERROR", "request failed"
63
+ if isinstance(body, dict):
64
+ code = _text(body, "code", code)
65
+ message = _text(body, "message", message)
66
+ entries = body.get("errors")
67
+ if isinstance(entries, list) and entries and isinstance(entries[0], dict):
68
+ code = _text(entries[0], "code", code)
69
+ message = _text(entries[0], "message", message)
70
+ message = _text(body, "detail", message)
71
+ headers = {
72
+ key: str(_redact(value, secrets))
73
+ for key, value in response.headers.items()
74
+ if not _sensitive(key)
75
+ }
76
+ return InflowApiError(
77
+ message,
78
+ code=code,
79
+ http_status=response.status_code,
80
+ endpoint=path,
81
+ body=body,
82
+ headers=headers,
83
+ )
84
+
85
+
86
+ def _parse(response: httpx.Response) -> object:
87
+ if not response.content:
88
+ return None
89
+ try:
90
+ return json.loads(response.content)
91
+ except (ValueError, UnicodeDecodeError):
92
+ return response.text
93
+
94
+
95
+ def _positive(value: float) -> None:
96
+ if not math.isfinite(value) or value <= 0:
97
+ raise ValueError("timeout and poll interval must be finite and positive")
98
+
99
+
100
+ def _credential(value: str) -> None:
101
+ if not value or any(ord(char) <= 32 or ord(char) >= 127 for char in value):
102
+ raise ValueError(
103
+ "credentials must be nonempty ASCII without whitespace or control characters"
104
+ )
105
+
106
+
107
+ class Client:
108
+ def __init__(self, options: ClientOptions) -> None:
109
+ if options.environment not in ("production", "sandbox"):
110
+ raise ValueError("unknown InFlow environment")
111
+ base = options.base_url or (
112
+ "https://api.inflowpay.ai"
113
+ if options.environment == "production"
114
+ else "https://sandbox.inflowpay.ai"
115
+ )
116
+ parsed = urlsplit(base)
117
+ if (
118
+ parsed.scheme not in ("http", "https")
119
+ or not parsed.hostname
120
+ or parsed.username is not None
121
+ or parsed.password is not None
122
+ or "?" in base
123
+ or "#" in base
124
+ ):
125
+ raise ValueError(
126
+ "base URL must be HTTP or HTTPS without credentials, query, or fragment"
127
+ )
128
+ if options.api_key is not None:
129
+ _credential(options.api_key)
130
+ if options.access_token is not None:
131
+ raise ValueError("API key and access token provider are mutually exclusive")
132
+ _positive(options.timeout)
133
+ self.base_url = base.rstrip("/")
134
+ self._options = options
135
+ self._http = httpx.AsyncClient(
136
+ transport=options.transport,
137
+ follow_redirects=False,
138
+ timeout=None,
139
+ )
140
+
141
+ async def __aenter__(self) -> Self:
142
+ await self._http.__aenter__()
143
+ return self
144
+
145
+ async def __aexit__(
146
+ self,
147
+ exc_type: type[BaseException] | None,
148
+ exc: BaseException | None,
149
+ traceback: TracebackType | None,
150
+ ) -> None:
151
+ await self.aclose()
152
+
153
+ async def aclose(self) -> None:
154
+ await self._http.aclose()
155
+
156
+ async def request(
157
+ self,
158
+ method: str,
159
+ path: str,
160
+ *,
161
+ body: object = None,
162
+ headers: Mapping[str, str] | None = None,
163
+ retries: int = 0,
164
+ ) -> object:
165
+ # Retry permission belongs to the protocol operation, not the HTTP method.
166
+ if not path.startswith("/") or path.startswith("//") or "#" in path:
167
+ raise ValueError("expected an InFlow API path beginning with one slash")
168
+ if retries < 0:
169
+ raise ValueError("retries must not be negative")
170
+ extra = dict(headers or {})
171
+ if any(key.lower() in ("authorization", "x-api-key", "cookie") for key in extra):
172
+ raise ValueError("request headers must not override authentication")
173
+ content = None if body is None else json.dumps(body, allow_nan=False).encode()
174
+ attempt = 0
175
+ while True:
176
+ error = await self._attempt(method, path, content, extra)
177
+ if not isinstance(error, InflowApiError):
178
+ return error
179
+ if attempt >= min(retries, 3) or (
180
+ error.http_status not in _RETRY_STATUSES and error.http_status != 0
181
+ ):
182
+ raise error
183
+ await asyncio.sleep(0.2 * 2**attempt * (1 + random.random() / 4))
184
+ attempt += 1
185
+
186
+ async def _attempt(
187
+ self,
188
+ method: str,
189
+ path: str,
190
+ content: bytes | None,
191
+ extra: dict[str, str],
192
+ ) -> object:
193
+ await asyncio.sleep(0)
194
+ token = self._options.api_key
195
+ # Provider failures are application errors, not retryable transport failures.
196
+ if self._options.access_token is not None:
197
+ token = await self._options.access_token()
198
+ _credential(token)
199
+ # A provider can request cancellation without yielding before it returns.
200
+ await asyncio.sleep(0)
201
+ headers = httpx.Headers(extra)
202
+ headers["Accept"] = "application/json"
203
+ headers["User-Agent"] = "inflowpay (python)"
204
+ if content is not None:
205
+ headers["Content-Type"] = "application/json"
206
+ if token is not None:
207
+ if self._options.api_key is not None:
208
+ headers["X-API-KEY"] = token
209
+ else:
210
+ headers["Authorization"] = f"Bearer {token}"
211
+ # A directly constructed Request does not inherit the HTTPX client's cookie jar.
212
+ request = httpx.Request(method, self.base_url + path, content=content, headers=headers)
213
+ try:
214
+ async with asyncio.timeout(self._options.timeout):
215
+ response = await self._http.send(request, follow_redirects=False)
216
+ task = asyncio.current_task()
217
+ assert task is not None
218
+ if task.cancelling():
219
+ await asyncio.sleep(0)
220
+ except (TimeoutError, httpx.TimeoutException):
221
+ return InflowApiError(
222
+ "request timed out",
223
+ code="TIMEOUT",
224
+ http_status=0,
225
+ endpoint=path,
226
+ )
227
+ except (httpx.TransportError, httpx.DecodingError):
228
+ return InflowApiError(
229
+ "network request failed",
230
+ code="NETWORK_ERROR",
231
+ http_status=0,
232
+ endpoint=path,
233
+ )
234
+ if response.is_success:
235
+ return _parse(response)
236
+ return _error(path, response, (token,) if token else ())
237
+
238
+ async def cancel_approval(self, approval_id: str) -> None:
239
+ async def cancel() -> None:
240
+ try:
241
+ async with asyncio.timeout(5):
242
+ await self.request(
243
+ "POST", f"/v1/approvals/{quote(approval_id, safe='')}/cancel"
244
+ )
245
+ except Exception:
246
+ # Cleanup must not replace the payment flow's original failure.
247
+ pass
248
+
249
+ # Unlike Node fire-and-forget, await bounded cleanup before the application exits.
250
+ task = asyncio.create_task(cancel())
251
+ cancelled = False
252
+ while not task.done():
253
+ try:
254
+ await asyncio.shield(task)
255
+ except asyncio.CancelledError:
256
+ cancelled = True
257
+ if cancelled:
258
+ raise asyncio.CancelledError
259
+
260
+
261
+ async def poll(read: Callable[[], Awaitable[tuple[_T, bool, float]]], *, timeout: float) -> _T:
262
+ _positive(timeout)
263
+ # Protocol code interprets states and chooses which read failures may be retried.
264
+ async with asyncio.timeout(timeout):
265
+ while True:
266
+ value, done, interval = await read()
267
+ if done:
268
+ return value
269
+ if not math.isfinite(interval) or interval < 0:
270
+ raise ValueError("poll interval must be finite and nonnegative")
271
+ await asyncio.sleep(interval)
inflowpay/errors.py ADDED
@@ -0,0 +1,21 @@
1
+ from collections.abc import Mapping
2
+
3
+
4
+ class InflowApiError(Exception):
5
+ def __init__(
6
+ self,
7
+ message: str,
8
+ *,
9
+ code: str,
10
+ http_status: int,
11
+ endpoint: str,
12
+ body: object = None,
13
+ headers: Mapping[str, str] | None = None,
14
+ ) -> None:
15
+ super().__init__(message)
16
+ self.code = code
17
+ self.http_status = http_status
18
+ self.endpoint = endpoint
19
+ self.body = body
20
+ self.headers = dict(headers or {})
21
+ self.request_id = self.headers.get("x-request-id")
@@ -0,0 +1,35 @@
1
+ """InFlow MPP wire codecs. Decoding does not authenticate or settle a payment."""
2
+
3
+ from ._pympp import from_pympp_challenge, to_pympp_challenge
4
+ from ._requests import validate_payload, validate_request
5
+ from ._wire import (
6
+ JsonValue,
7
+ MppCodecError,
8
+ WireObject,
9
+ canonicalize,
10
+ decode,
11
+ decode_credential,
12
+ decode_receipt,
13
+ encode,
14
+ parse_challenge_header,
15
+ parse_challenge_headers,
16
+ render_challenge_header,
17
+ )
18
+
19
+ __all__ = [
20
+ "JsonValue",
21
+ "MppCodecError",
22
+ "WireObject",
23
+ "canonicalize",
24
+ "decode",
25
+ "decode_credential",
26
+ "decode_receipt",
27
+ "encode",
28
+ "from_pympp_challenge",
29
+ "parse_challenge_header",
30
+ "parse_challenge_headers",
31
+ "render_challenge_header",
32
+ "to_pympp_challenge",
33
+ "validate_payload",
34
+ "validate_request",
35
+ ]
@@ -0,0 +1,60 @@
1
+ from copy import deepcopy
2
+ from dataclasses import dataclass, field, fields, replace
3
+
4
+ from mpp import Challenge, ChallengeEcho
5
+
6
+ from ._wire import (
7
+ MppCodecError,
8
+ WireObject,
9
+ decode,
10
+ object_value,
11
+ render_challenge_header,
12
+ string,
13
+ validate_challenge,
14
+ )
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class _WireChallenge(Challenge):
19
+ _wire: WireObject = field(default_factory=dict, repr=False, compare=False)
20
+
21
+ def to_echo(self) -> ChallengeEcho:
22
+ if "opaque" not in self._wire:
23
+ return super().to_echo()
24
+ opaque = string(self._wire["opaque"])
25
+ if decode(opaque) != self.opaque:
26
+ raise MppCodecError("pympp opaque data differs from its encoded value")
27
+ # pympp otherwise re-encodes opaque. Keep the original challenge-bound bytes.
28
+ return replace(super().to_echo(), opaque=opaque)
29
+
30
+
31
+ def to_pympp_challenge(value: WireObject) -> Challenge:
32
+ # Parse only at this boundary; the wire object retains extensions that pympp cannot represent.
33
+ validate_challenge(value)
34
+ parsed = Challenge.from_www_authenticate(render_challenge_header(value))
35
+ return _WireChallenge(
36
+ **{item.name: getattr(parsed, item.name) for item in fields(parsed)}, _wire=deepcopy(value)
37
+ )
38
+
39
+
40
+ def from_pympp_challenge(value: Challenge) -> WireObject:
41
+ # request_b64 is signed input. Re-encoding the decoded request can invalidate its binding.
42
+ result: WireObject = {
43
+ **(deepcopy(value._wire) if isinstance(value, _WireChallenge) else {}),
44
+ "id": value.id,
45
+ "realm": value.realm,
46
+ "method": value.method,
47
+ "intent": value.intent,
48
+ "request": value.request_b64,
49
+ }
50
+ for name in ("expires", "description", "digest", "header"):
51
+ item = getattr(value, name)
52
+ if item is not None:
53
+ result[name] = item
54
+ if value.opaque is not None:
55
+ # Converted challenges retain raw opaque bytes; native pympp challenges expose its echo.
56
+ result["opaque"] = value.to_echo().opaque
57
+ validate_challenge(result)
58
+ if object_value(decode(value.request_b64)) != value.request:
59
+ raise ValueError("pympp challenge request differs from its encoded request")
60
+ return deepcopy(result)
@@ -0,0 +1,105 @@
1
+ import math
2
+ import re
3
+ from copy import deepcopy
4
+
5
+ from ._wire import MppCodecError, WireObject, object_value, string, timestamp
6
+
7
+ _UUID = r"[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}"
8
+ _ADDRESS = r"0x[0-9a-fA-F]{40}"
9
+ _MEMO = r"0x[0-9a-fA-F]{64}"
10
+ _INTEGER = r"(?:0|[1-9][0-9]*)"
11
+
12
+
13
+ def _match(value: object, pattern: str) -> str:
14
+ result = string(value)
15
+ if not re.fullmatch(pattern, result):
16
+ raise MppCodecError("Invalid payment field format")
17
+ return result
18
+
19
+
20
+ def _optional_strings(data: WireObject, fields: tuple[str, ...], pattern: str = r"[\s\S]+") -> None:
21
+ for field in fields:
22
+ if field in data:
23
+ _match(data[field], pattern)
24
+
25
+
26
+ def validate_request(method: str, intent: str, value: object) -> WireObject:
27
+ request = object_value(value)
28
+ if method == "inflow" and intent in ("charge", "subscription"):
29
+ amount = _match(request.get("amount"), r"-?[0-9]+(?:\.[0-9]+)?")
30
+ string(request.get("currency"))
31
+ _optional_strings(request, ("recipient",), _UUID)
32
+ if "methodDetails" in request:
33
+ details = object_value(request["methodDetails"])
34
+ if "rail" in details and details["rail"] not in ("balance", "instrument"):
35
+ raise MppCodecError("Unsupported InFlow rail")
36
+ _optional_strings(details, ("instrumentId",), _UUID)
37
+ if intent == "subscription":
38
+ if amount.startswith("-") or not re.search("[1-9]", amount):
39
+ raise MppCodecError("Subscription amount must be positive")
40
+ unit, count = request.get("periodUnit"), request.get("periodCount")
41
+ if unit not in ("minute", "hour", "day", "week", "month", "quarter", "year"):
42
+ raise MppCodecError("Unsupported subscription period")
43
+ if (
44
+ isinstance(count, bool)
45
+ or not isinstance(count, (int, float))
46
+ or not 1 <= count <= 9007199254740991
47
+ or count % 1 != 0
48
+ ):
49
+ raise MppCodecError("periodCount must be a positive safe integer")
50
+ if unit == "minute" and count < 5:
51
+ raise MppCodecError("Minute subscriptions require periodCount of at least five")
52
+ timestamp(request.get("subscriptionExpires"))
53
+ if "externalId" in request:
54
+ external = string(request["externalId"])
55
+ if not external.strip() or len(external.encode("utf-16-le")) // 2 > 128:
56
+ raise MppCodecError("externalId must contain 1 to 128 characters")
57
+ elif method == "tempo" and intent == "charge":
58
+ _match(request.get("amount"), _INTEGER)
59
+ _optional_strings(request, ("currency", "recipient"), _ADDRESS)
60
+ _optional_strings(request, ("description", "externalId"))
61
+ if "methodDetails" in request:
62
+ details = object_value(request["methodDetails"])
63
+ if "chainId" in details:
64
+ chain = details["chainId"]
65
+ if (
66
+ isinstance(chain, bool)
67
+ or not isinstance(chain, (int, float))
68
+ or (isinstance(chain, float) and not math.isfinite(chain))
69
+ ):
70
+ raise MppCodecError("chainId must be a finite number")
71
+ if "feePayer" in details and type(details["feePayer"]) is not bool:
72
+ raise MppCodecError("feePayer must be a boolean")
73
+ _optional_strings(details, ("memo",), _MEMO)
74
+ if "supportedModes" in details:
75
+ modes = details["supportedModes"]
76
+ if not isinstance(modes, list) or any(
77
+ mode not in ("pull", "push") for mode in modes
78
+ ):
79
+ raise MppCodecError("Unsupported Tempo submission mode")
80
+ if "splits" in details:
81
+ splits = details["splits"]
82
+ if not isinstance(splits, list):
83
+ raise MppCodecError("splits must be an array")
84
+ for value in splits:
85
+ split = object_value(value)
86
+ _match(split.get("amount"), _INTEGER)
87
+ _match(split.get("recipient"), _ADDRESS)
88
+ _optional_strings(split, ("memo",), _MEMO)
89
+ else:
90
+ raise MppCodecError("Unsupported payment method or intent")
91
+ return deepcopy(request)
92
+
93
+
94
+ def validate_payload(method: str, value: object) -> WireObject:
95
+ payload = object_value(value)
96
+ if method == "tempo":
97
+ _optional_strings(payload, ("hash", "signature"), r"0x[0-9a-fA-F]+")
98
+ _optional_strings(payload, ("transactionId",))
99
+ kind = payload.get("type")
100
+ if kind not in ("hash", "transaction", "proof"):
101
+ raise MppCodecError("Unsupported Tempo credential type")
102
+ string(payload.get("hash" if kind == "hash" else "signature"))
103
+ elif method != "inflow":
104
+ raise MppCodecError("Unsupported payment method")
105
+ return deepcopy(payload)