Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changelog/httpx-client-adapters.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
pympp: minor
---

Add per-client HTTPX adapters with automatic 402 payment handling and a tested
HTTPX 0.27/0.28 compatibility matrix.
5 changes: 5 additions & 0 deletions .changelog/httpx-instrumentation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
pympp: minor
---

Add scoped HTTPX instrumentation for payment-aware Python harnesses.
5 changes: 5 additions & 0 deletions .changelog/quick-crows-swim.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
pympp: minor
---

Added an owned-loop `PaymentRuntime` with method factories, sync and async lifecycle contexts, shared events, and sync/async credential creation for custom integrations.
5 changes: 5 additions & 0 deletions .changelog/runtime-async-http.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
pympp: minor
---

Let asynchronous HTTP clients share a `PaymentRuntime`, with origin policy and fail-closed protection for uncertain payment outcomes.
6 changes: 6 additions & 0 deletions .changelog/runtime-mcp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
pympp: minor
---

Let explicit MCP clients share a `PaymentRuntime`, including origin policy,
payment events, and fail-closed protection for uncertain payment outcomes.
5 changes: 5 additions & 0 deletions .changelog/sync-payment-transport.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
pympp: minor
---

Add `SyncPaymentTransport` for payment-aware synchronous HTTPX clients.
63 changes: 61 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ on:
push:
branches: [main, master]
pull_request:
branches: [main, master]

permissions:
contents: read
Expand All @@ -17,7 +16,7 @@ jobs:
ci-gate:
name: CI Gate
if: always()
needs: [test, integration, package, install-smoke]
needs: [test, httpx-adapter, mcp-compat, integration, package, install-smoke]
runs-on: ubuntu-latest
steps:
- env:
Expand Down Expand Up @@ -90,6 +89,64 @@ jobs:
htmlcov/
if-no-files-found: ignore

