diff --git a/.changelog/shared-payment-runtime.md b/.changelog/shared-payment-runtime.md new file mode 100644 index 0000000..32a1d66 --- /dev/null +++ b/.changelog/shared-payment-runtime.md @@ -0,0 +1,5 @@ +--- +pympp: minor +--- + +Added shared client-side primitives for payment-method matching, events, and credential creation. diff --git a/README.md b/README.md index b0d0070..3b5a7ac 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,18 @@ async with Client(methods=[tempo(account=account, intents={"charge": ChargeInten response = await client.get("https://mpp.dev/api/ping/paid") ``` +Custom integrations can share method matching, events, and credential creation: + +```python +from mpp.runtime import PaymentRuntime + +async with PaymentRuntime([method]) as runtime: + challenge, method = runtime.match_challenge(challenges) + credential = await runtime.create_credential(challenge, method) +``` + +The runtime borrows its methods and runs them on the caller's event loop. + ## Examples | Example | Description | diff --git a/src/mpp/client/transport.py b/src/mpp/client/transport.py index 5712f01..c95b987 100644 --- a/src/mpp/client/transport.py +++ b/src/mpp/client/transport.py @@ -11,7 +11,7 @@ import logging from datetime import UTC, datetime -from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable +from typing import TYPE_CHECKING, Any import httpx @@ -28,6 +28,7 @@ EventHandler, Unsubscribe, ) +from mpp.runtime import Method logger = logging.getLogger(__name__) @@ -35,17 +36,6 @@ from collections.abc import Sequence -@runtime_checkable -class Method(Protocol): - """Payment method interface for client-side credential creation.""" - - name: str - - async def create_credential(self, challenge: Challenge) -> Credential: - """Create a credential to satisfy the given challenge.""" - ... - - def _client_payment_failed_payload( *, challenge: Challenge | None, diff --git a/src/mpp/runtime.py b/src/mpp/runtime.py new file mode 100644 index 0000000..c82d0f0 --- /dev/null +++ b/src/mpp/runtime.py @@ -0,0 +1,156 @@ +"""Shared client-side payment primitives.""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from typing import Any, Protocol, Self, runtime_checkable + +from mpp import Challenge, Credential +from mpp.events import ( + CHALLENGE_RECEIVED, + CREDENTIAL_CREATED, + EventDispatcher, + EventPayload, +) + + +@runtime_checkable +class Method(Protocol): + """Client-side payment method.""" + + name: str + + async def create_credential(self, challenge: Challenge) -> Credential: + """Create a credential for a challenge.""" + ... + + +class PaymentRuntime: + """Match methods and create credentials on the caller's event loop. + + Methods are borrowed: the runtime neither enters nor closes them. + """ + + def __init__( + self, + methods: Sequence[Method] = (), + *, + events: EventDispatcher | None = None, + ) -> None: + self.methods = tuple(methods) + for method in self.methods: + if not _is_method(method): + raise TypeError("methods must contain payment Methods") + _method_intents(method) + self.events = events or EventDispatcher() + self._closed = False + + def start(self) -> Self: + """Open the runtime, or return it if already open.""" + if self._closed: + raise RuntimeError("PaymentRuntime is closed") + return self + + def __enter__(self) -> Self: + return self.start() + + def __exit__(self, *_args: Any) -> None: + self.close() + + async def __aenter__(self) -> Self: + return self.start() + + async def __aexit__(self, *_args: Any) -> None: + self.close() + + def match_challenge( + self, + challenges: Sequence[Challenge], + *, + prefer_method_order: bool = True, + allow_name_only: bool = False, + ) -> tuple[Challenge, Method]: + """Return the first compatible challenge and method.""" + self.start() + pairs = ( + ((challenge, method) for method in self.methods for challenge in challenges) + if prefer_method_order + else ((challenge, method) for challenge in challenges for method in self.methods) + ) + for challenge, method in pairs: + if challenge.method == method.name and ( + allow_name_only or challenge.intent in _method_intents(method) + ): + return challenge, method + + offered = [challenge.method for challenge in challenges] + installed = [method.name for method in self.methods] + raise ValueError( + f"No compatible payment method. Server offered: {offered}, client has: {installed}" + ) + + async def create_credential( + self, + challenge: Challenge, + method: Method, + *, + allow_name_only: bool = False, + event_payload: dict[str, Any] | None = None, + ) -> Credential: + """Create a credential and emit its lifecycle events.""" + self.start() + if not any(candidate is method for candidate in self.methods): + raise ValueError("Method is not installed in this PaymentRuntime") + if challenge.method != method.name or ( + not allow_name_only and challenge.intent not in _method_intents(method) + ): + raise ValueError( + f"Method {method.name!r} does not support {challenge.method!r}/{challenge.intent!r}" + ) + payload = { + **(event_payload or {}), + "challenge": challenge, + "method": method, + } + payload.setdefault("challenges", [challenge]) + supplied = await self.events.emit( + CHALLENGE_RECEIVED, + payload, + first_result=True, + ) + credential = ( + supplied + if isinstance(supplied, Credential) + else await method.create_credential(challenge) + ) + await self.events.emit( + CREDENTIAL_CREATED, + {**payload, "credential": credential}, + ) + return credential + + async def emit_event(self, name: str, payload: EventPayload) -> Any: + """Emit an event on the caller's event loop.""" + self.start() + return await self.events.emit(name, payload) + + def close(self) -> None: + """Prevent new runtime operations.""" + self._closed = True + + +def _is_method(value: Any) -> bool: + return isinstance(getattr(value, "name", None), str) and callable( + getattr(value, "create_credential", None) + ) + + +def _method_intents(method: Method) -> Mapping[str, Any]: + intents = getattr(method, "intents", None) + if intents is None: + intents = getattr(method, "_intents", None) + if intents is None: + return {"charge": None} + if not isinstance(intents, Mapping): + raise TypeError("Method intents must be a Mapping") + return intents diff --git a/tests/test_runtime.py b/tests/test_runtime.py new file mode 100644 index 0000000..0a482ca --- /dev/null +++ b/tests/test_runtime.py @@ -0,0 +1,224 @@ +"""Tests for shared client-side payment primitives.""" + +from __future__ import annotations + +import asyncio +from types import MappingProxyType +from typing import Any + +import pytest + +from mpp import Challenge, Credential +from mpp.runtime import PaymentRuntime + + +def challenge( + identifier: str = "test", + *, + method: str = "tempo", + intent: str = "charge", +) -> Challenge: + return Challenge(id=identifier, method=method, intent=intent, request={}) + + +class MockMethod: + name = "tempo" + intents = MappingProxyType({"charge": object()}) + + def __init__(self) -> None: + self.loops: list[asyncio.AbstractEventLoop] = [] + + async def create_credential(self, challenge: Challenge) -> Credential: + self.loops.append(asyncio.get_running_loop()) + return Credential(challenge=challenge.to_echo(), payload={"ok": True}) + + +async def test_credential_creation_and_events_use_caller_loop() -> None: + caller_loop = asyncio.get_running_loop() + method = MockMethod() + runtime = PaymentRuntime([method]) + event_loops: list[asyncio.AbstractEventLoop] = [] + runtime.events.on("*", lambda _event: event_loops.append(asyncio.get_running_loop())) + + credential = await runtime.create_credential(challenge(), method) + + assert credential.payload == {"ok": True} + assert method.loops == [caller_loop] + assert event_loops == [caller_loop, caller_loop] + + +async def test_challenge_handler_can_supply_credential() -> None: + method = MockMethod() + supplied = Credential(challenge=challenge("supplied").to_echo(), payload={"event": True}) + created: list[dict[str, Any]] = [] + runtime = PaymentRuntime([method]) + runtime.events.on("challenge.received", lambda _payload: supplied) + runtime.events.on("credential.created", created.append) + + result = await runtime.create_credential( + challenge("requested"), + method, + event_payload={ + "challenge": "cannot override", + "challenges": [challenge("offered")], + "source": "adapter", + }, + ) + + assert result is supplied + assert method.loops == [] + assert created[0]["challenge"].id == "requested" + assert created[0]["challenges"][0].id == "offered" + assert created[0]["source"] == "adapter" + + +async def test_credential_creation_requires_installed_method_identity() -> None: + class EqualMethod(MockMethod): + def __eq__(self, other: object) -> bool: + return isinstance(other, EqualMethod) + + installed = EqualMethod() + uninstalled = EqualMethod() + events: list[Any] = [] + runtime = PaymentRuntime([installed]) + runtime.events.on("*", events.append) + + with pytest.raises(ValueError, match="not installed"): + await runtime.create_credential(challenge(), uninstalled) + + assert uninstalled == installed + assert uninstalled.loops == [] + assert events == [] + + +@pytest.mark.parametrize( + "incompatible", + [ + challenge(method="stripe"), + challenge(intent="subscription"), + ], +) +async def test_credential_creation_requires_compatible_method( + incompatible: Challenge, +) -> None: + method = MockMethod() + events: list[Any] = [] + runtime = PaymentRuntime([method]) + runtime.events.on("*", events.append) + + with pytest.raises(ValueError, match="does not support"): + await runtime.create_credential(incompatible, method) + + assert method.loops == [] + assert events == [] + + +def test_sync_context_and_close_are_idempotent() -> None: + runtime = PaymentRuntime() + + with runtime as entered: + assert entered is runtime + + runtime.close() + with pytest.raises(RuntimeError, match="closed"): + runtime.start() + with pytest.raises(RuntimeError, match="closed"): + runtime.match_challenge([]) + + +async def test_async_context_closes_runtime() -> None: + runtime = PaymentRuntime() + + async with runtime as entered: + assert entered is runtime + + with pytest.raises(RuntimeError, match="closed"): + await runtime.emit_event("test", {}) + + +async def test_borrowed_methods_are_not_entered_or_closed() -> None: + events: list[str] = [] + + class ManagedMethod(MockMethod): + async def __aenter__(self) -> ManagedMethod: + events.append("enter") + return self + + async def __aexit__(self, *_args: Any) -> None: + events.append("exit") + + method = ManagedMethod() + async with PaymentRuntime([method]) as runtime: + await runtime.create_credential(challenge(), method) + + assert events == [] + + +def test_matching_prefers_method_order_by_default() -> None: + class StripeMethod(MockMethod): + name = "stripe" + + stripe = StripeMethod() + tempo = MockMethod() + offered = [challenge("tempo"), challenge("stripe", method="stripe")] + runtime = PaymentRuntime([stripe, tempo]) + + assert runtime.match_challenge(offered)[1] is stripe + assert runtime.match_challenge(offered, prefer_method_order=False)[1] is tempo + + +def test_matching_uses_intent_capabilities() -> None: + class SubscriptionMethod(MockMethod): + intents = MappingProxyType({"subscription": object()}) + + method = SubscriptionMethod() + runtime = PaymentRuntime([method]) + subscription = challenge(intent="subscription") + + assert runtime.match_challenge([subscription]) == (subscription, method) + with pytest.raises(ValueError, match="No compatible payment method"): + runtime.match_challenge([challenge()]) + + +async def test_legacy_methods_default_to_charge() -> None: + class LegacyMethod: + name = "tempo" + + async def create_credential(self, challenge: Challenge) -> Credential: + return Credential(challenge=challenge.to_echo(), payload={}) + + method = LegacyMethod() + runtime = PaymentRuntime([method]) + + assert runtime.match_challenge([challenge()]) == (challenge(), method) + with pytest.raises(ValueError, match="No compatible payment method"): + runtime.match_challenge([challenge(intent="subscription")]) + matched = runtime.match_challenge( + [challenge(intent="subscription")], + allow_name_only=True, + ) + assert matched[1] is method + assert (await runtime.create_credential(*matched, allow_name_only=True)).payload == {} + + +@pytest.mark.parametrize( + "method, message", + [ + (object(), "payment Methods"), + ( + type( + "InvalidIntentsMethod", + (), + { + "name": "tempo", + "intents": ["charge"], + "create_credential": lambda self, value: None, + }, + )(), + "intents must be a Mapping", + ), + ], +) +def test_invalid_methods_are_rejected(method: object, message: str) -> None: + with pytest.raises(TypeError, match=message): + PaymentRuntime([method]) # type: ignore[list-item]