From fa99df05c26c097fc13a347c416c3f209f3d9368 Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Thu, 14 May 2026 13:48:54 -0700 Subject: [PATCH] refactor(challenge): build_accepted_methods + build_how_to_pay consume *RailSpec; async build_accepted_methods and build_how_to_pay now accept *RailSpec instances directly instead of plain dicts. The helpers are async because RecipientLike supports async factories (used by Stripe-multichain merchants that mint fresh deposit addresses per PaymentIntent). Single canonical type per rail: vendors declare one TempoRailSpec / X402BaseRailSpec / SolanaMppRailSpec / StripeRailSpec / TempoSessionRailSpec and pass the same instance to every helper. No more restating the same recipient across four different per-helper config shapes. Migrates the existing test suite + multi_rail_merchant example inline. Scrubs internal version annotations from rail_spec docstrings in both SDKs as part of the same diff. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../challenge/accepted_methods.py | 93 ++++++++----------- agentscore_commerce/challenge/how_to_pay.py | 52 +++++++---- agentscore_commerce/payment/rail_spec.py | 15 ++- examples/multi_rail_merchant.py | 30 +++--- tests/test_challenge.py | 92 +++++++++++++----- tests/test_coverage_fillers.py | 41 +++++--- 6 files changed, 192 insertions(+), 131 deletions(-) diff --git a/agentscore_commerce/challenge/accepted_methods.py b/agentscore_commerce/challenge/accepted_methods.py index 3b02b95..18c551d 100644 --- a/agentscore_commerce/challenge/accepted_methods.py +++ b/agentscore_commerce/challenge/accepted_methods.py @@ -2,84 +2,71 @@ from typing import Any -_DEFAULT_TEMPO = { - "network": "tempo-mainnet", - "chain_id": 4217, - "token": "0x20C000000000000000000000b9537d11c60E8b50", - "symbol": "USDC.e", - "decimals": 6, -} -_DEFAULT_X402_BASE = { - "network": "eip155:8453", - "chain_id": 8453, - "token": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", - "symbol": "USDC", - "decimals": 6, -} -_DEFAULT_SOLANA_MPP = { - "network": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", - "token": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", - "symbol": "USDC", - "decimals": 6, -} -_DEFAULT_STRIPE_RAILS = ["card", "link", "shared_payment_token"] +from agentscore_commerce.payment.rail_spec import ( + SolanaMppRailSpec, + StripeRailSpec, + TempoRailSpec, + X402BaseRailSpec, + resolve_recipient, +) -def build_accepted_methods( +async def build_accepted_methods( *, - tempo: dict[str, Any] | None = None, - x402_base: dict[str, Any] | None = None, - solana_mpp: dict[str, Any] | None = None, - stripe: dict[str, Any] | None = None, + tempo: TempoRailSpec | None = None, + x402_base: X402BaseRailSpec | None = None, + solana_mpp: SolanaMppRailSpec | None = None, + stripe: StripeRailSpec | None = None, ) -> list[dict[str, Any]]: - """Build the accepted_methods[] array. Each rail entry conditionally included if vendor passed it. + """Build the accepted_methods[] array. - Each rail value is a plain dict. Required key: ``recipient`` (or ``profile_id`` for stripe). - Optional keys override the rail's protocol defaults: ``network``, ``chain_id``, ``token``, - ``symbol``, ``decimals`` (for chain rails) or ``rails`` (for stripe). + Each rail entry is conditionally included when the vendor passed a `*RailSpec` + for that rail. Each spec's `recipient` is resolved via `resolve_recipient` so + per-order factories (e.g. Stripe-multichain mints fresh deposits per + PaymentIntent) flow through identically to static-treasury strings. """ out: list[dict[str, Any]] = [] - if tempo: + if tempo is not None: out.append( { "method": "tempo/charge", - "network": tempo.get("network", _DEFAULT_TEMPO["network"]), - "chain_id": tempo.get("chain_id", _DEFAULT_TEMPO["chain_id"]), - "token": tempo.get("token", _DEFAULT_TEMPO["token"]), - "symbol": tempo.get("symbol", _DEFAULT_TEMPO["symbol"]), - "decimals": tempo.get("decimals", _DEFAULT_TEMPO["decimals"]), - "pay_to": tempo["recipient"], + "network": tempo.network, + "chain_id": tempo.chain_id, + "token": tempo.token, + "symbol": tempo.symbol, + "decimals": tempo.decimals, + "pay_to": await resolve_recipient(tempo.recipient), } ) - if x402_base: + if x402_base is not None: out.append( { "method": "x402/exact", - "network": x402_base.get("network", _DEFAULT_X402_BASE["network"]), - "chain_id": x402_base.get("chain_id", _DEFAULT_X402_BASE["chain_id"]), - "token": x402_base.get("token", _DEFAULT_X402_BASE["token"]), - "symbol": x402_base.get("symbol", _DEFAULT_X402_BASE["symbol"]), - "decimals": x402_base.get("decimals", _DEFAULT_X402_BASE["decimals"]), - "pay_to": x402_base["recipient"], + "network": x402_base.network, + "chain_id": x402_base.chain_id, + "token": x402_base.token, + "symbol": x402_base.symbol, + "decimals": x402_base.decimals, + "pay_to": await resolve_recipient(x402_base.recipient), } ) - if solana_mpp: + if solana_mpp is not None: out.append( { "method": "x402/exact", - "network": solana_mpp.get("network", _DEFAULT_SOLANA_MPP["network"]), - "token": solana_mpp.get("token", _DEFAULT_SOLANA_MPP["token"]), - "symbol": solana_mpp.get("symbol", _DEFAULT_SOLANA_MPP["symbol"]), - "decimals": solana_mpp.get("decimals", _DEFAULT_SOLANA_MPP["decimals"]), - "pay_to": solana_mpp["recipient"], + "network": solana_mpp.network, + "token": solana_mpp.token, + "symbol": solana_mpp.symbol, + "decimals": solana_mpp.decimals, + "pay_to": await resolve_recipient(solana_mpp.recipient), } ) - if stripe: + if stripe is not None: out.append( { "method": "stripe/charge", - "rails": stripe.get("rails", _DEFAULT_STRIPE_RAILS), - "profile_id": stripe.get("profile_id"), + "rails": list(stripe.rails), + "profile_id": stripe.profile_id, } ) return out diff --git a/agentscore_commerce/challenge/how_to_pay.py b/agentscore_commerce/challenge/how_to_pay.py index 9d1e6c6..0d16d6e 100644 --- a/agentscore_commerce/challenge/how_to_pay.py +++ b/agentscore_commerce/challenge/how_to_pay.py @@ -3,9 +3,12 @@ import math from typing import Any -_DEFAULT_TEMPO = {"network_name": "tempo-mainnet", "chain_id": 4217, "recommend": "both"} -_DEFAULT_X402_BASE = {"network": "eip155:8453"} -_DEFAULT_SOLANA_MPP = {"network": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp"} +from agentscore_commerce.payment.rail_spec import ( + SolanaMppRailSpec, + StripeRailSpec, + TempoRailSpec, + X402BaseRailSpec, +) TEMPO_SETUP = [ "curl -fsSL https://tempo.xyz/install | bash", @@ -25,12 +28,21 @@ ] -def build_how_to_pay( +class HowToPayRails(dict): + """`build_how_to_pay`'s `rails` map. + + Dict keyed by rail name where each value is the matching `*RailSpec`. Subclasses + `dict` so existing callers' literal-dict construction keeps working without + import churn while still benefiting from the type alias when annotated explicitly. + """ + + +async def build_how_to_pay( *, url: str, retry_body_json: str, total_usd: float | str, - rails: dict[str, dict[str, Any]], + rails: dict[str, TempoRailSpec | X402BaseRailSpec | SolanaMppRailSpec | StripeRailSpec], op_token_placeholder: str = "", # noqa: S107 — literal placeholder, not a secret max_spend: float | str | None = None, ) -> dict[str, Any]: @@ -38,9 +50,11 @@ def build_how_to_pay( Generates per-rail setup/command/what_it_does so agents see concrete commands per rail in the 402 body. ``rails`` is a dict keyed by rail name (``"tempo"``, ``"x402_base"``, ``"solana_mpp"``, ``"stripe"``); - each value is a plain dict carrying the rail's config (``recipient`` for chain rails, ``profile_id`` - + ``product_name`` for stripe, plus the protocol-default overrides ``network`` / ``network_name`` / - ``chain_id`` / ``recommend``). Pass ``rails={}`` to emit no per-rail block. + each value is the matching `*RailSpec` instance. Pass ``rails={}`` to emit no per-rail block. + + `recipient` resolution: only `accepted_methods` consumes the resolved address; `how_to_pay` + surfaces commands the agent runs, none of which include the recipient string. So this builder + does NOT resolve `RecipientLike` factories. """ total_num = float(total_usd) if isinstance(total_usd, str) else total_usd max_spend_str = str(max_spend) if max_spend is not None else f"{math.ceil(total_num) + 1:.2f}" @@ -48,10 +62,10 @@ def build_how_to_pay( block: dict[str, Any] = {} tempo = rails.get("tempo") - if tempo: - network_name = tempo.get("network_name", _DEFAULT_TEMPO["network_name"]) - chain_id = tempo.get("chain_id", _DEFAULT_TEMPO["chain_id"]) - recommend = tempo.get("recommend", _DEFAULT_TEMPO["recommend"]) + if isinstance(tempo, TempoRailSpec): + network_name = "tempo-testnet" if tempo.testnet else tempo.network + chain_id = tempo.chain_id + recommend = tempo.recommend tempo_command = ( f"tempo request -X POST -H 'X-Operator-Token: {op_token}' -H 'Content-Type: application/json' " f"--json '{retry_body_json}' --max-spend {max_spend_str} {url}" @@ -80,8 +94,8 @@ def build_how_to_pay( block["tempo"] = entry x402_base = rails.get("x402_base") - if x402_base: - network = x402_base.get("network", _DEFAULT_X402_BASE["network"]) + if isinstance(x402_base, X402BaseRailSpec): + network = x402_base.network block["x402_base"] = { "setup": PAY_SETUP_BASE, "prerequisite": ( @@ -100,8 +114,8 @@ def build_how_to_pay( } solana_mpp = rails.get("solana_mpp") - if solana_mpp: - network = solana_mpp.get("network", _DEFAULT_SOLANA_MPP["network"]) + if isinstance(solana_mpp, SolanaMppRailSpec): + network = solana_mpp.network block["solana_mpp"] = { "setup": PAY_SETUP_SOLANA, "prerequisite": ( @@ -120,9 +134,9 @@ def build_how_to_pay( } stripe = rails.get("stripe") - if stripe: - profile_id = stripe.get("profile_id") - product_name = stripe.get("product_name") or "this purchase" + if isinstance(stripe, StripeRailSpec): + profile_id = stripe.profile_id + product_name = stripe.product_name or "this purchase" amount_cents = round(total_num * 100) link_cli_blocked = amount_cents > 50000 spt_context = ( diff --git a/agentscore_commerce/payment/rail_spec.py b/agentscore_commerce/payment/rail_spec.py index 0a362f4..8c2bcdd 100644 --- a/agentscore_commerce/payment/rail_spec.py +++ b/agentscore_commerce/payment/rail_spec.py @@ -1,10 +1,10 @@ """Canonical `*RailSpec` types — one shape per rail, consumed by every helper. -Pre-304 a merchant accepting Tempo + Base + Solana + Stripe restated the same -recipient four times in four different shapes (`build_accepted_methods`, -`build_how_to_pay`, `mpp_payment_handler`, `create_mppx_server` each had its own -per-rail config). This module unifies those into one `*RailSpec` per rail; every -helper accepts the same instance. +A merchant accepting Tempo + Base + Solana + Stripe declares one `*RailSpec` +per rail and passes it to every helper (`build_accepted_methods`, +`build_how_to_pay`, `mpp_payment_handler`, `create_mppx_server`, ...). One +canonical shape per rail means the recipient address, network identifier, and +token defaults are declared once and reused everywhere. `RecipientLike` is polymorphic over `str | Callable[[], Awaitable[str]]` so per-order recipients (Stripe-multichain mints fresh deposit addresses per @@ -29,9 +29,8 @@ async def resolve_recipient(r: RecipientLike) -> str: """Resolve a `RecipientLike` to a concrete address string. Accepts a string (returned verbatim), a sync callable (called once), or an - async callable (awaited once). The orchestrator (TEC-305) calls this once - per session and caches the resolved value; helpers within a session never - re-invoke the factory. + async callable (awaited once). Helpers call this on every invocation; + callers that want once-per-session resolution should cache externally. """ if isinstance(r, str): return r diff --git a/examples/multi_rail_merchant.py b/examples/multi_rail_merchant.py index 74deaf6..58b2892 100644 --- a/examples/multi_rail_merchant.py +++ b/examples/multi_rail_merchant.py @@ -51,6 +51,10 @@ from agentscore_commerce.identity.fastapi import AgentScoreGate, get_agentscore_data from agentscore_commerce.payment import ( USDC, + SolanaMppRailSpec, + StripeRailSpec, + TempoRailSpec, + X402BaseRailSpec, build_x402_accepts_for_402, networks, process_x402_settle, @@ -186,22 +190,24 @@ async def purchase(request: Request, assess: dict = Depends(get_agentscore_data) # WWW-Auth + adds x402's PAYMENT-REQUIRED): pympx_challenge_headers = {"www-authenticate": 'Payment id="..."'} # from pympp.compose deposit_addresses = {"tempo": "0x...", "base": "0x...", "solana": "..."} # from create_multichain_payment_intent - accepted = build_accepted_methods( - tempo={"recipient": deposit_addresses["tempo"]}, - x402_base={"recipient": deposit_addresses["base"]}, - solana_mpp={"recipient": deposit_addresses["solana"]}, - stripe={"profile_id": os.environ["STRIPE_PROFILE_ID"]}, + # Declare every rail once — every helper consumes the same RailSpec instances. + rails = { + "tempo": TempoRailSpec(recipient=deposit_addresses["tempo"]), + "x402_base": X402BaseRailSpec(recipient=deposit_addresses["base"]), + "solana_mpp": SolanaMppRailSpec(recipient=deposit_addresses["solana"]), + "stripe": StripeRailSpec(profile_id=os.environ["STRIPE_PROFILE_ID"]), + } + accepted = await build_accepted_methods( + tempo=rails["tempo"], + x402_base=rails["x402_base"], + solana_mpp=rails["solana_mpp"], + stripe=rails["stripe"], ) - how_to_pay = build_how_to_pay( + how_to_pay = await build_how_to_pay( url=APP_URL, retry_body_json=str(body), total_usd=total_usd, - rails={ - "tempo": {"recipient": deposit_addresses["tempo"]}, - "x402_base": {"recipient": deposit_addresses["base"]}, - "solana_mpp": {"recipient": deposit_addresses["solana"]}, - "stripe": {"profile_id": os.environ["STRIPE_PROFILE_ID"]}, - }, + rails=rails, ) result = respond_402( diff --git a/tests/test_challenge.py b/tests/test_challenge.py index 1948a38..297a3ab 100644 --- a/tests/test_challenge.py +++ b/tests/test_challenge.py @@ -1,3 +1,5 @@ +import pytest + from agentscore_commerce.challenge import ( PricingBlock, SignerMatchResult, @@ -8,12 +10,19 @@ build_how_to_pay, build_identity_metadata, ) +from agentscore_commerce.payment import ( + SolanaMppRailSpec, + StripeRailSpec, + TempoRailSpec, + X402BaseRailSpec, +) -def test_build_accepted_methods_includes_only_provided_rails(): - out = build_accepted_methods( - tempo={"recipient": "0xT"}, - stripe={"profile_id": "acct_x"}, +@pytest.mark.asyncio +async def test_build_accepted_methods_includes_only_provided_rails(): + out = await build_accepted_methods( + tempo=TempoRailSpec(recipient="0xT"), + stripe=StripeRailSpec(profile_id="acct_x"), ) methods = [e["method"] for e in out] assert "tempo/charge" in methods @@ -21,27 +30,45 @@ def test_build_accepted_methods_includes_only_provided_rails(): assert all(e["method"] != "x402/exact" for e in out) -def test_build_accepted_methods_full_set(): - out = build_accepted_methods( - tempo={"recipient": "0xT"}, - x402_base={"recipient": "0xB"}, - solana_mpp={"recipient": "solanaaddr"}, - stripe={"profile_id": "acct_x"}, +@pytest.mark.asyncio +async def test_build_accepted_methods_full_set(): + out = await build_accepted_methods( + tempo=TempoRailSpec(recipient="0xT"), + x402_base=X402BaseRailSpec(recipient="0xB"), + solana_mpp=SolanaMppRailSpec(recipient="solanaaddr"), + stripe=StripeRailSpec(profile_id="acct_x"), ) assert len(out) == 4 assert out[1]["pay_to"] == "0xB" assert out[2]["network"].startswith("solana:") -def test_build_accepted_methods_overrides_defaults(): - """Per-rail dict values override the rail's protocol defaults inline.""" - out = build_accepted_methods( - tempo={"recipient": "0xT", "network": "tempo-testnet", "chain_id": 42431}, +@pytest.mark.asyncio +async def test_build_accepted_methods_overrides_defaults(): + """Per-rail spec fields override the rail's protocol defaults.""" + out = await build_accepted_methods( + tempo=TempoRailSpec(recipient="0xT", network="tempo-testnet", chain_id=42431), ) assert out[0]["network"] == "tempo-testnet" assert out[0]["chain_id"] == 42431 +@pytest.mark.asyncio +async def test_build_accepted_methods_recipient_factory_called_per_helper(): + """Async recipient factory resolves to a fresh address on each invocation.""" + calls = 0 + + async def factory() -> str: + nonlocal calls + calls += 1 + return f"0xfresh-{calls}" + + first = await build_accepted_methods(tempo=TempoRailSpec(recipient=factory)) + second = await build_accepted_methods(tempo=TempoRailSpec(recipient=factory)) + assert first[0]["pay_to"] == "0xfresh-1" + assert second[0]["pay_to"] == "0xfresh-2" + + def test_build_identity_metadata_wallet_mode_emits_signer_constraint(): md = build_identity_metadata(mode="wallet", wallet="0xClaim", linked_wallets=["0xSibling"]) assert md["identity_mode"] == "wallet" @@ -64,15 +91,16 @@ def test_build_identity_metadata_token_mode_only_returns_mode(): assert md == {"identity_mode": "operator_token"} -def test_build_how_to_pay_emits_per_rail_blocks(): - out = build_how_to_pay( +@pytest.mark.asyncio +async def test_build_how_to_pay_emits_per_rail_blocks(): + out = await build_how_to_pay( url="https://ex.com/buy", retry_body_json='{"x":1}', total_usd=10.0, rails={ - "tempo": {"recipient": "0xT"}, - "x402_base": {"recipient": "0xB"}, - "stripe": {"profile_id": "acct_x"}, + "tempo": TempoRailSpec(recipient="0xT"), + "x402_base": X402BaseRailSpec(recipient="0xB"), + "stripe": StripeRailSpec(profile_id="acct_x"), }, ) assert "tempo" in out @@ -83,29 +111,43 @@ def test_build_how_to_pay_emits_per_rail_blocks(): assert "setup_link_cli" in out["stripe"] -def test_build_how_to_pay_blocks_link_cli_above_500(): - out = build_how_to_pay( +@pytest.mark.asyncio +async def test_build_how_to_pay_blocks_link_cli_above_500(): + out = await build_how_to_pay( url="https://ex.com/buy", retry_body_json="{}", total_usd=750.0, - rails={"stripe": {"profile_id": "acct_x"}}, + rails={"stripe": StripeRailSpec(profile_id="acct_x")}, ) assert "note" in out["stripe"] assert "setup_link_cli" not in out["stripe"] -def test_build_how_to_pay_recommend_kwarg_switches_command(): +@pytest.mark.asyncio +async def test_build_how_to_pay_recommend_kwarg_switches_command(): """`recommend='agentscore-pay'` puts the pay CLI command as primary; `'tempo'` uses tempo request.""" - out = build_how_to_pay( + out = await build_how_to_pay( url="https://ex.com/buy", retry_body_json="{}", total_usd=5.0, - rails={"tempo": {"recipient": "0xT", "recommend": "agentscore-pay"}}, + rails={"tempo": TempoRailSpec(recipient="0xT", recommend="agentscore-pay")}, ) assert "agentscore-pay pay POST" in out["tempo"]["command"] assert "tempo request" in out["tempo"]["alternative_command"] +@pytest.mark.asyncio +async def test_build_how_to_pay_testnet_flag_swaps_network_name(): + """`TempoRailSpec(testnet=True)` surfaces 'tempo-testnet' in the prerequisite copy.""" + out = await build_how_to_pay( + url="https://ex.com/buy", + retry_body_json="{}", + total_usd=5.0, + rails={"tempo": TempoRailSpec(recipient="0xT", testnet=True)}, + ) + assert "tempo-testnet" in out["tempo"]["prerequisite"] + + def test_build_agent_instructions_uses_defaults(): out = build_agent_instructions(how_to_pay={"tempo": {}}) assert out["timeout_seconds"] == 300 diff --git a/tests/test_coverage_fillers.py b/tests/test_coverage_fillers.py index cc0cd18..a449f1b 100644 --- a/tests/test_coverage_fillers.py +++ b/tests/test_coverage_fillers.py @@ -1,5 +1,7 @@ """Targeted tests covering optional-field branches across discovery + challenge builders.""" +import pytest + from agentscore_commerce.challenge import ( build_402_body, build_accepted_methods, @@ -13,6 +15,10 @@ llms_txt_identity_section, llms_txt_payment_section, ) +from agentscore_commerce.payment import ( + SolanaMppRailSpec, + TempoRailSpec, +) def test_bazaar_payload_includes_all_optional_fields(): @@ -94,50 +100,57 @@ def test_llms_txt_payment_section_includes_all_rails(): assert "Stripe Shared Payment Token" in section -def test_build_accepted_methods_includes_solana_only(): - out = build_accepted_methods(solana_mpp={"recipient": "solanaaddr"}) +@pytest.mark.asyncio +async def test_build_accepted_methods_includes_solana_only(): + out = await build_accepted_methods(solana_mpp=SolanaMppRailSpec(recipient="solanaaddr")) assert out[0]["network"].startswith("solana:") assert out[0]["pay_to"] == "solanaaddr" -def test_build_how_to_pay_solana_only(): - out = build_how_to_pay( +@pytest.mark.asyncio +async def test_build_how_to_pay_solana_only(): + out = await build_how_to_pay( url="https://ex.com", retry_body_json="{}", total_usd=5.0, - rails={"solana_mpp": {"recipient": "solanaaddr"}}, + rails={"solana_mpp": SolanaMppRailSpec(recipient="solanaaddr")}, ) assert "solana_mpp" in out assert "agentscore-pay pay POST" in out["solana_mpp"]["command"] -def test_build_how_to_pay_tempo_recommend_pay(): - out = build_how_to_pay( +@pytest.mark.asyncio +async def test_build_how_to_pay_tempo_recommend_pay(): + out = await build_how_to_pay( url="https://ex.com", retry_body_json="{}", total_usd=5.0, - rails={"tempo": {"recipient": "0xT", "recommend": "agentscore-pay"}}, + rails={"tempo": TempoRailSpec(recipient="0xT", recommend="agentscore-pay")}, ) assert out["tempo"]["command"].startswith("agentscore-pay pay POST") assert out["tempo"]["alternative_command"].startswith("tempo request") -def test_build_how_to_pay_tempo_recommend_tempo_only(): - out = build_how_to_pay( +@pytest.mark.asyncio +async def test_build_how_to_pay_tempo_recommend_tempo_only(): + out = await build_how_to_pay( url="https://ex.com", retry_body_json="{}", total_usd=5.0, - rails={"tempo": {"recipient": "0xT", "recommend": "tempo"}}, + rails={"tempo": TempoRailSpec(recipient="0xT", recommend="tempo")}, ) assert "alternative_command" not in out["tempo"] -def test_build_how_to_pay_stripe_no_profile_id_skips_link_cli(): - out = build_how_to_pay( +@pytest.mark.asyncio +async def test_build_how_to_pay_stripe_no_profile_id_skips_link_cli(): + from agentscore_commerce.payment import StripeRailSpec + + out = await build_how_to_pay( url="https://ex.com", retry_body_json="{}", total_usd=5.0, - rails={"stripe": {"profile_id": None}}, + rails={"stripe": StripeRailSpec(profile_id=None)}, ) assert "setup_link_cli" not in out["stripe"] assert "note" not in out["stripe"]