httpx-adapter:
name: HTTPX adapter (${{ matrix.httpx-version }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
httpx-version: ["0.27.0", "0.27.1", "0.27.2", "0.28.0", "0.28.1"]

steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false

- name: Install uv
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
enable-cache: true
cache-dependency-glob: "pyproject.toml"

- name: Set up Python
run: uv python install 3.12

- name: Install dependencies
run: |
uv venv --python 3.12
uv pip install -e . "httpx==$HTTPX_VERSION" "pytest>=8" "pytest-asyncio>=0.24"
env:
HTTPX_VERSION: ${{ matrix.httpx-version }}

- name: Run adapter tests
run: uv run --no-sync pytest -v --confcutdir=tests/httpx tests/httpx

mcp-compat:
name: MCP 1.19.0
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false

- name: Install uv
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0

- name: Set up Python
run: uv python install 3.12

- name: Install minimum supported MCP SDK
run: |
uv venv .compat-venv --python 3.12
uv pip install --python .compat-venv/bin/python \
-e ".[mcp]" "mcp==1.19.0" pytest pytest-asyncio

- name: Run MCP client tests
run: |
.compat-venv/bin/python -m pytest -q --noconftest \
tests/test_mcp_client.py tests/test_mcp_runtime.py

integration:
name: Integration Test
runs-on: ubuntu-latest
Expand Down Expand Up @@ -235,9 +292,11 @@ jobs:
import mpp
import mpp.extensions.mcp as mcp
import mpp.methods.tempo as tempo
from mpp.runtime import PaymentRuntime

assert tempo.CHAIN_ID == 4217
assert mcp.CODE_PAYMENT_REQUIRED == -32042
with PaymentRuntime(): pass

for expr, hint in (
("tempo.ChargeIntent", "pympp[tempo]"),
Expand Down
102 changes: 102 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,108 @@ async with Client(methods=[tempo(account=account, intents={"charge": ChargeInten
response = await client.get("https://mpp.dev/api/ping/paid")
```

### Shared payment-method lifecycle

Custom integrations can use one owned event loop for loop-bound payment methods:

```python
from mpp.runtime import PaymentRuntime

method_factory = ...
challenges = ...

async with PaymentRuntime(method_factories=[method_factory]) as runtime:
challenge, method = runtime.match_challenge(challenges)
credential = await runtime.create_credential(challenge, method)
```

Factories run once on the owned loop. Async context-manager results are closed
on that same loop; direct `methods=` are borrowed and must be loop-independent.
Factories and methods must finish any work they spawn before returning.

The runtime can also power the existing asynchronous HTTP client while enforcing
an exact origin allowlist:

```python
from mpp.client import Client

async with PaymentRuntime(
method_factories=[method_factory],
allowed_origins=["https://api.example.com"],
) as runtime:
async with Client(runtime=runtime) as client:
response = await client.get("https://api.example.com/paid")
```

The optional MCP client can explicitly share that runtime and its origin policy,
events, and unknown-outcome protection:

```python
from mpp.extensions.mcp import McpClient

async with PaymentRuntime(
method_factories=[method_factory],
allowed_origins=["api.example.com"],
) as runtime:
async with McpClient(session, runtime=runtime) as client:
result = await client.call_tool("premium_tool", {"query": "hello"})
```

If a credential is sent but its outcome cannot be confirmed, matching attempts
raise `PaymentOutcomeUnknownError` instead of paying again. Reconcile those
payments externally before calling
`runtime.reset_unknown_outcomes(reconciled=True)`.

Synchronous HTTPX clients can use the same runtime:

```python
import httpx

with PaymentRuntime(
method_factories=[method_factory],
allowed_origins=["https://api.example.com"],
) as runtime:
with httpx.Client(transport=runtime.sync_payment_transport()) as client:
response = client.get("https://api.example.com/paid")
```

Existing HTTPX clients can instead be adapted in place, preserving their
authentication, redirects, hooks, and streaming behavior:

```python
with PaymentRuntime(
method_factories=[method_factory],
allowed_origins=["https://api.example.com"],
) as runtime:
with runtime.wrap_client(httpx.Client()) as client:
response = client.get("https://api.example.com/paid")
```

Use `runtime.wrap_async_client(client)` for an `httpx.AsyncClient`. These
per-client adapters support HTTPX `0.27.x` and `0.28.x` and fail before changing
the client when its adapter seam is incompatible. The explicit
`PaymentTransport` and `SyncPaymentTransport` remain available on newer HTTPX
versions.

For harnesses that create their own standard HTTPX clients, instrumentation can
apply the same behavior within a scope:

```python
from mpp.instrumentation import instrument

with PaymentRuntime(
method_factories=[method_factory],
allowed_origins=["https://api.example.com"],
) as runtime:
with instrument(runtime):
existing_harness.run()
```

Context scope is the default. Use `scope="process"` for independent worker
threads in a single-wallet process; ambiguous process bindings fail closed.
Process propagation covers raw threads and `ThreadPoolExecutor`, including
`asyncio.to_thread`, but not custom executors or process pools.

## Examples

| Example | Description |
Expand Down
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ description = "Python SDK for the Machine Payments Protocol (MPP)"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"anyio>=4.12,<5",
"httpx>=0.27",
]
authors = [
Expand Down Expand Up @@ -42,7 +43,7 @@ stripe = [
server = ["pydantic>=2.0"]
redis = ["redis>=5.0"]
sqlite = ["aiosqlite>=0.20"]
mcp = ["mcp>=1.1,<2"]
mcp = ["mcp>=1.19,<2"]
dev = [
"pytest>=8.0",
"pytest-asyncio>=0.24",
Expand Down
1 change: 1 addition & 0 deletions src/mpp/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
PaymentExpiredError,
PaymentInsufficientError,
PaymentMethodUnsupportedError,
PaymentOutcomeUnknownError,
PaymentRequiredError,
VerificationFailedError,
)
Expand Down
129 changes: 129 additions & 0 deletions src/mpp/_httpx.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
"""Compatibility checks for HTTPX client adapters."""

from __future__ import annotations

import inspect
from importlib.metadata import version
from typing import Any

import httpx

HTTPX_ADAPTER_VERSIONS = ">=0.27,<0.29"
_SUPPORTED_HTTPX_MINORS = {(0, 27), (0, 28)}


class HttpxCompatibilityError(RuntimeError):
"""The installed HTTPX version cannot be safely adapted."""


_POSITIONAL = inspect.Parameter.POSITIONAL_OR_KEYWORD
_KEYWORD_ONLY = inspect.Parameter.KEYWORD_ONLY
_PRIVATE_PARAMETERS = (("self", _POSITIONAL), ("request", _POSITIONAL))
_BOUND_PRIVATE_PARAMETERS = (("request", _POSITIONAL),)
_PUBLIC_PARAMETERS = (
("self", _POSITIONAL),
("request", _POSITIONAL),
("stream", _KEYWORD_ONLY),
("auth", _KEYWORD_ONLY),
("follow_redirects", _KEYWORD_ONLY),
)
_BOUND_PUBLIC_PARAMETERS = _PUBLIC_PARAMETERS[1:]


def _method_shape(method: Any) -> tuple[tuple[str, Any], ...]:
try:
return tuple(
(parameter.name, parameter.kind)
for parameter in inspect.signature(method).parameters.values()
)
except (TypeError, ValueError) as error:
raise HttpxCompatibilityError("HTTPX adapter seam has no inspectable signature") from error


def _validate_method(
name: str,
method: Any,
expected: tuple[tuple[str, Any], ...],
*,
asynchronous: bool,
) -> None:
if not callable(method):
raise HttpxCompatibilityError(f"HTTPX adapter seam {name} is not callable")
if _method_shape(method) != expected:
raise HttpxCompatibilityError(f"HTTPX adapter seam {name} has an unsupported signature")
if inspect.iscoroutinefunction(method) is not asynchronous:
kind = "async" if asynchronous else "sync"
raise HttpxCompatibilityError(f"HTTPX adapter seam {name} is not {kind}")


def _validate_httpx_version() -> None:
installed = version("httpx")
try:
major, minor, *_ = installed.split(".")
supported = (int(major), int(minor)) in _SUPPORTED_HTTPX_MINORS
except ValueError as error:
raise HttpxCompatibilityError(
f"Cannot determine HTTPX compatibility from version {installed!r}"
) from error
if not supported:
raise HttpxCompatibilityError(
f"HTTPX {installed} is unsupported by pympp HTTPX adapters "
f"(supported: {HTTPX_ADAPTER_VERSIONS}). "
"Use PaymentTransport or SyncPaymentTransport explicitly, or upgrade pympp."
)


def _validate_httpx_seams(owner: Any, *, asynchronous: bool) -> tuple[Any, Any]:
seams: list[Any] = []
for name, parameters in (
("_send_single_request", _PRIVATE_PARAMETERS),
("send", _PUBLIC_PARAMETERS),
):
seam_name = f"{owner.__name__}.{name}"
try:
method = inspect.getattr_static(owner, name)
except AttributeError as error:
raise HttpxCompatibilityError(f"HTTPX adapter seam {seam_name} is missing") from error
_validate_method(
seam_name,
method,
parameters,
asynchronous=asynchronous,
)
seams.append(method)
return seams[0], seams[1]


def _validate_httpx_compatibility() -> tuple[Any, Any, Any, Any]:
"""Return all compatible class-level HTTPX seams, without mutation."""
_validate_httpx_version()
sync_private, sync_public = _validate_httpx_seams(httpx.Client, asynchronous=False)
async_private, async_public = _validate_httpx_seams(httpx.AsyncClient, asynchronous=True)
return sync_private, async_private, sync_public, async_public


def _validate_httpx_client(
client: httpx.Client | httpx.AsyncClient,
) -> tuple[Any, Any]:
"""Return compatible bound send seams, without mutating the client."""
_validate_httpx_version()
asynchronous = isinstance(client, httpx.AsyncClient)
_validate_httpx_seams(
type(client),
asynchronous=asynchronous,
)
private, public = client._send_single_request, client.send
owner = type(client).__name__
_validate_method(
f"{owner} instance._send_single_request",
private,
_BOUND_PRIVATE_PARAMETERS,
asynchronous=asynchronous,
)
_validate_method(
f"{owner} instance.send",
public,
_BOUND_PUBLIC_PARAMETERS,
asynchronous=asynchronous,
)
return private, public
Loading