solvapay 0.1.0.dev4__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.
solvapay/_http.py ADDED
@@ -0,0 +1,243 @@
1
+ """The HTTP layer under the generated client: auth, timeouts, retries, errors.
2
+
3
+ The generated methods (``_generated/operations.py``) only build a request and
4
+ parse its response. Everything about talking to the API lives here, once,
5
+ for both the async and the blocking client.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import time
12
+ from collections.abc import Mapping
13
+ from importlib.metadata import PackageNotFoundError, version
14
+ from typing import Any, TypeVar
15
+ from urllib.parse import quote
16
+
17
+ import httpx
18
+ from pydantic import BaseModel, ValidationError
19
+
20
+ from .errors import SolvaPayError
21
+
22
+ DEFAULT_API_BASE_URL = "https://api.solvapay.com"
23
+ DEFAULT_TIMEOUT_SECONDS = 30.0
24
+ DEFAULT_MAX_RETRIES = 2
25
+ # Statuses worth a retry: rate limited, or the platform briefly unavailable.
26
+ RETRY_STATUSES = frozenset({429, 502, 503, 504})
27
+
28
+ M = TypeVar("M", bound=BaseModel)
29
+
30
+ try:
31
+ _VERSION = version("solvapay")
32
+ except PackageNotFoundError: # running from a source checkout
33
+ _VERSION = "0.0.0+source"
34
+
35
+ USER_AGENT = f"solvapay-python/{_VERSION}"
36
+
37
+
38
+ def path_segment(value: str) -> str:
39
+ """A path parameter, percent-encoded so a reference can never change the path."""
40
+ if not value:
41
+ raise SolvaPayError("A path parameter is empty", code="invalid_argument")
42
+ return quote(value, safe="")
43
+
44
+
45
+ def encode_body(body: BaseModel | Mapping[str, Any] | None) -> Any:
46
+ """A request body as JSON-ready data.
47
+
48
+ A model is sent with its wire names and only the fields that were set, so
49
+ an explicit ``None`` is sent as ``null`` and an unset field is left out.
50
+ """
51
+ if body is None:
52
+ return None
53
+ if isinstance(body, BaseModel):
54
+ return body.model_dump(mode="json", by_alias=True, exclude_unset=True)
55
+ return dict(body)
56
+
57
+
58
+ def _error_from_response(response: httpx.Response) -> SolvaPayError:
59
+ status = response.status_code
60
+ try:
61
+ body = response.json()
62
+ except ValueError:
63
+ snippet = response.text[:240]
64
+ return SolvaPayError(
65
+ f"The SolvaPay API answered {status} with a body that is not JSON: {snippet}",
66
+ code="non_json_response",
67
+ status=status,
68
+ body=snippet,
69
+ )
70
+ message: Any = body.get("message") if isinstance(body, dict) else None
71
+ if isinstance(message, list):
72
+ message = "; ".join(str(part) for part in message)
73
+ # Only a non-empty string is the API's own code, as in the TypeScript SDK.
74
+ code = body.get("code") if isinstance(body, dict) else None
75
+ return SolvaPayError(
76
+ str(message or f"The SolvaPay API answered {status}"),
77
+ code=code if isinstance(code, str) and code else f"http_{status}",
78
+ status=status,
79
+ body=body,
80
+ )
81
+
82
+
83
+ def _retry_after(response: httpx.Response | None, attempt: int) -> float:
84
+ if response is not None:
85
+ header = response.headers.get("retry-after")
86
+ if header and header.isdigit():
87
+ return min(float(header), 30.0)
88
+ return float(0.5 * 2**attempt)
89
+
90
+
91
+ class _Transport:
92
+ def __init__(
93
+ self,
94
+ *,
95
+ api_key: str,
96
+ api_base_url: str,
97
+ timeout: float,
98
+ max_retries: int,
99
+ ) -> None:
100
+ if not api_key:
101
+ raise SolvaPayError(
102
+ "A SolvaPay secret key is required (SOLVAPAY_SECRET_KEY).", code="missing_secret"
103
+ )
104
+ if max_retries < 0:
105
+ raise SolvaPayError("max_retries cannot be negative", code="invalid_argument")
106
+ self.api_base_url = api_base_url.rstrip("/")
107
+ self.timeout = timeout
108
+ self.max_retries = max_retries
109
+ self._headers = {
110
+ "Authorization": f"Bearer {api_key}",
111
+ "Accept": "application/json",
112
+ "User-Agent": USER_AGENT,
113
+ }
114
+
115
+ def _build(
116
+ self,
117
+ method: str,
118
+ path: str,
119
+ query: Mapping[str, Any],
120
+ headers: Mapping[str, Any],
121
+ body: Any,
122
+ ) -> dict[str, Any]:
123
+ params = {
124
+ key: ("true" if value is True else "false" if value is False else value)
125
+ for key, value in query.items()
126
+ if value is not None
127
+ }
128
+ merged = {**self._headers, **{k: str(v) for k, v in headers.items() if v is not None}}
129
+ return {
130
+ "method": method,
131
+ "url": f"{self.api_base_url}{path}",
132
+ "params": params,
133
+ "headers": merged,
134
+ "json": body,
135
+ }
136
+
137
+ def _retryable(self, request: dict[str, Any]) -> bool:
138
+ """Only requests that are safe to send twice: reads, and writes with an idempotency key."""
139
+ has_key = any(k.lower() == "idempotency-key" for k in request["headers"])
140
+ return request["method"] in {"GET", "HEAD"} or has_key
141
+
142
+ @staticmethod
143
+ def parse(model: type[M], data: Any) -> M:
144
+ try:
145
+ return model.model_validate(data)
146
+ except ValidationError as error:
147
+ raise SolvaPayError(
148
+ f"The SolvaPay API answered with a {model.__name__} this SDK cannot read: {error}",
149
+ code="invalid_response",
150
+ body=data,
151
+ ) from error
152
+
153
+
154
+ class SyncTransport(_Transport):
155
+ """Blocking transport over ``httpx.Client``."""
156
+
157
+ def __init__(self, *, client: httpx.Client | None = None, **options: Any) -> None:
158
+ super().__init__(**options)
159
+ self._client = client or httpx.Client(timeout=self.timeout)
160
+
161
+ def close(self) -> None:
162
+ self._client.close()
163
+
164
+ def request(
165
+ self,
166
+ method: str,
167
+ path: str,
168
+ *,
169
+ query: Mapping[str, Any],
170
+ headers: Mapping[str, Any],
171
+ body: Any,
172
+ ) -> Any:
173
+ request = self._build(method, path, query, headers, body)
174
+ retries = self.max_retries if self._retryable(request) else 0
175
+ for attempt in range(retries + 1):
176
+ try:
177
+ response = self._client.request(**request)
178
+ except httpx.TransportError as error:
179
+ if attempt < retries:
180
+ time.sleep(_retry_after(None, attempt))
181
+ continue
182
+ raise SolvaPayError(
183
+ f"Could not reach the SolvaPay API: {error}", code="network_error"
184
+ ) from error
185
+ if response.status_code in RETRY_STATUSES and attempt < retries:
186
+ time.sleep(_retry_after(response, attempt))
187
+ continue
188
+ return _result(response)
189
+ raise AssertionError("unreachable")
190
+
191
+
192
+ class AsyncTransport(_Transport):
193
+ """Async transport over ``httpx.AsyncClient``."""
194
+
195
+ def __init__(self, *, client: httpx.AsyncClient | None = None, **options: Any) -> None:
196
+ super().__init__(**options)
197
+ self._client = client or httpx.AsyncClient(timeout=self.timeout)
198
+
199
+ async def aclose(self) -> None:
200
+ await self._client.aclose()
201
+
202
+ async def request(
203
+ self,
204
+ method: str,
205
+ path: str,
206
+ *,
207
+ query: Mapping[str, Any],
208
+ headers: Mapping[str, Any],
209
+ body: Any,
210
+ ) -> Any:
211
+ request = self._build(method, path, query, headers, body)
212
+ retries = self.max_retries if self._retryable(request) else 0
213
+ for attempt in range(retries + 1):
214
+ try:
215
+ response = await self._client.request(**request)
216
+ except httpx.TransportError as error:
217
+ if attempt < retries:
218
+ await asyncio.sleep(_retry_after(None, attempt))
219
+ continue
220
+ raise SolvaPayError(
221
+ f"Could not reach the SolvaPay API: {error}", code="network_error"
222
+ ) from error
223
+ if response.status_code in RETRY_STATUSES and attempt < retries:
224
+ await asyncio.sleep(_retry_after(response, attempt))
225
+ continue
226
+ return _result(response)
227
+ raise AssertionError("unreachable")
228
+
229
+
230
+ def _result(response: httpx.Response) -> Any:
231
+ if response.status_code >= 400:
232
+ raise _error_from_response(response)
233
+ if response.status_code == 204 or not response.content:
234
+ return None
235
+ try:
236
+ return response.json()
237
+ except ValueError as error:
238
+ raise SolvaPayError(
239
+ f"The SolvaPay API answered {response.status_code} with a body that is not JSON",
240
+ code="non_json_response",
241
+ status=response.status_code,
242
+ body=response.text[:240],
243
+ ) from error
solvapay/_js.py ADDED
@@ -0,0 +1,114 @@
1
+ """JavaScript value semantics, for code ported from the TypeScript SDK.
2
+
3
+ The paywall code builds its output from JSON with JavaScript's rules: a key
4
+ that is missing (``undefined``) is not the same as a key set to ``null``,
5
+ ``??`` falls through both, and ``0`` and ``""`` are falsy. Porting it with
6
+ these helpers keeps every branch identical, which the conformance suite then
7
+ checks.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import math
13
+ from collections.abc import Mapping
14
+ from decimal import ROUND_HALF_UP, Decimal
15
+ from typing import Any, Final
16
+
17
+
18
+ class _Undefined:
19
+ """JavaScript ``undefined``: a key that is not there. Falsy."""
20
+
21
+ _instance: _Undefined | None = None
22
+
23
+ def __new__(cls) -> _Undefined:
24
+ if cls._instance is None:
25
+ cls._instance = super().__new__(cls)
26
+ return cls._instance
27
+
28
+ def __bool__(self) -> bool:
29
+ return False
30
+
31
+ def __repr__(self) -> str:
32
+ return "undefined"
33
+
34
+
35
+ UNDEFINED: Final = _Undefined()
36
+
37
+
38
+ def get(obj: Any, key: str) -> Any:
39
+ """``obj?.key``: UNDEFINED when obj is null/undefined or the key is missing."""
40
+ if isinstance(obj, Mapping) and key in obj:
41
+ return obj[key]
42
+ return UNDEFINED
43
+
44
+
45
+ def nullish(value: Any) -> bool:
46
+ return value is None or value is UNDEFINED
47
+
48
+
49
+ def coalesce(*values: Any) -> Any:
50
+ """``a ?? b ?? c``: the first value that is neither null nor undefined, else the last."""
51
+ for value in values[:-1]:
52
+ if not nullish(value):
53
+ return value
54
+ return values[-1]
55
+
56
+
57
+ def truthy(value: Any) -> bool:
58
+ if value is UNDEFINED or value is None or value is False:
59
+ return False
60
+ if isinstance(value, bool):
61
+ return True
62
+ if isinstance(value, (int, float)):
63
+ return value != 0 and not math.isnan(value)
64
+ if isinstance(value, str):
65
+ return value != ""
66
+ return True
67
+
68
+
69
+ def number(value: Any) -> float:
70
+ """A value as a JavaScript relational operator sees it: null is 0, undefined is NaN."""
71
+ if value is None:
72
+ return 0.0
73
+ if value is UNDEFINED:
74
+ return math.nan
75
+ if isinstance(value, bool):
76
+ return 1.0 if value else 0.0
77
+ if isinstance(value, (int, float)):
78
+ return float(value)
79
+ return math.nan
80
+
81
+
82
+ def num_str(value: Any) -> str:
83
+ """``String(n)`` for a number: 3.0 prints as "3", as JavaScript does."""
84
+ if isinstance(value, bool):
85
+ return "true" if value else "false"
86
+ if isinstance(value, float):
87
+ if value.is_integer():
88
+ return str(int(value))
89
+ return repr(value)
90
+ if value is None:
91
+ return "null"
92
+ if value is UNDEFINED:
93
+ return "undefined"
94
+ return str(value)
95
+
96
+
97
+ def js_round(value: float) -> int:
98
+ """``Math.round``: halves round toward +infinity."""
99
+ return math.floor(value + 0.5)
100
+
101
+
102
+ def to_locale_string(value: float) -> str:
103
+ """``n.toLocaleString('en-US')``: grouped, at most 3 fraction digits, half away from zero."""
104
+ quantized = Decimal(repr(float(value))).quantize(Decimal("0.001"), rounding=ROUND_HALF_UP)
105
+ sign = "-" if quantized < 0 else ""
106
+ whole, _, fraction = f"{abs(quantized):f}".partition(".")
107
+ fraction = fraction.rstrip("0")
108
+ grouped = f"{int(whole):,}"
109
+ return f"{sign}{grouped}.{fraction}" if fraction else f"{sign}{grouped}"
110
+
111
+
112
+ def compact(items: Mapping[str, Any]) -> dict[str, Any]:
113
+ """An object literal as JSON.stringify writes it: undefined values drop out."""
114
+ return {key: value for key, value in items.items() if value is not UNDEFINED}