Skip to content

Commit 03eecba

Browse files
authored
feat(payment)!: create_mppx_server consumes dict[str, *RailSpec] (#43)
## Summary Drop the legacy `MppxRails` / `TempoChargeRail` / `TempoSessionRail` / `StripeRail` config wrappers; `create_mppx_server` now consumes the canonical `*RailSpec` types every other helper (`build_accepted_methods`, `build_how_to_pay`, `mpp_payment_handler`, ...) already takes. ## Migration ```python # before mpp = await create_mppx_server( secret_key=..., rails=MppxRails(tempo=TempoChargeRail(recipient=...)), ) # after mpp = await create_mppx_server( secret_key=..., rails={"tempo": TempoRailSpec(recipient=...)}, ) ``` Keys are rail names (`tempo`, `tempo_session`, `stripe`); values are the matching `*RailSpec`. First resolvable rail in dict-insertion order wins. ## Test plan - [x] `pytest` green (1064 tests pass; coverage 95.24%) - [x] `ty check` clean - [x] `ruff check` clean - [x] Example doc updated
1 parent d8ff934 commit 03eecba

4 files changed

Lines changed: 134 additions & 145 deletions

File tree

agentscore_commerce/payment/__init__.py

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,7 @@
1414
build_payment_headers,
1515
)
1616
from agentscore_commerce.payment.idempotency import build_idempotency_key
17-
from agentscore_commerce.payment.mppx_server import (
18-
MppxRails,
19-
StripeRail,
20-
TempoChargeRail,
21-
TempoSessionRail,
22-
create_mppx_server,
23-
)
17+
from agentscore_commerce.payment.mppx_server import MppxRailSpec, create_mppx_server
2418
from agentscore_commerce.payment.networks import NetworkFamily, network_family, networks
2519
from agentscore_commerce.payment.rail_spec import (
2620
RecipientLike,
@@ -89,7 +83,7 @@
8983
"X402_SUPPORTED_BASE_NETWORKS",
9084
"ClassifiedX402Error",
9185
"CustomScheme",
92-
"MppxRails",
86+
"MppxRailSpec",
9387
"NetworkFamily",
9488
"PaymentHeadersRail",
9589
"PaymentHeadersResult",
@@ -101,11 +95,8 @@
10195
"RecipientLike",
10296
"SignerNetwork",
10397
"SolanaMppRailSpec",
104-
"StripeRail",
10598
"StripeRailSpec",
106-
"TempoChargeRail",
10799
"TempoRailSpec",
108-
"TempoSessionRail",
109100
"TempoSessionRailSpec",
110101
"VerifyX402RequestFailure",
111102
"VerifyX402RequestResult",
Lines changed: 89 additions & 128 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,31 @@
11
"""One-call MPP server setup wrapping the official `pympp` Python package.
22
33
Wires Tempo charge, Tempo session (channel-based for variable-cost /
4-
streaming), and Stripe SPT methods from symbolic rail config — replaces
5-
the boilerplate of constructing each method by hand.
4+
streaming), and Stripe SPT methods from rail specs — replaces the boilerplate
5+
of constructing each method by hand.
66
77
Usage::
88
9-
from agentscore_commerce.payment import create_mppx_server, MppxRails, TempoChargeRail
9+
from agentscore_commerce.payment import (
10+
create_mppx_server,
11+
TempoRailSpec,
12+
StripeRailSpec,
13+
)
1014
1115
mpp = await create_mppx_server(
12-
rails=MppxRails(
13-
tempo=TempoChargeRail(recipient=os.environ["TEMPO_RECIPIENT"]),
14-
stripe=StripeRail(
16+
secret_key=os.environ["MPP_SECRET_KEY"],
17+
rails={
18+
"tempo": TempoRailSpec(recipient=os.environ["TEMPO_RECIPIENT"]),
19+
"stripe": StripeRailSpec(
1520
profile_id=os.environ["STRIPE_PROFILE_ID"],
1621
secret_key=os.environ["STRIPE_SECRET_KEY"],
1722
),
18-
),
19-
secret_key=os.environ["MPP_SECRET_KEY"],
23+
},
2024
)
2125
26+
Keys are rail names (``"tempo"``, ``"tempo_session"``, ``"stripe"``); values are
27+
the canonical ``*RailSpec`` instances every other helper also consumes.
28+
2229
`pympp` is an OPTIONAL peer dependency — install only if you accept MPP rails::
2330
2431
pip install 'pympp[server,tempo,stripe]>=0.6,<1'
@@ -27,86 +34,68 @@
2734
from __future__ import annotations
2835

2936
import importlib
30-
from dataclasses import dataclass
3137
from typing import Any
3238

39+
from agentscore_commerce.payment.rail_spec import (
40+
RecipientLike,
41+
StripeRailSpec,
42+
TempoRailSpec,
43+
TempoSessionRailSpec,
44+
resolve_recipient,
45+
)
3346
from agentscore_commerce.payment.usdc import USDC
3447

48+
MppxRailSpec = TempoRailSpec | TempoSessionRailSpec | StripeRailSpec
3549

36-
@dataclass
37-
class TempoChargeRail:
38-
"""One-shot Tempo USDC charge (intent: ``charge``)."""
39-
40-
recipient: str
41-
"""Tempo wallet address that receives settled funds."""
42-
43-
currency: str | None = None
44-
"""Token contract address. Defaults to USDC on Tempo (selected by ``testnet`` flag)."""
45-
46-
testnet: bool = False
47-
"""Use Tempo testnet (Moderato) instead of mainnet."""
48-
49-
50-
@dataclass
51-
class TempoSessionRail:
52-
"""Tempo session (intent: ``session``) — pay-as-you-go channel.
53-
54-
Used for repeated calls or SSE-streamed responses. Vendor brings their own
55-
``ChannelStore`` and ``escrow_contract`` address.
56-
"""
5750

58-
recipient: str
59-
escrow_contract: str
60-
"""On-chain escrow contract address that holds channel deposits and pays out
61-
cumulative vouchers on settlement. Vendor-deployed."""
62-
63-
store: Any
64-
"""ChannelStore implementation tracking open channels + cumulative voucher state.
65-
Pass an instance of pympp's ``ChannelStore`` interface (in-memory default for
66-
dev or a Postgres/Redis-backed store for production)."""
67-
68-
currency: str | None = None
69-
testnet: bool = False
70-
chains: Any | None = None
71-
"""Optional supported chains; defaults to pympp defaults if omitted."""
72-
73-
74-
@dataclass
75-
class StripeRail:
76-
"""Stripe SPT (Shared Payment Token) rail config.
77-
78-
See :mod:`agentscore_commerce.stripe_multichain` for the multichain
79-
PaymentIntent helpers used alongside this rail.
80-
"""
51+
def _import_optional(module_name: str) -> Any | None:
52+
try:
53+
return importlib.import_module(module_name)
54+
except ImportError:
55+
return None
8156

82-
profile_id: str
83-
secret_key: str
84-
payment_method_types: list[str] | None = None
8557

58+
async def _resolve_recipient_for_method(recipient: RecipientLike) -> str:
59+
return await resolve_recipient(recipient)
8660

87-
@dataclass
88-
class MppxRails:
89-
"""Symbolic rail config for :func:`create_mppx_server`.
9061

