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.
@@ -0,0 +1,105 @@
1
+ """OAuth helpers for a server agents call: its resource URL and the RFC 9728 document.
2
+
3
+ Ported from ``@solvapay/agents``' ``oauth.ts``, which reads URLs as browsers do (the
4
+ WHATWG URL standard); the canonical form below follows it for the URLs a server
5
+ can have.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+ from urllib.parse import quote, urljoin, urlsplit
12
+
13
+ AGENT_SCOPE = "usage"
14
+ METADATA_PREFIX = "/.well-known/oauth-protected-resource"
15
+
16
+ # What the URL standard leaves unescaped in a path and in a query (everything printable
17
+ # but space, ``"``, ``#``, ``<``, ``>``, and ``?``, `````, ``{``, ``}`` in a path or ``'`` in a
18
+ # query); ``%`` stays, so an escape is never escaped twice.
19
+ _PATH_SAFE = "!$%&'()*+,-./:;=@[\\]^_|~"
20
+ _QUERY_SAFE = "!$%&()*+,-./:;=?@[\\]^_`{|}~"
21
+
22
+
23
+ def _remove_dot_segments(path: str) -> str:
24
+ """RFC 3986 section 5.2.4: ``/a/../b/./c`` is ``/b/c``."""
25
+ out: list[str] = []
26
+ segments = path.split("/")
27
+ for i, segment in enumerate(segments):
28
+ last = i == len(segments) - 1
29
+ if segment == ".":
30
+ if last:
31
+ out.append("")
32
+ elif segment == "..":
33
+ if len(out) > 1:
34
+ out.pop()
35
+ if last:
36
+ out.append("")
37
+ else:
38
+ out.append(segment)
39
+ return "/".join(out)
40
+
41
+
42
+ def canonical_resource(resource: str) -> str:
43
+ """The form SolvaPay compares resources in: scheme and host lowercased, default
44
+ port dropped, no trailing slash. A fragment or credentials make it unusable."""
45
+ parts = urlsplit(resource)
46
+ if parts.fragment or parts.username or parts.password:
47
+ raise ValueError(f"{resource} cannot be an OAuth resource: no fragment or credentials")
48
+ if not parts.scheme or not parts.hostname:
49
+ raise ValueError(f"{resource} is not an absolute URL")
50
+ scheme = parts.scheme.lower()
51
+ host = parts.hostname.lower()
52
+ if ":" in host:
53
+ host = f"[{host}]" # an IPv6 address keeps its brackets
54
+ default = {"http": 80, "https": 443}.get(scheme)
55
+ port = f":{parts.port}" if parts.port and parts.port != default else ""
56
+ path = quote(_remove_dot_segments(parts.path or "/"), safe=_PATH_SAFE).rstrip("/")
57
+ query = f"?{quote(parts.query, safe=_QUERY_SAFE)}" if parts.query else ""
58
+ return f"{scheme}://{host}{port}{path}{query}"
59
+
60
+
61
+ def protected_resource_metadata_path(resource: str, metadata_path: str | None = None) -> str:
62
+ """Where the metadata is served: ``metadata_path``, else the RFC 9728 path-suffixed
63
+ form hosts try first, ``/.well-known/oauth-protected-resource/mcp``."""
64
+ if metadata_path:
65
+ return metadata_path
66
+ return f"{METADATA_PREFIX}{urlsplit(canonical_resource(resource)).path}"
67
+
68
+
69
+ def protected_resource_metadata_url(resource: str, metadata_path: str | None = None) -> str:
70
+ """The absolute URL a challenge points hosts at, on the resource's origin."""
71
+ path = protected_resource_metadata_path(resource, metadata_path)
72
+ return urljoin(canonical_resource(resource), path)
73
+
74
+
75
+ def protected_resource_metadata(
76
+ *,
77
+ resource: str,
78
+ authorization_server: str,
79
+ server_name: str | None = None,
80
+ documentation: str | None = None,
81
+ ) -> dict[str, Any]:
82
+ """The RFC 9728 document naming SolvaPay's agent issuer as the authorization server."""
83
+ out: dict[str, Any] = {
84
+ "resource": canonical_resource(resource),
85
+ "authorization_servers": [authorization_server],
86
+ "scopes_supported": [AGENT_SCOPE],
87
+ "bearer_methods_supported": ["header"],
88
+ }
89
+ if server_name:
90
+ out["resource_name"] = server_name
91
+ if documentation:
92
+ out["resource_documentation"] = documentation
93
+ return out
94
+
95
+
96
+ def challenge_header(*, resource: str, description: str, metadata_url: str | None = None) -> str:
97
+ """The ``WWW-Authenticate`` value hosts look for, with ``error``, ``error_description``,
98
+ the metadata URL (default: the path-suffixed form on the resource's origin) and the
99
+ one scope."""
100
+ metadata = metadata_url or protected_resource_metadata_url(resource)
101
+ quoted = description.replace("\\", "\\\\").replace('"', '\\"')
102
+ return (
103
+ f'Bearer error="invalid_token", error_description="{quoted}", '
104
+ f'resource_metadata="{metadata}", scope="{AGENT_SCOPE}"'
105
+ )
@@ -0,0 +1,124 @@
1
+ """Business purchase details, checked and normalised as ``@solvapay/core``'s
2
+ ``validateBusinessDetails`` does (``packages/core/src/business-details.ts``).
3
+
4
+ The tax-ID tables come from the TypeScript source (``_tax_ids.py``, generated
5
+ by ``scripts/tax_ids.mjs``), so both SDKs accept and reject the same input.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ from ._tax_ids import (
13
+ BUYER_COUNTRIES,
14
+ COUNTRY_TO_TAX_ID_TYPE,
15
+ EU_MEMBER_COUNTRIES,
16
+ EU_VAT_FORMAT_BY_COUNTRY,
17
+ TAX_ID_FORMAT_BY_TYPE,
18
+ TAX_ID_TYPES,
19
+ )
20
+ from .errors import SolvaPayError
21
+
22
+ Json = dict[str, Any]
23
+
24
+ CUSTOMER_NAME_MAX = 100
25
+
26
+ __all__ = [
27
+ "TAX_ID_TYPES",
28
+ "is_buyer_country",
29
+ "is_valid_tax_id_for_country",
30
+ "normalize_tax_id",
31
+ "validate_business_details",
32
+ ]
33
+
34
+
35
+ def is_buyer_country(value: str) -> bool:
36
+ """A country Stripe Tax calculates tax for, as a buyer's country."""
37
+ return value.strip().upper() in BUYER_COUNTRIES
38
+
39
+
40
+ def normalize_tax_id(tax_id: str) -> str:
41
+ return "".join(tax_id.strip().upper().split())
42
+
43
+
44
+ def is_valid_tax_id_for_country(country: str, tax_id: str) -> bool:
45
+ tax_id_type = COUNTRY_TO_TAX_ID_TYPE.get(country)
46
+ if tax_id_type is None:
47
+ return False
48
+ normalized = normalize_tax_id(tax_id)
49
+ if tax_id_type == "eu_vat" and country in EU_MEMBER_COUNTRIES:
50
+ return EU_VAT_FORMAT_BY_COUNTRY[country].fullmatch(normalized) is not None
51
+ return TAX_ID_FORMAT_BY_TYPE[tax_id_type].fullmatch(normalized) is not None
52
+
53
+
54
+ def _stripped(value: str | None) -> str:
55
+ return value.strip() if value else ""
56
+
57
+
58
+ def _invalid(message: str) -> SolvaPayError:
59
+ return SolvaPayError(message, code="invalid_business_details", status=400)
60
+
61
+
62
+ def validate_business_details(
63
+ *,
64
+ is_business: bool,
65
+ business_name: str | None = None,
66
+ country: str | None = None,
67
+ customer_country: str | None = None,
68
+ customer_name: str | None = None,
69
+ customer_state: str | None = None,
70
+ customer_postal_code: str | None = None,
71
+ tax_id: str | None = None,
72
+ ) -> Json:
73
+ """The details to send the API, with camelCase keys.
74
+
75
+ Raises ``SolvaPayError`` (code ``invalid_business_details``, status 400)
76
+ with the first problem as its message, the one TypeScript reports.
77
+ """
78
+ if customer_name is not None and len(customer_name) > CUSTOMER_NAME_MAX:
79
+ raise _invalid(f"Too big: expected string to have <={CUSTOMER_NAME_MAX} characters")
80
+
81
+ name = _stripped(customer_name)
82
+ address: Json = {}
83
+ if _stripped(customer_state):
84
+ address["customerState"] = _stripped(customer_state)
85
+ if _stripped(customer_postal_code):
86
+ address["customerPostalCode"] = _stripped(customer_postal_code)
87
+
88
+ if not is_business:
89
+ billing_country = _stripped(customer_country).upper()
90
+ if billing_country and not is_buyer_country(billing_country):
91
+ raise _invalid("Billing country is not supported for tax calculation")
92
+ if billing_country:
93
+ consumer: Json = {"isBusiness": False, "customerCountry": billing_country}
94
+ if name:
95
+ consumer["customerName"] = name
96
+ return {**consumer, **address}
97
+ return {"isBusiness": False, **({"customerName": name} if name else {})}
98
+
99
+ business_country = _stripped(country).upper()
100
+ if not business_country:
101
+ raise _invalid("Country is required")
102
+ if not is_buyer_country(business_country):
103
+ raise _invalid("Country is not supported for business purchases")
104
+ if _stripped(tax_id) and not is_valid_tax_id_for_country(business_country, tax_id or ""):
105
+ raise _invalid(f"Enter a valid tax ID for {business_country}")
106
+
107
+ billing_country = _stripped(customer_country).upper()
108
+ details: Json = {
109
+ "isBusiness": True,
110
+ "country": business_country,
111
+ "customerCountry": billing_country
112
+ if billing_country and is_buyer_country(billing_country)
113
+ else business_country,
114
+ }
115
+ if _stripped(business_name):
116
+ details["businessName"] = _stripped(business_name)
117
+ if _stripped(tax_id):
118
+ details["taxId"] = normalize_tax_id(tax_id or "")
119
+ tax_id_type = COUNTRY_TO_TAX_ID_TYPE.get(business_country)
120
+ if tax_id_type:
121
+ details["taxIdType"] = tax_id_type
122
+ if name:
123
+ details["customerName"] = name
124
+ return {**details, **address}
solvapay/client.py ADDED
@@ -0,0 +1,107 @@
1
+ """The SolvaPay API client: every operation in the spec, async or blocking.
2
+
3
+ ```python
4
+ from solvapay import AsyncApiClient
5
+
6
+ async with AsyncApiClient(api_key="sk_sandbox_...") as api:
7
+ customer = await api.get_customer(reference="cus_123")
8
+ ```
9
+
10
+ The methods are generated from ``openapi/sdk-v1.json``; this module only
11
+ adds construction and lifecycle.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from types import TracebackType
17
+ from typing import Any
18
+
19
+ import httpx
20
+
21
+ from ._generated.operations import AsyncSolvaPayApi, SolvaPayApi
22
+ from ._http import (
23
+ DEFAULT_API_BASE_URL,
24
+ DEFAULT_MAX_RETRIES,
25
+ DEFAULT_TIMEOUT_SECONDS,
26
+ AsyncTransport,
27
+ SyncTransport,
28
+ )
29
+
30
+ __all__ = ["ApiClient", "AsyncApiClient"]
31
+
32
+
33
+ class AsyncApiClient(AsyncSolvaPayApi):
34
+ """Async client. Use it as an async context manager, or call ``aclose()``."""
35
+
36
+ def __init__(
37
+ self,
38
+ *,
39
+ api_key: str,
40
+ api_base_url: str = DEFAULT_API_BASE_URL,
41
+ timeout: float = DEFAULT_TIMEOUT_SECONDS,
42
+ max_retries: int = DEFAULT_MAX_RETRIES,
43
+ http_client: httpx.AsyncClient | None = None,
44
+ ) -> None:
45
+ super().__init__(
46
+ AsyncTransport(
47
+ api_key=api_key,
48
+ api_base_url=api_base_url,
49
+ timeout=timeout,
50
+ max_retries=max_retries,
51
+ client=http_client,
52
+ )
53
+ )
54
+
55
+ @property
56
+ def api_base_url(self) -> str:
57
+ return self._transport.api_base_url
58
+
59
+ async def aclose(self) -> None:
60
+ await self._transport.aclose()
61
+
62
+ async def __aenter__(self) -> AsyncApiClient:
63
+ return self
64
+
65
+ async def __aexit__(
66
+ self,
67
+ exc_type: type[BaseException] | None,
68
+ exc: BaseException | None,
69
+ tb: TracebackType | None,
70
+ ) -> None:
71
+ await self.aclose()
72
+
73
+
74
+ class ApiClient(SolvaPayApi):
75
+ """Blocking client. Use it as a context manager, or call ``close()``."""
76
+
77
+ def __init__(
78
+ self,
79
+ *,
80
+ api_key: str,
81
+ api_base_url: str = DEFAULT_API_BASE_URL,
82
+ timeout: float = DEFAULT_TIMEOUT_SECONDS,
83
+ max_retries: int = DEFAULT_MAX_RETRIES,
84
+ http_client: httpx.Client | None = None,
85
+ ) -> None:
86
+ super().__init__(
87
+ SyncTransport(
88
+ api_key=api_key,
89
+ api_base_url=api_base_url,
90
+ timeout=timeout,
91
+ max_retries=max_retries,
92
+ client=http_client,
93
+ )
94
+ )
95
+
96
+ @property
97
+ def api_base_url(self) -> str:
98
+ return self._transport.api_base_url
99
+
100
+ def close(self) -> None:
101
+ self._transport.close()
102
+
103
+ def __enter__(self) -> ApiClient:
104
+ return self
105
+
106
+ def __exit__(self, *_: Any) -> None:
107
+ self.close()
solvapay/errors.py ADDED
@@ -0,0 +1,33 @@
1
+ """Errors raised by the SolvaPay SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+
8
+ class SolvaPayError(Exception):
9
+ """Every error the SDK raises.
10
+
11
+ Branch on ``code`` (a stable identifier) and ``status`` (the HTTP status,
12
+ when the error came from the API), never on the message, which is for
13
+ people and may change.
14
+ """
15
+
16
+ def __init__(
17
+ self,
18
+ message: str,
19
+ *,
20
+ code: str,
21
+ status: int | None = None,
22
+ body: Any = None,
23
+ ) -> None:
24
+ super().__init__(message)
25
+ self.message = message
26
+ self.code = code
27
+ self.status = status
28
+ self.body = body
29
+
30
+ def __repr__(self) -> str:
31
+ return (
32
+ f"SolvaPayError(code={self.code!r}, status={self.status!r}, message={self.message!r})"
33
+ )
@@ -0,0 +1,90 @@
1
+ """Paid MCP tools for the official MCP Python SDK. Install with ``pip install 'solvapay[mcp]'``."""
2
+
3
+ from .app import (
4
+ BOOTSTRAP_URI,
5
+ DEFAULT_CSP,
6
+ RESOURCE_MIME_TYPE,
7
+ HideToolsByAudience,
8
+ is_chatgpt_request,
9
+ merge_csp,
10
+ widget_html,
11
+ )
12
+ from .auth import SolvaPayTokenVerifier, authorization_server_metadata, oauth_bridge_routes
13
+ from .bootstrap import (
14
+ build_bootstrap_payload,
15
+ derive_default_view,
16
+ enrich_purchase,
17
+ select_active_plan_purchase,
18
+ )
19
+ from .intent import (
20
+ OVERVIEW_MARKDOWN,
21
+ OVERVIEW_URI,
22
+ build_solvapay_prompts,
23
+ narrated_tool_result,
24
+ register_intent_tools,
25
+ tool_error_result,
26
+ )
27
+ from .narrate import (
28
+ NARRATORS,
29
+ NarratorOutput,
30
+ narrate_activate_plan,
31
+ narrate_auto_recharge,
32
+ narrate_manage_account,
33
+ narrate_topup,
34
+ narrate_upgrade,
35
+ ui_placeholder,
36
+ )
37
+ from .response import CustomerSnapshot, Nudge, ResponseContext, ResponseResult
38
+ from .server import SolvaPayMcpServer, create_solvapay_mcp_server
39
+ from .tools import (
40
+ PAID_TOOL_HINT,
41
+ default_customer_ref,
42
+ free_tool_description_suffix,
43
+ register_free_tool,
44
+ register_payable_tool,
45
+ )
46
+ from .transport import TRANSPORT_TOOL_NAMES, register_transport_tools
47
+
48
+ __all__ = [
49
+ "BOOTSTRAP_URI",
50
+ "DEFAULT_CSP",
51
+ "NARRATORS",
52
+ "OVERVIEW_MARKDOWN",
53
+ "OVERVIEW_URI",
54
+ "PAID_TOOL_HINT",
55
+ "RESOURCE_MIME_TYPE",
56
+ "TRANSPORT_TOOL_NAMES",
57
+ "CustomerSnapshot",
58
+ "HideToolsByAudience",
59
+ "NarratorOutput",
60
+ "Nudge",
61
+ "ResponseContext",
62
+ "ResponseResult",
63
+ "SolvaPayMcpServer",
64
+ "SolvaPayTokenVerifier",
65
+ "authorization_server_metadata",
66
+ "build_bootstrap_payload",
67
+ "build_solvapay_prompts",
68
+ "create_solvapay_mcp_server",
69
+ "default_customer_ref",
70
+ "derive_default_view",
71
+ "enrich_purchase",
72
+ "free_tool_description_suffix",
73
+ "is_chatgpt_request",
74
+ "merge_csp",
75
+ "narrate_activate_plan",
76
+ "narrate_auto_recharge",
77
+ "narrate_manage_account",
78
+ "narrate_topup",
79
+ "narrate_upgrade",
80
+ "narrated_tool_result",
81
+ "oauth_bridge_routes",
82
+ "register_free_tool",
83
+ "register_intent_tools",
84
+ "register_payable_tool",
85
+ "register_transport_tools",
86
+ "select_active_plan_purchase",
87
+ "tool_error_result",
88
+ "ui_placeholder",
89
+ "widget_html",
90
+ ]