diff --git a/.changelog/gentle-crows-run.md b/.changelog/gentle-crows-run.md new file mode 100644 index 0000000..9b13c83 --- /dev/null +++ b/.changelog/gentle-crows-run.md @@ -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. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0828bb6..b584c78 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: | @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 5041ee2..239b97d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" @@ -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] @@ -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"] diff --git a/src/mpp/_lazy_exports.py b/src/mpp/_lazy_exports.py new file mode 100644 index 0000000..88b6fe7 --- /dev/null +++ b/src/mpp/_lazy_exports.py @@ -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 diff --git a/src/mpp/extensions/mcp/__init__.py b/src/mpp/extensions/mcp/__init__.py index bd937b9..351cd2d 100644 --- a/src/mpp/extensions/mcp/__init__.py +++ b/src/mpp/extensions/mcp/__init__.py @@ -58,12 +58,9 @@ 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, @@ -71,11 +68,26 @@ async def expensive_tool(query: str, *, credential, receipt) -> str: 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) diff --git a/src/mpp/extensions/mcp/__init__.pyi b/src/mpp/extensions/mcp/__init__.pyi new file mode 100644 index 0000000..99268c3 --- /dev/null +++ b/src/mpp/extensions/mcp/__init__.pyi @@ -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 diff --git a/src/mpp/methods/tempo/__init__.py b/src/mpp/methods/tempo/__init__.py index 79ce6b0..a7c6ff3 100644 --- a/src/mpp/methods/tempo/__init__.py +++ b/src/mpp/methods/tempo/__init__.py @@ -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, @@ -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) diff --git a/src/mpp/methods/tempo/__init__.pyi b/src/mpp/methods/tempo/__init__.pyi new file mode 100644 index 0000000..8cc6db1 --- /dev/null +++ b/src/mpp/methods/tempo/__init__.pyi @@ -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 diff --git a/src/mpp/methods/tempo/_attribution.py b/src/mpp/methods/tempo/_attribution.py index 1ae44e3..ce14606 100644 --- a/src/mpp/methods/tempo/_attribution.py +++ b/src/mpp/methods/tempo/_attribution.py @@ -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: @@ -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: diff --git a/src/mpp/stores/__init__.py b/src/mpp/stores/__init__.py index 41dd0f6..a707c46 100644 --- a/src/mpp/stores/__init__.py +++ b/src/mpp/stores/__init__.py @@ -7,18 +7,18 @@ - ``SQLiteStore`` – local SQLite file, for single-instance production deployments. """ -from mpp.store import MemoryStore +from typing import Any -__all__ = ["MemoryStore", "RedisStore", "SQLiteStore"] +from mpp._lazy_exports import load_lazy_attr +from mpp.store import MemoryStore +_EXTRA_INSTALL_HINT = "Install the required store extra for this backend." -def __getattr__(name: str): # type: ignore[reportReturnType] - if name == "RedisStore": - from mpp.stores.redis import RedisStore +_LAZY_EXPORTS = { + "mpp.stores.redis": ("RedisStore",), + "mpp.stores.sqlite": ("SQLiteStore",), +} - return RedisStore - if name == "SQLiteStore": - from mpp.stores.sqlite import SQLiteStore - return SQLiteStore - raise AttributeError(f"module {__name__!r} has no attribute {name!r}") +def __getattr__(name: str) -> Any: + return load_lazy_attr(__name__, name, _LAZY_EXPORTS, globals(), _EXTRA_INSTALL_HINT) diff --git a/src/mpp/stores/__init__.pyi b/src/mpp/stores/__init__.pyi new file mode 100644 index 0000000..e29a1a7 --- /dev/null +++ b/src/mpp/stores/__init__.pyi @@ -0,0 +1,7 @@ +from mpp.store import MemoryStore as _MemoryStore +from mpp.stores.redis import RedisStore as _RedisStore +from mpp.stores.sqlite import SQLiteStore as _SQLiteStore + +MemoryStore = _MemoryStore +RedisStore = _RedisStore +SQLiteStore = _SQLiteStore diff --git a/tests/test_optional_deps.py b/tests/test_optional_deps.py new file mode 100644 index 0000000..91360da --- /dev/null +++ b/tests/test_optional_deps.py @@ -0,0 +1,149 @@ +"""Tests that optional extras fail gracefully with clear install hints. + +Verifies that importing modules behind optional extras produces actionable +error messages instead of cryptic ImportError tracebacks. +""" + +import subprocess +import sys +import textwrap + + +def _run_python(script: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, "-c", script], + capture_output=True, + text=True, + ) + + +def test_base_import_no_extras(): + """Core mpp module imports with only httpx (the sole base dep).""" + script = textwrap.dedent("""\ + import mpp + print("ok") + """) + result = _run_python(script) + assert result.returncode == 0, f"Base import failed:\n{result.stderr.strip()}" + assert result.stdout.strip() == "ok" + + +def test_tempo_module_import_succeeds(): + """Importing mpp.methods.tempo itself should not crash (lazy loading).""" + script = textwrap.dedent("""\ + import mpp.methods.tempo + # Access a non-lazy attribute that has no external deps + print(mpp.methods.tempo.CHAIN_ID) + """) + result = _run_python(script) + assert result.returncode == 0, f"Tempo module import failed:\n{result.stderr.strip()}" + + +def test_mcp_module_import_succeeds(): + """Importing mpp.extensions.mcp itself should not crash (lazy loading).""" + script = textwrap.dedent("""\ + import mpp.extensions.mcp + # Access a non-lazy attribute that has no external deps + print(mpp.extensions.mcp.CODE_PAYMENT_REQUIRED) + """) + result = _run_python(script) + assert result.returncode == 0, f"MCP module import failed:\n{result.stderr.strip()}" + + +def test_tempo_lazy_attr_error_message(): + """Accessing a lazy tempo attr with missing deps gives a helpful message. + + Uses ChargeIntent which imports ``attrs`` at module level, so blocking + ``attrs`` reliably triggers the lazy-import guard. + """ + script = textwrap.dedent("""\ + import sys + + # Block packages by inserting None into sys.modules. + blocked = [ + "eth_account", "eth_account.signers", "eth_account.signers.local", + "eth_hash", "eth_hash.auto", + "attrs", + "rlp", + "pytempo", "pytempo.models", + "web3", + ] + for mod_name in blocked: + sys.modules.pop(mod_name, None) + sys.modules[mod_name] = None # type: ignore + + # Clear cached mpp.methods.tempo submodules so they re-import + for key in list(sys.modules): + if key.startswith("mpp.methods.tempo") and key != "mpp.methods.tempo._defaults": + del sys.modules[key] + if "mpp.methods.tempo" in sys.modules: + del sys.modules["mpp.methods.tempo"] + + import mpp.methods.tempo + + try: + _ = mpp.methods.tempo.ChargeIntent + print("ERROR: should have raised ImportError") + sys.exit(1) + except ImportError as e: + msg = str(e) + if 'pympp[tempo]' in msg: + print("ok") + else: + print(f"ERROR: missing install hint in: {msg}") + sys.exit(1) + """) + result = _run_python(script) + assert result.returncode == 0, f"Test failed:\n{result.stderr.strip()}\n{result.stdout.strip()}" + assert result.stdout.strip() == "ok" + + +def test_mcp_lazy_attr_error_message(): + """Accessing a lazy MCP attr with missing deps gives a helpful message.""" + script = textwrap.dedent("""\ + import sys + + blocked = [ + "mcp", + "mcp.shared", + "mcp.shared.exceptions", + "mcp.types", + ] + for mod_name in blocked: + sys.modules.pop(mod_name, None) + sys.modules[mod_name] = None # type: ignore + + for key in list(sys.modules): + if key.startswith("mpp.extensions.mcp") and key != "mpp.extensions.mcp.constants": + del sys.modules[key] + if "mpp.extensions.mcp" in sys.modules: + del sys.modules["mpp.extensions.mcp"] + + import mpp.extensions.mcp + + try: + _ = mpp.extensions.mcp.PaymentRequiredError + print("ERROR: should have raised ImportError") + sys.exit(1) + except ImportError as e: + msg = str(e) + if 'pympp[mcp]' in msg: + print("ok") + else: + print(f"ERROR: missing install hint in: {msg}") + sys.exit(1) + """) + result = _run_python(script) + assert result.returncode == 0, f"Test failed:\n{result.stderr.strip()}\n{result.stdout.strip()}" + assert result.stdout.strip() == "ok" + + +def test_stores_lazy_import(): + """RedisStore and SQLiteStore use lazy imports in stores/__init__.py.""" + script = textwrap.dedent("""\ + from mpp.stores import MemoryStore + print(MemoryStore.__name__) + """) + result = _run_python(script) + assert result.returncode == 0, f"Store import failed:\n{result.stderr.strip()}" + assert result.stdout.strip() == "MemoryStore"