91-
Commerce wires the boilerplate (``tempo.charge()``, ``mpp_stripe.charge()``,
92-
etc.) so vendors only declare the rails they accept.
93-
"""
62+
async def _tempo_method(spec: TempoRailSpec) -> Any:
63+
tempo_module = _import_optional("mpp.methods.tempo")
64+
tempo_factory = getattr(tempo_module, "tempo", None) if tempo_module else None
65+
if not callable(tempo_factory):
66+
msg = "pympp[tempo] not installed — run `pip install 'pympp[tempo]'` for Tempo MPP rails."
67+
raise ImportError(msg)
68+
charge_intent_cls = getattr(tempo_module, "ChargeIntent", None) if tempo_module else None
69+
if charge_intent_cls is None:
70+
msg = "pympp[tempo] missing ChargeIntent — upgrade pympp to 0.6+."
71+
raise ImportError(msg)
72+
default_currency = USDC.tempo.testnet.address if spec.testnet else USDC.tempo.mainnet.address
73+
chain_id = 42431 if spec.testnet else (spec.chain_id or 4217)
74+
return tempo_factory(
75+
intents={"charge": charge_intent_cls()},
76+
currency=spec.token or default_currency,
77+
recipient=await _resolve_recipient_for_method(spec.recipient),
78+
chain_id=chain_id,
79+
)
9480

95-
tempo: TempoChargeRail | None = None
96-
tempo_session: TempoSessionRail | None = None
97-
stripe: StripeRail | None = None
9881

82+
async def _stripe_method(spec: StripeRailSpec) -> Any:
83+
from agentscore_commerce.stripe_multichain.mppx_stripe import create_mppx_stripe
9984

100-
def _import_optional(module_name: str) -> Any | None:
101-
try:
102-
return importlib.import_module(module_name)
103-
except ImportError:
104-
return None
85+
if not spec.profile_id or not spec.secret_key:
86+
msg = "StripeRailSpec for create_mppx_server requires both profile_id and secret_key."
87+
raise ValueError(msg)
88+
return await create_mppx_stripe(
89+
profile_id=spec.profile_id,
90+
secret_key=spec.secret_key,
91+
payment_method_types=spec.payment_method_types,
92+
)
10593

10694

10795
async def create_mppx_server(
96+
*,
10897
secret_key: str,
109-
rails: MppxRails | None = None,
98+
rails: dict[str, MppxRailSpec] | None = None,
11099
method: Any = None,
111100
realm: str | None = None,
112101
) -> Any:
@@ -116,71 +105,46 @@ async def create_mppx_server(
116105
``ImportError`` with a guiding install command when ``pympp`` or a per-rail
117106
extra is missing.
118107
119-
Async because Stripe SPT method construction may require an HTTP setup call
120-
to the Stripe API.
108+
``rails`` keys are rail names (``"tempo"``, ``"tempo_session"``, ``"stripe"``);
109+
values are the canonical ``*RailSpec`` instances every other helper also
110+
consumes. Tempo session is reserved for future pympp ``SessionIntent``
111+
support — passing it today raises ``ImportError``.
121112
122-
Note: pympp 0.6 takes a single ``method`` per ``Mpp`` instance (the prior
123-
multi-method ``Mppx`` API was removed). If multiple rails are configured on
124-
``rails``, the first non-None one wins; merchants supporting multiple
125-
distinct methods (e.g. tempo charge + tempo session, or tempo + Stripe SPT)
126-
construct a separate ``Mpp`` instance per method and route by the method
127-
name they detect on the request. Mirrors how pympp 0.6 separates methods.
113+
pympp 0.6 takes a single ``method`` per ``Mpp`` instance. When ``rails`` is
114+
provided, the first resolvable rail in dict-insertion order wins; merchants
115+
supporting multiple distinct methods construct a separate ``Mpp`` per method
116+
and route by name at the request layer.
128117
"""
129-
# The pympp distribution publishes its modules under the top-level `mpp`
130-
# package (the dist name is `pympp` but `import pympp` doesn't resolve —
131-
# only `import mpp`).
132118
pympp = _import_optional("mpp.server")
133119
if pympp is None or not hasattr(pympp, "Mpp"):
134120
msg = "pympp not installed — run `pip install 'pympp[server,tempo,stripe]>=0.6,<1'` to use create_mppx_server."
135121
raise ImportError(msg)
136122

