Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changelog/gentle-crows-run.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
pympp: patch
---

Fixed optional dependency handling by converting eager imports in `mpp.extensions.mcp` and `mpp.methods.tempo` to lazy `__getattr__`-based loading, so importing these modules without their extras installed no longer raises `ImportError` at import time. Added clear install hint messages when optional attrs are accessed without the required extras, and added `eth-account`, `eth-hash`, `attrs`, and `rlp` as declared dependencies for the `[tempo]` extra.
104 changes: 103 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ jobs:
ci-gate:
name: CI Gate
if: always()
needs: [test, package]
needs: [test, package, install-smoke]
runs-on: ubuntu-latest
steps:
- run: |
Expand Down Expand Up @@ -100,3 +100,105 @@ jobs:
run: |
uv run python -m build
uv run twine check --strict dist/*

- name: Upload package artifacts
uses: actions/upload-artifact@v4
with:
name: package-dist
path: dist/*
if-no-files-found: error

install-smoke:
needs: package
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
profile: [base, tempo, mcp]

steps:
- uses: actions/checkout@v4

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

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

- name: Download package artifacts
uses: actions/download-artifact@v4
with:
name: package-dist
path: dist

- name: Create clean virtualenv
run: uv venv .smoke-venv --python 3.12

- name: Install built wheel
run: |
WHEEL=$(echo dist/*.whl)
case "${{ matrix.profile }}" in
base)
INSTALL_TARGET="$WHEEL"
;;
tempo)
INSTALL_TARGET="${WHEEL}[tempo]"
;;
mcp)
INSTALL_TARGET="${WHEEL}[mcp]"
;;
esac
uv pip install --python .smoke-venv/bin/python "$INSTALL_TARGET"

- name: Smoke test built wheel (base)
if: matrix.profile == 'base'
run: |
.smoke-venv/bin/python - <<'PY'
import mpp
import mpp.extensions.mcp as mcp
import mpp.methods.tempo as tempo

assert tempo.CHAIN_ID == 4217
assert mcp.CODE_PAYMENT_REQUIRED == -32042

for expr, hint in (
("tempo.ChargeIntent", "pympp[tempo]"),
("mcp.PaymentRequiredError", "pympp[mcp]"),
):
try:
eval(expr)
except ImportError as exc:
assert hint in str(exc), str(exc)
else:
raise AssertionError(f"{expr} unexpectedly imported in base install")

print("base install smoke test passed")
PY

- name: Smoke test built wheel (tempo)
if: matrix.profile == 'tempo'
run: |
.smoke-venv/bin/python - <<'PY'
from mpp.methods.tempo import ChargeIntent, TempoAccount

assert ChargeIntent.__name__ == "ChargeIntent"
assert TempoAccount.__name__ == "TempoAccount"

print("tempo install smoke test passed")
PY

- name: Smoke test built wheel (mcp)
if: matrix.profile == 'mcp'
run: |
.smoke-venv/bin/python - <<'PY'
from mpp.extensions.mcp import PaymentRequiredError, pay, verify_or_challenge

assert PaymentRequiredError.__name__ == "PaymentRequiredError"
assert pay.__name__ == "pay"
assert verify_or_challenge.__name__ == "verify_or_challenge"

print("mcp install smoke test passed")
PY
10 changes: 8 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "pympp"
version = "0.5.0"
version = "0.5.1"
description = "Python SDK for the Machine Payments Protocol (MPP)"
readme = "README.md"
requires-python = ">=3.12"
Expand All @@ -14,8 +14,10 @@ license = {text = "MIT OR Apache-2.0"}
classifiers = [
"License :: OSI Approved :: MIT License",
"License :: OSI Approved :: Apache Software License",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Typing :: Typed",
]

[project.urls]
Expand All @@ -26,8 +28,12 @@ Documentation = "https://github.com/tempoxyz/pympp#readme"
tempo = [
"pytempo>=0.2.1",
"pydantic>=2.0",
"eth-account>=0.11",
"eth-hash[pycryptodome]>=0.7",
"attrs>=23.0",
"rlp>=4.0",
]
server = ["pydantic>=2.0", "python-dotenv>=1.0"]
server = ["pydantic>=2.0"]
redis = ["redis>=5.0"]
sqlite = ["aiosqlite>=0.20"]
mcp = ["mcp>=1.1.0"]
Expand Down
39 changes: 39 additions & 0 deletions src/mpp/_lazy_exports.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"""Helpers for package-level lazy exports."""

from __future__ import annotations

import importlib
from collections.abc import Mapping
from typing import Any


def load_lazy_attr(
module_name: str,
name: str,
lazy_exports: Mapping[str, tuple[str, ...]],
namespace: dict[str, Any],
extra_install_hint: str,
) -> Any:
"""Load and cache a lazily exported attribute.

Raises:
AttributeError: If the name is not a known lazy export.
ImportError: If the target module cannot be imported.
"""
module_path = next(
(module_path for module_path, names in lazy_exports.items() if name in names),
None,
)
if module_path is None:
raise AttributeError(f"module {module_name!r} has no attribute {name!r}")

try:
mod = importlib.import_module(module_path)
except ImportError as exc:
raise ImportError(
f"Cannot import {name!r} from {module_name}: {exc}. {extra_install_hint}"
) from exc

value = getattr(mod, name)
namespace[name] = value
return value
40 changes: 26 additions & 14 deletions src/mpp/extensions/mcp/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,24 +58,36 @@ async def expensive_tool(query: str, *, credential, receipt) -> str:
return f"Result for {query}, paid by {credential.source}"
"""

from mpp.extensions.mcp.capabilities import payment_capabilities
from mpp.extensions.mcp.client import (
McpClient,
McpToolResult,
PaymentOutcomeUnknownError,
)
from typing import Any

from mpp._lazy_exports import load_lazy_attr
from mpp.extensions.mcp.constants import (
CODE_MALFORMED_CREDENTIAL,
CODE_PAYMENT_REQUIRED,
CODE_PAYMENT_VERIFICATION_FAILED,
META_CREDENTIAL,
META_RECEIPT,
)
from mpp.extensions.mcp.decorator import pay
from mpp.extensions.mcp.errors import (
MalformedCredentialError,
PaymentRequiredError,
PaymentVerificationError,
)
from mpp.extensions.mcp.types import MCPChallenge, MCPCredential, MCPReceipt
from mpp.extensions.mcp.verify import create_challenge, verify_or_challenge

_EXTRA_INSTALL_HINT = 'Install the "mcp" extra to use this module: pip install "pympp[mcp]"'

_LAZY_EXPORTS = {
"mpp.extensions.mcp.capabilities": ("payment_capabilities",),
"mpp.extensions.mcp.client": (
"McpClient",
"McpToolResult",
"PaymentOutcomeUnknownError",
),
"mpp.extensions.mcp.decorator": ("pay",),
"mpp.extensions.mcp.errors": (
"MalformedCredentialError",
"PaymentRequiredError",
"PaymentVerificationError",
),
"mpp.extensions.mcp.types": ("MCPChallenge", "MCPCredential", "MCPReceipt"),
"mpp.extensions.mcp.verify": ("create_challenge", "verify_or_challenge"),
}


def __getattr__(name: str) -> Any:
return load_lazy_attr(__name__, name, _LAZY_EXPORTS, globals(), _EXTRA_INSTALL_HINT)
39 changes: 39 additions & 0 deletions src/mpp/extensions/mcp/__init__.pyi
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
from mpp.extensions.mcp.capabilities import payment_capabilities as _payment_capabilities
from mpp.extensions.mcp.client import McpClient as _McpClient
from mpp.extensions.mcp.client import McpToolResult as _McpToolResult
from mpp.extensions.mcp.client import PaymentOutcomeUnknownError as _PaymentOutcomeUnknownError
from mpp.extensions.mcp.constants import CODE_MALFORMED_CREDENTIAL as _CODE_MALFORMED_CREDENTIAL
from mpp.extensions.mcp.constants import CODE_PAYMENT_REQUIRED as _CODE_PAYMENT_REQUIRED
from mpp.extensions.mcp.constants import (
CODE_PAYMENT_VERIFICATION_FAILED as _CODE_PAYMENT_VERIFICATION_FAILED,
)
from mpp.extensions.mcp.constants import META_CREDENTIAL as _META_CREDENTIAL
from mpp.extensions.mcp.constants import META_RECEIPT as _META_RECEIPT
from mpp.extensions.mcp.decorator import pay as _pay
from mpp.extensions.mcp.errors import MalformedCredentialError as _MalformedCredentialError
from mpp.extensions.mcp.errors import PaymentRequiredError as _PaymentRequiredError
from mpp.extensions.mcp.errors import PaymentVerificationError as _PaymentVerificationError
from mpp.extensions.mcp.types import MCPChallenge as _MCPChallenge
from mpp.extensions.mcp.types import MCPCredential as _MCPCredential
from mpp.extensions.mcp.types import MCPReceipt as _MCPReceipt
from mpp.extensions.mcp.verify import create_challenge as _create_challenge
from mpp.extensions.mcp.verify import verify_or_challenge as _verify_or_challenge

CODE_MALFORMED_CREDENTIAL = _CODE_MALFORMED_CREDENTIAL
CODE_PAYMENT_REQUIRED = _CODE_PAYMENT_REQUIRED
CODE_PAYMENT_VERIFICATION_FAILED = _CODE_PAYMENT_VERIFICATION_FAILED
META_CREDENTIAL = _META_CREDENTIAL
META_RECEIPT = _META_RECEIPT
payment_capabilities = _payment_capabilities
McpClient = _McpClient
McpToolResult = _McpToolResult
PaymentOutcomeUnknownError = _PaymentOutcomeUnknownError
pay = _pay
MalformedCredentialError = _MalformedCredentialError
PaymentRequiredError = _PaymentRequiredError
PaymentVerificationError = _PaymentVerificationError
MCPChallenge = _MCPChallenge
MCPCredential = _MCPCredential
MCPReceipt = _MCPReceipt
create_challenge = _create_challenge
verify_or_challenge = _verify_or_challenge
18 changes: 15 additions & 3 deletions src/mpp/methods/tempo/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@
)
"""

from typing import Any

from mpp._lazy_exports import load_lazy_attr
from mpp.methods.tempo._defaults import (
CHAIN_ID,
ESCROW_CONTRACTS,
Expand All @@ -35,6 +38,15 @@
default_currency_for_chain,
escrow_contract_for_chain,
)
from mpp.methods.tempo.account import TempoAccount
from mpp.methods.tempo.client import TempoMethod, TransactionError, tempo
from mpp.methods.tempo.intents import ChargeIntent

_EXTRA_INSTALL_HINT = 'Install the "tempo" extra to use this module: pip install "pympp[tempo]"'

_LAZY_EXPORTS = {
"mpp.methods.tempo.account": ("TempoAccount",),
"mpp.methods.tempo.client": ("TempoMethod", "TransactionError", "tempo"),
"mpp.methods.tempo.intents": ("ChargeIntent",),
}


def __getattr__(name: str) -> Any:
return load_lazy_attr(__name__, name, _LAZY_EXPORTS, globals(), _EXTRA_INSTALL_HINT)
25 changes: 25 additions & 0 deletions src/mpp/methods/tempo/__init__.pyi
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
from mpp.methods.tempo._defaults import CHAIN_ID as _CHAIN_ID
from mpp.methods.tempo._defaults import ESCROW_CONTRACTS as _ESCROW_CONTRACTS
from mpp.methods.tempo._defaults import PATH_USD as _PATH_USD
from mpp.methods.tempo._defaults import TESTNET_CHAIN_ID as _TESTNET_CHAIN_ID
from mpp.methods.tempo._defaults import USDC as _USDC
from mpp.methods.tempo._defaults import default_currency_for_chain as _default_currency_for_chain
from mpp.methods.tempo._defaults import escrow_contract_for_chain as _escrow_contract_for_chain
from mpp.methods.tempo.account import TempoAccount as _TempoAccount
from mpp.methods.tempo.client import TempoMethod as _TempoMethod
from mpp.methods.tempo.client import TransactionError as _TransactionError
from mpp.methods.tempo.client import tempo as _tempo
from mpp.methods.tempo.intents import ChargeIntent as _ChargeIntent

CHAIN_ID = _CHAIN_ID
ESCROW_CONTRACTS = _ESCROW_CONTRACTS
PATH_USD = _PATH_USD
TESTNET_CHAIN_ID = _TESTNET_CHAIN_ID
USDC = _USDC
default_currency_for_chain = _default_currency_for_chain
escrow_contract_for_chain = _escrow_contract_for_chain
TempoAccount = _TempoAccount
TempoMethod = _TempoMethod
TransactionError = _TransactionError
tempo = _tempo
ChargeIntent = _ChargeIntent
32 changes: 25 additions & 7 deletions src/mpp/methods/tempo/_attribution.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,21 +20,39 @@
import os
from dataclasses import dataclass

from eth_hash.auto import keccak

TAG: bytes = keccak(b"mpp")[:4]

_VERSION = 0x01
_ANONYMOUS = bytes(10)

_TAG: bytes | None = None


def _keccak(data: bytes) -> bytes:
from eth_hash.auto import keccak

return keccak(data)


def _get_tag() -> bytes:
global _TAG
if _TAG is None:
_TAG = _keccak(b"mpp")[:4]
return _TAG


def _fingerprint(value: str) -> bytes:
return keccak(value.encode())[:10]
return _keccak(value.encode())[:10]


def __getattr__(name: str): # type: ignore[reportReturnType]
if name == "TAG":
return _get_tag()
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")


def encode(server_id: str, client_id: str | None = None) -> str:
tag = _get_tag()
buf = bytearray(32)
buf[0:4] = TAG
buf[0:4] = tag
buf[4] = _VERSION
buf[5:15] = _fingerprint(server_id)
if client_id:
Expand All @@ -51,7 +69,7 @@ def is_mpp_memo(memo: str) -> bool:
memo_version = int(memo[10:12], 16)
except ValueError:
return False
return memo_tag == TAG and memo_version == _VERSION
return memo_tag == _get_tag() and memo_version == _VERSION


def verify_server(memo: str, server_id: str) -> bool:
Expand Down
Loading
Loading