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/__init__.py +26 -0
- solvapay/_currencies.py +170 -0
- solvapay/_generated/__init__.py +1 -0
- solvapay/_generated/models.py +3643 -0
- solvapay/_generated/operations.py +1894 -0
- solvapay/_http.py +243 -0
- solvapay/_js.py +114 -0
- solvapay/_tax_ids.py +442 -0
- solvapay/agents/__init__.py +67 -0
- solvapay/agents/calls.py +302 -0
- solvapay/agents/identity.py +293 -0
- solvapay/agents/mcp.py +614 -0
- solvapay/agents/oauth.py +105 -0
- solvapay/business_details.py +124 -0
- solvapay/client.py +107 -0
- solvapay/errors.py +33 -0
- solvapay/mcp/__init__.py +90 -0
- solvapay/mcp/app.py +243 -0
- solvapay/mcp/auth.py +604 -0
- solvapay/mcp/bootstrap.py +494 -0
- solvapay/mcp/intent.py +474 -0
- solvapay/mcp/mcp-app.html +119 -0
- solvapay/mcp/narrate.py +1019 -0
- solvapay/mcp/response.py +220 -0
- solvapay/mcp/server.py +343 -0
- solvapay/mcp/tools.py +273 -0
- solvapay/mcp/transport.py +550 -0
- solvapay/money.py +61 -0
- solvapay/paywall/__init__.py +25 -0
- solvapay/paywall/decide.py +450 -0
- solvapay/paywall/gate.py +176 -0
- solvapay/paywall/state.py +371 -0
- solvapay/pricing.py +281 -0
- solvapay/py.typed +0 -0
- solvapay/solvapay.py +173 -0
- solvapay/webhooks.py +93 -0
- solvapay-0.1.0.dev4.dist-info/METADATA +183 -0
- solvapay-0.1.0.dev4.dist-info/RECORD +39 -0
- solvapay-0.1.0.dev4.dist-info/WHEEL +4 -0
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}
|