137-
rails_cfg = rails or MppxRails()
138123
resolved_method: Any = method
124+
rails_map: dict[str, MppxRailSpec] = rails or {}
139125

140-
if resolved_method is None and rails_cfg.tempo is not None:
141-
tempo_module = _import_optional("mpp.methods.tempo")
142-
tempo_factory = getattr(tempo_module, "tempo", None) if tempo_module else None
143-
if not callable(tempo_factory):
144-
msg = "pympp[tempo] not installed — run `pip install 'pympp[tempo]'` for Tempo MPP rails."
145-
raise ImportError(msg)
146-
charge_intent_cls = getattr(tempo_module, "ChargeIntent", None) if tempo_module else None
147-
if charge_intent_cls is None:
148-
msg = "pympp[tempo] missing ChargeIntent — upgrade pympp to 0.6+."
149-
raise ImportError(msg)
150-
t = rails_cfg.tempo
151-
default_currency = USDC.tempo.testnet.address if t.testnet else USDC.tempo.mainnet.address
152-
chain_id = 42431 if t.testnet else 4217
153-
resolved_method = tempo_factory(
154-
intents={"charge": charge_intent_cls()},
155-
currency=t.currency or default_currency,
156-
recipient=t.recipient,
157-
chain_id=chain_id,
158-
)
159-
160-
if resolved_method is None and rails_cfg.tempo_session is not None:
161-
# pympp 0.6 has not shipped a session intent factory under the same
162-
# naming. Keep the surface (TempoSessionRail), but vendors must wait
163-
# for pympp to expose ``SessionIntent`` before this branch resolves.
164-
msg = (
165-
"pympp[tempo] session support not available — pympp 0.6 has not "
166-
"shipped a SessionIntent factory yet. Upgrade pympp when it does "
167-
"or pass `method=` directly with a hand-built TempoMethod."
168-
)
169-
raise ImportError(msg)
170-
171-
if resolved_method is None and rails_cfg.stripe is not None:
172-
from agentscore_commerce.stripe_multichain.mppx_stripe import create_mppx_stripe
173-
174-
resolved_method = await create_mppx_stripe(
175-
profile_id=rails_cfg.stripe.profile_id,
176-
secret_key=rails_cfg.stripe.secret_key,
177-
payment_method_types=rails_cfg.stripe.payment_method_types,
178-
)
126+
if resolved_method is None:
127+
for name, spec in rails_map.items():
128+
if isinstance(spec, TempoRailSpec):
129+
resolved_method = await _tempo_method(spec)
130+
break
131+
if isinstance(spec, TempoSessionRailSpec):
132+
msg = (
133+
"pympp[tempo] session support not available — pympp 0.6 has not "
134+
"shipped a SessionIntent factory yet. Upgrade pympp when it does "
135+
"or pass `method=` directly with a hand-built TempoMethod."
136+
)
137+
raise ImportError(msg)
138+
if isinstance(spec, StripeRailSpec):
139+
resolved_method = await _stripe_method(spec)
140+
break
141+
msg = f"create_mppx_server: unsupported rail spec for key {name!r}: {type(spec).__name__}"
142+
raise TypeError(msg)
179143

