From 619b9b5bbac7a889dff679f967f330a0ed893dfe Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Thu, 14 May 2026 14:18:13 -0700 Subject: [PATCH] feat(payment)!: create_mppx_server consumes dict[str, *RailSpec] BREAKING: drop MppxRails, TempoChargeRail, TempoSessionRail, StripeRail in favor of the canonical *RailSpec types every other helper already consumes. Migration: create_mppx_server(secret_key=..., rails={ 'tempo': TempoRailSpec(recipient=...), 'stripe': StripeRailSpec(profile_id=..., secret_key=...), }) Single source of truth for rail config across the SDK. --- agentscore_commerce/payment/__init__.py | 13 +- agentscore_commerce/payment/mppx_server.py | 217 +++++++++------------ examples/variable_cost_merchant.py | 6 +- tests/test_payment_servers.py | 43 +++- 4 files changed, 134 insertions(+), 145 deletions(-) diff --git a/agentscore_commerce/payment/__init__.py b/agentscore_commerce/payment/__init__.py index 589eaaa..917b872 100644 --- a/agentscore_commerce/payment/__init__.py +++ b/agentscore_commerce/payment/__init__.py @@ -14,13 +14,7 @@ build_payment_headers, ) from agentscore_commerce.payment.idempotency import build_idempotency_key -from agentscore_commerce.payment.mppx_server import ( - MppxRails, - StripeRail, - TempoChargeRail, - TempoSessionRail, - create_mppx_server, -) +from agentscore_commerce.payment.mppx_server import MppxRailSpec, create_mppx_server from agentscore_commerce.payment.networks import NetworkFamily, network_family, networks from agentscore_commerce.payment.rail_spec import ( RecipientLike, @@ -89,7 +83,7 @@ "X402_SUPPORTED_BASE_NETWORKS", "ClassifiedX402Error", "CustomScheme", - "MppxRails", + "MppxRailSpec", "NetworkFamily", "PaymentHeadersRail", "PaymentHeadersResult", @@ -101,11 +95,8 @@ "RecipientLike", "SignerNetwork", "SolanaMppRailSpec", - "StripeRail", "StripeRailSpec", - "TempoChargeRail", "TempoRailSpec", - "TempoSessionRail", "TempoSessionRailSpec", "VerifyX402RequestFailure", "VerifyX402RequestResult", diff --git a/agentscore_commerce/payment/mppx_server.py b/agentscore_commerce/payment/mppx_server.py index 4bc3f65..964f10d 100644 --- a/agentscore_commerce/payment/mppx_server.py +++ b/agentscore_commerce/payment/mppx_server.py @@ -1,24 +1,31 @@ """One-call MPP server setup wrapping the official `pympp` Python package. Wires Tempo charge, Tempo session (channel-based for variable-cost / -streaming), and Stripe SPT methods from symbolic rail config — replaces -the boilerplate of constructing each method by hand. +streaming), and Stripe SPT methods from rail specs — replaces the boilerplate +of constructing each method by hand. Usage:: - from agentscore_commerce.payment import create_mppx_server, MppxRails, TempoChargeRail + from agentscore_commerce.payment import ( + create_mppx_server, + TempoRailSpec, + StripeRailSpec, + ) mpp = await create_mppx_server( - rails=MppxRails( - tempo=TempoChargeRail(recipient=os.environ["TEMPO_RECIPIENT"]), - stripe=StripeRail( + secret_key=os.environ["MPP_SECRET_KEY"], + rails={ + "tempo": TempoRailSpec(recipient=os.environ["TEMPO_RECIPIENT"]), + "stripe": StripeRailSpec( profile_id=os.environ["STRIPE_PROFILE_ID"], secret_key=os.environ["STRIPE_SECRET_KEY"], ), - ), - secret_key=os.environ["MPP_SECRET_KEY"], + }, ) +Keys are rail names (``"tempo"``, ``"tempo_session"``, ``"stripe"``); values are +the canonical ``*RailSpec`` instances every other helper also consumes. + `pympp` is an OPTIONAL peer dependency — install only if you accept MPP rails:: pip install 'pympp[server,tempo,stripe]>=0.6,<1' @@ -27,86 +34,68 @@ from __future__ import annotations import importlib -from dataclasses import dataclass from typing import Any +from agentscore_commerce.payment.rail_spec import ( + RecipientLike, + StripeRailSpec, + TempoRailSpec, + TempoSessionRailSpec, + resolve_recipient, +) from agentscore_commerce.payment.usdc import USDC +MppxRailSpec = TempoRailSpec | TempoSessionRailSpec | StripeRailSpec -@dataclass -class TempoChargeRail: - """One-shot Tempo USDC charge (intent: ``charge``).""" - - recipient: str - """Tempo wallet address that receives settled funds.""" - - currency: str | None = None - """Token contract address. Defaults to USDC on Tempo (selected by ``testnet`` flag).""" - - testnet: bool = False - """Use Tempo testnet (Moderato) instead of mainnet.""" - - -@dataclass -class TempoSessionRail: - """Tempo session (intent: ``session``) — pay-as-you-go channel. - - Used for repeated calls or SSE-streamed responses. Vendor brings their own - ``ChannelStore`` and ``escrow_contract`` address. - """ - recipient: str - escrow_contract: str - """On-chain escrow contract address that holds channel deposits and pays out - cumulative vouchers on settlement. Vendor-deployed.""" - - store: Any - """ChannelStore implementation tracking open channels + cumulative voucher state. - Pass an instance of pympp's ``ChannelStore`` interface (in-memory default for - dev or a Postgres/Redis-backed store for production).""" - - currency: str | None = None - testnet: bool = False - chains: Any | None = None - """Optional supported chains; defaults to pympp defaults if omitted.""" - - -@dataclass -class StripeRail: - """Stripe SPT (Shared Payment Token) rail config. - - See :mod:`agentscore_commerce.stripe_multichain` for the multichain - PaymentIntent helpers used alongside this rail. - """ +def _import_optional(module_name: str) -> Any | None: + try: + return importlib.import_module(module_name) + except ImportError: + return None - profile_id: str - secret_key: str - payment_method_types: list[str] | None = None +async def _resolve_recipient_for_method(recipient: RecipientLike) -> str: + return await resolve_recipient(recipient) -@dataclass -class MppxRails: - """Symbolic rail config for :func:`create_mppx_server`. - Commerce wires the boilerplate (``tempo.charge()``, ``mpp_stripe.charge()``, - etc.) so vendors only declare the rails they accept. - """ +async def _tempo_method(spec: TempoRailSpec) -> Any: + tempo_module = _import_optional("mpp.methods.tempo") + tempo_factory = getattr(tempo_module, "tempo", None) if tempo_module else None + if not callable(tempo_factory): + msg = "pympp[tempo] not installed — run `pip install 'pympp[tempo]'` for Tempo MPP rails." + raise ImportError(msg) + charge_intent_cls = getattr(tempo_module, "ChargeIntent", None) if tempo_module else None + if charge_intent_cls is None: + msg = "pympp[tempo] missing ChargeIntent — upgrade pympp to 0.6+." + raise ImportError(msg) + default_currency = USDC.tempo.testnet.address if spec.testnet else USDC.tempo.mainnet.address + chain_id = 42431 if spec.testnet else (spec.chain_id or 4217) + return tempo_factory( + intents={"charge": charge_intent_cls()}, + currency=spec.token or default_currency, + recipient=await _resolve_recipient_for_method(spec.recipient), + chain_id=chain_id, + ) - tempo: TempoChargeRail | None = None - tempo_session: TempoSessionRail | None = None - stripe: StripeRail | None = None +async def _stripe_method(spec: StripeRailSpec) -> Any: + from agentscore_commerce.stripe_multichain.mppx_stripe import create_mppx_stripe -def _import_optional(module_name: str) -> Any | None: - try: - return importlib.import_module(module_name) - except ImportError: - return None + if not spec.profile_id or not spec.secret_key: + msg = "StripeRailSpec for create_mppx_server requires both profile_id and secret_key." + raise ValueError(msg) + return await create_mppx_stripe( + profile_id=spec.profile_id, + secret_key=spec.secret_key, + payment_method_types=spec.payment_method_types, + ) async def create_mppx_server( + *, secret_key: str, - rails: MppxRails | None = None, + rails: dict[str, MppxRailSpec] | None = None, method: Any = None, realm: str | None = None, ) -> Any: @@ -116,71 +105,46 @@ async def create_mppx_server( ``ImportError`` with a guiding install command when ``pympp`` or a per-rail extra is missing. - Async because Stripe SPT method construction may require an HTTP setup call - to the Stripe API. + ``rails`` keys are rail names (``"tempo"``, ``"tempo_session"``, ``"stripe"``); + values are the canonical ``*RailSpec`` instances every other helper also + consumes. Tempo session is reserved for future pympp ``SessionIntent`` + support — passing it today raises ``ImportError``. - Note: pympp 0.6 takes a single ``method`` per ``Mpp`` instance (the prior - multi-method ``Mppx`` API was removed). If multiple rails are configured on - ``rails``, the first non-None one wins; merchants supporting multiple - distinct methods (e.g. tempo charge + tempo session, or tempo + Stripe SPT) - construct a separate ``Mpp`` instance per method and route by the method - name they detect on the request. Mirrors how pympp 0.6 separates methods. + pympp 0.6 takes a single ``method`` per ``Mpp`` instance. When ``rails`` is + provided, the first resolvable rail in dict-insertion order wins; merchants + supporting multiple distinct methods construct a separate ``Mpp`` per method + and route by name at the request layer. """ - # The pympp distribution publishes its modules under the top-level `mpp` - # package (the dist name is `pympp` but `import pympp` doesn't resolve — - # only `import mpp`). pympp = _import_optional("mpp.server") if pympp is None or not hasattr(pympp, "Mpp"): msg = "pympp not installed — run `pip install 'pympp[server,tempo,stripe]>=0.6,<1'` to use create_mppx_server." raise ImportError(msg) - rails_cfg = rails or MppxRails() resolved_method: Any = method + rails_map: dict[str, MppxRailSpec] = rails or {} - if resolved_method is None and rails_cfg.tempo is not None: - tempo_module = _import_optional("mpp.methods.tempo") - tempo_factory = getattr(tempo_module, "tempo", None) if tempo_module else None - if not callable(tempo_factory): - msg = "pympp[tempo] not installed — run `pip install 'pympp[tempo]'` for Tempo MPP rails." - raise ImportError(msg) - charge_intent_cls = getattr(tempo_module, "ChargeIntent", None) if tempo_module else None - if charge_intent_cls is None: - msg = "pympp[tempo] missing ChargeIntent — upgrade pympp to 0.6+." - raise ImportError(msg) - t = rails_cfg.tempo - default_currency = USDC.tempo.testnet.address if t.testnet else USDC.tempo.mainnet.address - chain_id = 42431 if t.testnet else 4217 - resolved_method = tempo_factory( - intents={"charge": charge_intent_cls()}, - currency=t.currency or default_currency, - recipient=t.recipient, - chain_id=chain_id, - ) - - if resolved_method is None and rails_cfg.tempo_session is not None: - # pympp 0.6 has not shipped a session intent factory under the same - # naming. Keep the surface (TempoSessionRail), but vendors must wait - # for pympp to expose ``SessionIntent`` before this branch resolves. - msg = ( - "pympp[tempo] session support not available — pympp 0.6 has not " - "shipped a SessionIntent factory yet. Upgrade pympp when it does " - "or pass `method=` directly with a hand-built TempoMethod." - ) - raise ImportError(msg) - - if resolved_method is None and rails_cfg.stripe is not None: - from agentscore_commerce.stripe_multichain.mppx_stripe import create_mppx_stripe - - resolved_method = await create_mppx_stripe( - profile_id=rails_cfg.stripe.profile_id, - secret_key=rails_cfg.stripe.secret_key, - payment_method_types=rails_cfg.stripe.payment_method_types, - ) + if resolved_method is None: + for name, spec in rails_map.items(): + if isinstance(spec, TempoRailSpec): + resolved_method = await _tempo_method(spec) + break + if isinstance(spec, TempoSessionRailSpec): + msg = ( + "pympp[tempo] session support not available — pympp 0.6 has not " + "shipped a SessionIntent factory yet. Upgrade pympp when it does " + "or pass `method=` directly with a hand-built TempoMethod." + ) + raise ImportError(msg) + if isinstance(spec, StripeRailSpec): + resolved_method = await _stripe_method(spec) + break + msg = f"create_mppx_server: unsupported rail spec for key {name!r}: {type(spec).__name__}" + raise TypeError(msg) if resolved_method is None: msg = ( - "create_mppx_server called with no method or rails — pass at least one of " - "`method=`, `rails.tempo`, `rails.tempo_session`, or `rails.stripe`." + "create_mppx_server called with no method or rails — pass `method=` or a " + "non-empty `rails={...}` map keyed by rail name (`tempo`, `tempo_session`, `stripe`)." ) raise ValueError(msg) @@ -191,9 +155,6 @@ async def create_mppx_server( __all__ = [ - "MppxRails", - "StripeRail", - "TempoChargeRail", - "TempoSessionRail", + "MppxRailSpec", "create_mppx_server", ] diff --git a/examples/variable_cost_merchant.py b/examples/variable_cost_merchant.py index 266e5bd..88f4dc6 100644 --- a/examples/variable_cost_merchant.py +++ b/examples/variable_cost_merchant.py @@ -94,9 +94,9 @@ async def complete(request: Request): async def stream(request: Request): """MPP tempo session path — agent opens channel, server streams SSE with mid-stream vouchers. - Production wiring: ``mpp = await create_mppx_server(secret_key=MPP_SECRET, rails=MppxRails( - tempo_session=TempoSessionRail(recipient=TEMPO_RECIPIENT, escrow_contract=TEMPO_ESCROW, - store=YourChannelStore())))`` — parse channel state from ``Authorization: Payment``, + Production wiring: ``mpp = await create_mppx_server(secret_key=MPP_SECRET, rails={ + "tempo_session": TempoSessionRailSpec(recipient=TEMPO_RECIPIENT, escrow_contract=TEMPO_ESCROW, + store=YourChannelStore())})`` — parse channel state from ``Authorization: Payment``, emit SSE chunks, request fresh voucher signatures as cumulative cost grows, close channel on completion. """ diff --git a/tests/test_payment_servers.py b/tests/test_payment_servers.py index b956b4e..23c350e 100644 --- a/tests/test_payment_servers.py +++ b/tests/test_payment_servers.py @@ -14,8 +14,9 @@ import pytest from agentscore_commerce.payment import ( - MppxRails, - TempoChargeRail, + StripeRailSpec, + TempoRailSpec, + TempoSessionRailSpec, create_mppx_server, create_x402_server, ) @@ -147,7 +148,7 @@ async def test_create_mppx_server_tempo_returns_mpp_instance() -> None: """create_mppx_server with a Tempo charge rail returns a configured Mpp.""" server = await create_mppx_server( secret_key="X" * 32, - rails=MppxRails(tempo=TempoChargeRail(recipient="0x" + "00" * 20, testnet=True)), + rails={"tempo": TempoRailSpec(recipient="0x" + "00" * 20, testnet=True)}, ) assert type(server).__name__ == "Mpp" # pympp 0.6 exposes intent-named methods directly (charge, pay, …) on the Mpp instance. @@ -159,3 +160,39 @@ async def test_create_mppx_server_tempo_returns_mpp_instance() -> None: async def test_create_mppx_server_no_method_or_rails_raises() -> None: with pytest.raises(ValueError, match="no method or rails"): await create_mppx_server(secret_key="X" * 32) + + +@pytest.mark.skipif(not _MPPX_INSTALLED, reason="pympp not installed") +@pytest.mark.asyncio +async def test_create_mppx_server_tempo_session_raises_until_pympp_supports_it() -> None: + with pytest.raises(ImportError, match="SessionIntent"): + await create_mppx_server( + secret_key="X" * 32, + rails={ + "tempo_session": TempoSessionRailSpec( + recipient="0x" + "00" * 20, + escrow_contract="0x" + "11" * 20, + store=object(), + ), + }, + ) + + +@pytest.mark.skipif(not _MPPX_INSTALLED, reason="pympp not installed") +@pytest.mark.asyncio +async def test_create_mppx_server_stripe_requires_secret_key() -> None: + with pytest.raises(ValueError, match="profile_id and secret_key"): + await create_mppx_server( + secret_key="X" * 32, + rails={"stripe": StripeRailSpec(profile_id="profile_x")}, + ) + + +@pytest.mark.skipif(not _MPPX_INSTALLED, reason="pympp not installed") +@pytest.mark.asyncio +async def test_create_mppx_server_unknown_rail_spec_raises() -> None: + with pytest.raises(TypeError, match="unsupported rail spec"): + await create_mppx_server( + secret_key="X" * 32, + rails={"weird": "not-a-spec"}, # type: ignore[dict-item] + )