180144
if resolved_method is None:
181145
msg = (
182-
"create_mppx_server called with no method or rails — pass at least one of "
183-
"`method=`, `rails.tempo`, `rails.tempo_session`, or `rails.stripe`."
146+
"create_mppx_server called with no method or rails — pass `method=` or a "
147+
"non-empty `rails={...}` map keyed by rail name (`tempo`, `tempo_session`, `stripe`)."
184148
)
185149
raise ValueError(msg)
186150

@@ -191,9 +155,6 @@ async def create_mppx_server(
191155

192156

193157
__all__ = [
194-
"MppxRails",
195-
"StripeRail",
196-
"TempoChargeRail",
197-
"TempoSessionRail",
158+
"MppxRailSpec",
198159
"create_mppx_server",
199160
]

examples/variable_cost_merchant.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -94,9 +94,9 @@ async def complete(request: Request):
9494
async def stream(request: Request):
9595
"""MPP tempo session path — agent opens channel, server streams SSE with mid-stream vouchers.
9696
97-
Production wiring: ``mpp = await create_mppx_server(secret_key=MPP_SECRET, rails=MppxRails(
98-
tempo_session=TempoSessionRail(recipient=TEMPO_RECIPIENT, escrow_contract=TEMPO_ESCROW,
99-
store=YourChannelStore())))`` — parse channel state from ``Authorization: Payment``,
97+
Production wiring: ``mpp = await create_mppx_server(secret_key=MPP_SECRET, rails={
98+
"tempo_session": TempoSessionRailSpec(recipient=TEMPO_RECIPIENT, escrow_contract=TEMPO_ESCROW,
99+
store=YourChannelStore())})`` — parse channel state from ``Authorization: Payment``,
100100
emit SSE chunks, request fresh voucher signatures as cumulative cost grows, close
101101
channel on completion.
102102
"""

tests/test_payment_servers.py

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,9 @@
1414
import pytest
1515

1616
from agentscore_commerce.payment import (
17-
MppxRails,
18-
TempoChargeRail,
17+
StripeRailSpec,
18+
TempoRailSpec,
19+
TempoSessionRailSpec,
1920
create_mppx_server,
2021
create_x402_server,
2122
)
@@ -147,7 +148,7 @@ async def test_create_mppx_server_tempo_returns_mpp_instance() -> None:
147148
"""create_mppx_server with a Tempo charge rail returns a configured Mpp."""
148149
server = await create_mppx_server(
149150
secret_key="X" * 32,
150-
rails=MppxRails(tempo=TempoChargeRail(recipient="0x" + "00" * 20, testnet=True)),
151+
rails={"tempo": TempoRailSpec(recipient="0x" + "00" * 20, testnet=True)},
151152
)
152153
assert type(server).__name__ == "Mpp"
153154
# 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:
159160
async def test_create_mppx_server_no_method_or_rails_raises() -> None:
160161
with pytest.raises(ValueError, match="no method or rails"):
161162
await create_mppx_server(secret_key="X" * 32)
163+
164+
165+
@pytest.mark.skipif(not _MPPX_INSTALLED, reason="pympp not installed")
166+
@pytest.mark.asyncio
167+
async def test_create_mppx_server_tempo_session_raises_until_pympp_supports_it() -> None:
168+
with pytest.raises(ImportError, match="SessionIntent"):
169+
await create_mppx_server(
170+
secret_key="X" * 32,
171+
rails={
172+
"tempo_session": TempoSessionRailSpec(
173+
recipient="0x" + "00" * 20,
174+
escrow_contract="0x" + "11" * 20,
175+
store=object(),
176+
),
177+
},
178+
)
179+
180+
181+
@pytest.mark.skipif(not _MPPX_INSTALLED, reason="pympp not installed")
182+
@pytest.mark.asyncio
183+
async def test_create_mppx_server_stripe_requires_secret_key() -> None:
184+
with pytest.raises(ValueError, match="profile_id and secret_key"):
185+
await create_mppx_server(
186+
secret_key="X" * 32,
187+
rails={"stripe": StripeRailSpec(profile_id="profile_x")},
188+
)
189+
190+
191+
@pytest.mark.skipif(not _MPPX_INSTALLED, reason="pympp not installed")
192+
@pytest.mark.asyncio
193+
async def test_create_mppx_server_unknown_rail_spec_raises() -> None:
194+
with pytest.raises(TypeError, match="unsupported rail spec"):
195+
await create_mppx_server(
196+
secret_key="X" * 32,
197+
rails={"weird": "not-a-spec"}, # type: ignore[dict-item]
198+
)

0 commit comments

Comments
 (0)