From 00bd466b94fe97af7f16a4f01ee5c00675ba1b9b Mon Sep 17 00:00:00 2001 From: Brendan Ryan Date: Tue, 31 Mar 2026 21:04:58 -0700 Subject: [PATCH 1/7] fix: declare all direct deps in [tempo] extra, add lazy import guards - Add eth-account, eth-hash[pycryptodome], attrs, rlp to [tempo] extra - Remove unused python-dotenv from [server] extra - Add lazy __getattr__ loading to mpp.methods.tempo and mpp.extensions.mcp so missing extras produce clear install hints instead of cryptic errors - Defer eth_hash import in _attribution.py to avoid eager dep loading - Add py.typed marker and Typing :: Typed classifier - Add test_optional_deps.py for import isolation validation - Bump to 0.5.1 Fixes: users having to manually pip install eth_hash attrs pydantic eth_account --- pyproject.toml | 10 ++- src/mpp/extensions/mcp/__init__.py | 57 ++++++++++---- src/mpp/methods/tempo/__init__.py | 45 ++++++++++- src/mpp/methods/tempo/_attribution.py | 31 ++++++-- tests/test_optional_deps.py | 108 ++++++++++++++++++++++++++ 5 files changed, 227 insertions(+), 24 deletions(-) create mode 100644 tests/test_optional_deps.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/extensions/mcp/__init__.py b/src/mpp/extensions/mcp/__init__.py index bd937b9..eff1d46 100644 --- a/src/mpp/extensions/mcp/__init__.py +++ b/src/mpp/extensions/mcp/__init__.py @@ -58,12 +58,6 @@ 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 mpp.extensions.mcp.constants import ( CODE_MALFORMED_CREDENTIAL, CODE_PAYMENT_REQUIRED, @@ -71,11 +65,48 @@ 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, + +_EXTRA_INSTALL_HINT = ( + 'Install the "mcp" extra to use this module: pip install "pympp[mcp]"' ) -from mpp.extensions.mcp.types import MCPChallenge, MCPCredential, MCPReceipt -from mpp.extensions.mcp.verify import create_challenge, verify_or_challenge + +_LAZY_IMPORTS: dict[str, tuple[str, str]] = { + "payment_capabilities": ("mpp.extensions.mcp.capabilities", "payment_capabilities"), + "McpClient": ("mpp.extensions.mcp.client", "McpClient"), + "McpToolResult": ("mpp.extensions.mcp.client", "McpToolResult"), + "PaymentOutcomeUnknownError": ("mpp.extensions.mcp.client", "PaymentOutcomeUnknownError"), + "pay": ("mpp.extensions.mcp.decorator", "pay"), + "MalformedCredentialError": ("mpp.extensions.mcp.errors", "MalformedCredentialError"), + "PaymentRequiredError": ("mpp.extensions.mcp.errors", "PaymentRequiredError"), + "PaymentVerificationError": ("mpp.extensions.mcp.errors", "PaymentVerificationError"), + "MCPChallenge": ("mpp.extensions.mcp.types", "MCPChallenge"), + "MCPCredential": ("mpp.extensions.mcp.types", "MCPCredential"), + "MCPReceipt": ("mpp.extensions.mcp.types", "MCPReceipt"), + "create_challenge": ("mpp.extensions.mcp.verify", "create_challenge"), + "verify_or_challenge": ("mpp.extensions.mcp.verify", "verify_or_challenge"), +} + +__all__ = [ + "CODE_MALFORMED_CREDENTIAL", + "CODE_PAYMENT_REQUIRED", + "CODE_PAYMENT_VERIFICATION_FAILED", + "META_CREDENTIAL", + "META_RECEIPT", + *_LAZY_IMPORTS, +] + + +def __getattr__(name: str): # type: ignore[reportReturnType] + if name in _LAZY_IMPORTS: + module_path, attr = _LAZY_IMPORTS[name] + try: + import importlib + + mod = importlib.import_module(module_path) + except ImportError as exc: + raise ImportError( + f"Cannot import {name!r} from mpp.extensions.mcp: {exc}. " + f"{_EXTRA_INSTALL_HINT}" + ) from exc + return getattr(mod, attr) + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/mpp/methods/tempo/__init__.py b/src/mpp/methods/tempo/__init__.py index 79ce6b0..620cffd 100644 --- a/src/mpp/methods/tempo/__init__.py +++ b/src/mpp/methods/tempo/__init__.py @@ -35,6 +35,45 @@ 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_IMPORTS: dict[str, tuple[str, str]] = { + "TempoAccount": ("mpp.methods.tempo.account", "TempoAccount"), + "TempoMethod": ("mpp.methods.tempo.client", "TempoMethod"), + "TransactionError": ("mpp.methods.tempo.client", "TransactionError"), + "tempo": ("mpp.methods.tempo.client", "tempo"), + "ChargeIntent": ("mpp.methods.tempo.intents", "ChargeIntent"), + "Transfer": ("mpp.methods.tempo.intents", "Transfer"), + "get_transfers": ("mpp.methods.tempo.intents", "get_transfers"), + "Split": ("mpp.methods.tempo.schemas", "Split"), +} + +__all__ = [ + "CHAIN_ID", + "ESCROW_CONTRACTS", + "PATH_USD", + "TESTNET_CHAIN_ID", + "USDC", + "default_currency_for_chain", + "escrow_contract_for_chain", + *_LAZY_IMPORTS, +] + + +def __getattr__(name: str): # type: ignore[reportReturnType] + if name in _LAZY_IMPORTS: + module_path, attr = _LAZY_IMPORTS[name] + try: + import importlib + + mod = importlib.import_module(module_path) + except ImportError as exc: + raise ImportError( + f"Cannot import {name!r} from mpp.methods.tempo: {exc}. " + f"{_EXTRA_INSTALL_HINT}" + ) from exc + return getattr(mod, attr) + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/mpp/methods/tempo/_attribution.py b/src/mpp/methods/tempo/_attribution.py index 1ae44e3..94711a6 100644 --- a/src/mpp/methods/tempo/_attribution.py +++ b/src/mpp/methods/tempo/_attribution.py @@ -20,21 +20,40 @@ 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 +70,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/tests/test_optional_deps.py b/tests/test_optional_deps.py new file mode 100644 index 0000000..1226156 --- /dev/null +++ b/tests/test_optional_deps.py @@ -0,0 +1,108 @@ +"""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 + +import pytest + + +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 = subprocess.run( + [sys.executable, "-c", script], + capture_output=True, + text=True, + ) + 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 = subprocess.run( + [sys.executable, "-c", script], + capture_output=True, + text=True, + ) + assert result.returncode == 0, f"Tempo 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 = subprocess.run( + [sys.executable, "-c", script], + capture_output=True, + text=True, + ) + 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 = subprocess.run( + [sys.executable, "-c", script], + capture_output=True, + text=True, + ) + assert result.returncode == 0, f"Store import failed:\n{result.stderr.strip()}" + assert result.stdout.strip() == "MemoryStore" From 43a90fb2ee35b17a3eced9135ab91cea865bb027 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 1 Apr 2026 04:06:14 +0000 Subject: [PATCH 2/7] chore: add changelog --- .changelog/gentle-crows-run.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changelog/gentle-crows-run.md 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. From a709be406b05f038a1adea174d59cafce7045d0e Mon Sep 17 00:00:00 2001 From: Brendan Ryan Date: Tue, 31 Mar 2026 21:23:26 -0700 Subject: [PATCH 3/7] test: harden optional dependency packaging checks --- .github/workflows/ci.yml | 103 +++++++++++++++++++++++++- src/mpp/extensions/mcp/__init__.py | 7 +- src/mpp/methods/tempo/__init__.py | 7 +- src/mpp/methods/tempo/_attribution.py | 1 - tests/test_optional_deps.py | 83 +++++++++++++++------ 5 files changed, 168 insertions(+), 33 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0828bb6..e84d2ce 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,104 @@ 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 + run: | + case "${{ matrix.profile }}" in + base) + .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 + ;; + tempo) + .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 + ;; + mcp) + .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 + ;; + esac diff --git a/src/mpp/extensions/mcp/__init__.py b/src/mpp/extensions/mcp/__init__.py index eff1d46..30b1068 100644 --- a/src/mpp/extensions/mcp/__init__.py +++ b/src/mpp/extensions/mcp/__init__.py @@ -66,9 +66,7 @@ async def expensive_tool(query: str, *, credential, receipt) -> str: META_RECEIPT, ) -_EXTRA_INSTALL_HINT = ( - 'Install the "mcp" extra to use this module: pip install "pympp[mcp]"' -) +_EXTRA_INSTALL_HINT = 'Install the "mcp" extra to use this module: pip install "pympp[mcp]"' _LAZY_IMPORTS: dict[str, tuple[str, str]] = { "payment_capabilities": ("mpp.extensions.mcp.capabilities", "payment_capabilities"), @@ -105,8 +103,7 @@ def __getattr__(name: str): # type: ignore[reportReturnType] mod = importlib.import_module(module_path) except ImportError as exc: raise ImportError( - f"Cannot import {name!r} from mpp.extensions.mcp: {exc}. " - f"{_EXTRA_INSTALL_HINT}" + f"Cannot import {name!r} from mpp.extensions.mcp: {exc}. {_EXTRA_INSTALL_HINT}" ) from exc return getattr(mod, attr) raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/mpp/methods/tempo/__init__.py b/src/mpp/methods/tempo/__init__.py index 620cffd..2e54727 100644 --- a/src/mpp/methods/tempo/__init__.py +++ b/src/mpp/methods/tempo/__init__.py @@ -36,9 +36,7 @@ escrow_contract_for_chain, ) -_EXTRA_INSTALL_HINT = ( - 'Install the "tempo" extra to use this module: pip install "pympp[tempo]"' -) +_EXTRA_INSTALL_HINT = 'Install the "tempo" extra to use this module: pip install "pympp[tempo]"' _LAZY_IMPORTS: dict[str, tuple[str, str]] = { "TempoAccount": ("mpp.methods.tempo.account", "TempoAccount"), @@ -72,8 +70,7 @@ def __getattr__(name: str): # type: ignore[reportReturnType] mod = importlib.import_module(module_path) except ImportError as exc: raise ImportError( - f"Cannot import {name!r} from mpp.methods.tempo: {exc}. " - f"{_EXTRA_INSTALL_HINT}" + f"Cannot import {name!r} from mpp.methods.tempo: {exc}. {_EXTRA_INSTALL_HINT}" ) from exc return getattr(mod, attr) raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/mpp/methods/tempo/_attribution.py b/src/mpp/methods/tempo/_attribution.py index 94711a6..ce14606 100644 --- a/src/mpp/methods/tempo/_attribution.py +++ b/src/mpp/methods/tempo/_attribution.py @@ -20,7 +20,6 @@ import os from dataclasses import dataclass - _VERSION = 0x01 _ANONYMOUS = bytes(10) diff --git a/tests/test_optional_deps.py b/tests/test_optional_deps.py index 1226156..91360da 100644 --- a/tests/test_optional_deps.py +++ b/tests/test_optional_deps.py @@ -8,7 +8,13 @@ import sys import textwrap -import pytest + +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(): @@ -17,11 +23,7 @@ def test_base_import_no_extras(): import mpp print("ok") """) - result = subprocess.run( - [sys.executable, "-c", script], - capture_output=True, - text=True, - ) + result = _run_python(script) assert result.returncode == 0, f"Base import failed:\n{result.stderr.strip()}" assert result.stdout.strip() == "ok" @@ -33,14 +35,21 @@ def test_tempo_module_import_succeeds(): # Access a non-lazy attribute that has no external deps print(mpp.methods.tempo.CHAIN_ID) """) - result = subprocess.run( - [sys.executable, "-c", script], - capture_output=True, - text=True, - ) + 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. @@ -84,11 +93,47 @@ def test_tempo_lazy_attr_error_message(): print(f"ERROR: missing install hint in: {msg}") sys.exit(1) """) - result = subprocess.run( - [sys.executable, "-c", script], - capture_output=True, - text=True, - ) + 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" @@ -99,10 +144,6 @@ def test_stores_lazy_import(): from mpp.stores import MemoryStore print(MemoryStore.__name__) """) - result = subprocess.run( - [sys.executable, "-c", script], - capture_output=True, - text=True, - ) + result = _run_python(script) assert result.returncode == 0, f"Store import failed:\n{result.stderr.strip()}" assert result.stdout.strip() == "MemoryStore" From 26d541238be8307120ca3641bab67a01e0474f1f Mon Sep 17 00:00:00 2001 From: Brendan Ryan Date: Tue, 31 Mar 2026 21:26:10 -0700 Subject: [PATCH 4/7] chore: tighten lazy export typing --- src/mpp/extensions/mcp/__init__.py | 37 ++++++++++++++++++++++++++---- src/mpp/methods/tempo/__init__.py | 25 +++++++++++++------- src/mpp/stores/__init__.py | 10 +++++++- 3 files changed, 58 insertions(+), 14 deletions(-) diff --git a/src/mpp/extensions/mcp/__init__.py b/src/mpp/extensions/mcp/__init__.py index 30b1068..1ab59dc 100644 --- a/src/mpp/extensions/mcp/__init__.py +++ b/src/mpp/extensions/mcp/__init__.py @@ -58,6 +58,9 @@ async def expensive_tool(query: str, *, credential, receipt) -> str: return f"Result for {query}, paid by {credential.source}" """ +import importlib +from typing import TYPE_CHECKING, Any + from mpp.extensions.mcp.constants import ( CODE_MALFORMED_CREDENTIAL, CODE_PAYMENT_REQUIRED, @@ -66,6 +69,18 @@ async def expensive_tool(query: str, *, credential, receipt) -> str: META_RECEIPT, ) +if TYPE_CHECKING: + from mpp.extensions.mcp.capabilities import payment_capabilities + from mpp.extensions.mcp.client import McpClient, McpToolResult, PaymentOutcomeUnknownError + 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_IMPORTS: dict[str, tuple[str, str]] = { @@ -90,20 +105,32 @@ async def expensive_tool(query: str, *, credential, receipt) -> str: "CODE_PAYMENT_VERIFICATION_FAILED", "META_CREDENTIAL", "META_RECEIPT", - *_LAZY_IMPORTS, + "payment_capabilities", + "McpClient", + "McpToolResult", + "PaymentOutcomeUnknownError", + "pay", + "MalformedCredentialError", + "PaymentRequiredError", + "PaymentVerificationError", + "MCPChallenge", + "MCPCredential", + "MCPReceipt", + "create_challenge", + "verify_or_challenge", ] -def __getattr__(name: str): # type: ignore[reportReturnType] +def __getattr__(name: str) -> Any: if name in _LAZY_IMPORTS: module_path, attr = _LAZY_IMPORTS[name] try: - import importlib - mod = importlib.import_module(module_path) except ImportError as exc: raise ImportError( f"Cannot import {name!r} from mpp.extensions.mcp: {exc}. {_EXTRA_INSTALL_HINT}" ) from exc - return getattr(mod, attr) + value = getattr(mod, attr) + globals()[name] = value + return value raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/mpp/methods/tempo/__init__.py b/src/mpp/methods/tempo/__init__.py index 2e54727..3dd5699 100644 --- a/src/mpp/methods/tempo/__init__.py +++ b/src/mpp/methods/tempo/__init__.py @@ -26,6 +26,9 @@ ) """ +import importlib +from typing import TYPE_CHECKING, Any + from mpp.methods.tempo._defaults import ( CHAIN_ID, ESCROW_CONTRACTS, @@ -36,6 +39,11 @@ escrow_contract_for_chain, ) +if TYPE_CHECKING: + 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_IMPORTS: dict[str, tuple[str, str]] = { @@ -44,9 +52,6 @@ "TransactionError": ("mpp.methods.tempo.client", "TransactionError"), "tempo": ("mpp.methods.tempo.client", "tempo"), "ChargeIntent": ("mpp.methods.tempo.intents", "ChargeIntent"), - "Transfer": ("mpp.methods.tempo.intents", "Transfer"), - "get_transfers": ("mpp.methods.tempo.intents", "get_transfers"), - "Split": ("mpp.methods.tempo.schemas", "Split"), } __all__ = [ @@ -57,20 +62,24 @@ "USDC", "default_currency_for_chain", "escrow_contract_for_chain", - *_LAZY_IMPORTS, + "TempoAccount", + "TempoMethod", + "TransactionError", + "tempo", + "ChargeIntent", ] -def __getattr__(name: str): # type: ignore[reportReturnType] +def __getattr__(name: str) -> Any: if name in _LAZY_IMPORTS: module_path, attr = _LAZY_IMPORTS[name] try: - import importlib - mod = importlib.import_module(module_path) except ImportError as exc: raise ImportError( f"Cannot import {name!r} from mpp.methods.tempo: {exc}. {_EXTRA_INSTALL_HINT}" ) from exc - return getattr(mod, attr) + value = getattr(mod, attr) + globals()[name] = value + return value raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/mpp/stores/__init__.py b/src/mpp/stores/__init__.py index 41dd0f6..98119e6 100644 --- a/src/mpp/stores/__init__.py +++ b/src/mpp/stores/__init__.py @@ -7,18 +7,26 @@ - ``SQLiteStore`` – local SQLite file, for single-instance production deployments. """ +from typing import TYPE_CHECKING, Any + from mpp.store import MemoryStore +if TYPE_CHECKING: + from mpp.stores.redis import RedisStore + from mpp.stores.sqlite import SQLiteStore + __all__ = ["MemoryStore", "RedisStore", "SQLiteStore"] -def __getattr__(name: str): # type: ignore[reportReturnType] +def __getattr__(name: str) -> Any: if name == "RedisStore": from mpp.stores.redis import RedisStore + globals()[name] = RedisStore return RedisStore if name == "SQLiteStore": from mpp.stores.sqlite import SQLiteStore + globals()[name] = SQLiteStore return SQLiteStore raise AttributeError(f"module {__name__!r} has no attribute {name!r}") From a56ad9a5d59f25bef89eed7bedc9ae8ea8af63c8 Mon Sep 17 00:00:00 2001 From: Brendan Ryan Date: Tue, 31 Mar 2026 21:28:45 -0700 Subject: [PATCH 5/7] fix: make install smoke workflow shell-safe --- .github/workflows/ci.yml | 87 ++++++++++++++++++++-------------------- 1 file changed, 44 insertions(+), 43 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e84d2ce..b584c78 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -153,51 +153,52 @@ jobs: esac uv pip install --python .smoke-venv/bin/python "$INSTALL_TARGET" - - name: Smoke test built wheel + - name: Smoke test built wheel (base) + if: matrix.profile == 'base' run: | - case "${{ matrix.profile }}" in - base) - .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 - ;; - tempo) - .smoke-venv/bin/python - <<'PY' - from mpp.methods.tempo import ChargeIntent, TempoAccount + .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" + assert ChargeIntent.__name__ == "ChargeIntent" + assert TempoAccount.__name__ == "TempoAccount" - print("tempo install smoke test passed") - PY - ;; - mcp) - .smoke-venv/bin/python - <<'PY' - from mpp.extensions.mcp import PaymentRequiredError, pay, verify_or_challenge + print("tempo install smoke test passed") + PY - assert PaymentRequiredError.__name__ == "PaymentRequiredError" - assert pay.__name__ == "pay" - assert verify_or_challenge.__name__ == "verify_or_challenge" + - 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 - print("mcp install smoke test passed") - PY - ;; - esac + assert PaymentRequiredError.__name__ == "PaymentRequiredError" + assert pay.__name__ == "pay" + assert verify_or_challenge.__name__ == "verify_or_challenge" + + print("mcp install smoke test passed") + PY From 52244c191b102ce56854b6174ac7c7cb2d5c90cf Mon Sep 17 00:00:00 2001 From: Brendan Ryan Date: Tue, 31 Mar 2026 21:37:03 -0700 Subject: [PATCH 6/7] refactor: move lazy export typing into stubs --- src/mpp/_lazy_exports.py | 41 ++++++++++++++++ src/mpp/extensions/mcp/__init__.py | 76 +++++++++-------------------- src/mpp/extensions/mcp/__init__.pyi | 39 +++++++++++++++ src/mpp/methods/tempo/__init__.py | 42 +++++----------- src/mpp/methods/tempo/__init__.pyi | 25 ++++++++++ src/mpp/stores/__init__.py | 28 +++++------ src/mpp/stores/__init__.pyi | 7 +++ 7 files changed, 159 insertions(+), 99 deletions(-) create mode 100644 src/mpp/_lazy_exports.py create mode 100644 src/mpp/extensions/mcp/__init__.pyi create mode 100644 src/mpp/methods/tempo/__init__.pyi create mode 100644 src/mpp/stores/__init__.pyi diff --git a/src/mpp/_lazy_exports.py b/src/mpp/_lazy_exports.py new file mode 100644 index 0000000..e4b8117 --- /dev/null +++ b/src/mpp/_lazy_exports.py @@ -0,0 +1,41 @@ +"""Helpers for package-level lazy exports.""" + +from __future__ import annotations + +import importlib +from collections.abc import Mapping +from typing import Any + + +def build_lazy_imports(exports: Mapping[str, tuple[str, ...]]) -> dict[str, str]: + """Flatten a module -> names mapping into a name -> module lookup.""" + return {name: module_path for module_path, names in exports.items() for name in names} + + +def load_lazy_attr( + module_name: str, + name: str, + lazy_imports: Mapping[str, 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 = lazy_imports.get(name) + 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 1ab59dc..8169b54 100644 --- a/src/mpp/extensions/mcp/__init__.py +++ b/src/mpp/extensions/mcp/__init__.py @@ -1,3 +1,5 @@ +# pyright: reportUnsupportedDunderAll=false + """MCP transport support for HTTP 402 Payment Authentication. This module implements the Payment Authentication Scheme for the Model Context @@ -58,9 +60,9 @@ async def expensive_tool(query: str, *, credential, receipt) -> str: return f"Result for {query}, paid by {credential.source}" """ -import importlib -from typing import TYPE_CHECKING, Any +from typing import Any +from mpp._lazy_exports import build_lazy_imports, load_lazy_attr from mpp.extensions.mcp.constants import ( CODE_MALFORMED_CREDENTIAL, CODE_PAYMENT_REQUIRED, @@ -69,68 +71,36 @@ async def expensive_tool(query: str, *, credential, receipt) -> str: META_RECEIPT, ) -if TYPE_CHECKING: - from mpp.extensions.mcp.capabilities import payment_capabilities - from mpp.extensions.mcp.client import McpClient, McpToolResult, PaymentOutcomeUnknownError - 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_IMPORTS: dict[str, tuple[str, str]] = { - "payment_capabilities": ("mpp.extensions.mcp.capabilities", "payment_capabilities"), - "McpClient": ("mpp.extensions.mcp.client", "McpClient"), - "McpToolResult": ("mpp.extensions.mcp.client", "McpToolResult"), - "PaymentOutcomeUnknownError": ("mpp.extensions.mcp.client", "PaymentOutcomeUnknownError"), - "pay": ("mpp.extensions.mcp.decorator", "pay"), - "MalformedCredentialError": ("mpp.extensions.mcp.errors", "MalformedCredentialError"), - "PaymentRequiredError": ("mpp.extensions.mcp.errors", "PaymentRequiredError"), - "PaymentVerificationError": ("mpp.extensions.mcp.errors", "PaymentVerificationError"), - "MCPChallenge": ("mpp.extensions.mcp.types", "MCPChallenge"), - "MCPCredential": ("mpp.extensions.mcp.types", "MCPCredential"), - "MCPReceipt": ("mpp.extensions.mcp.types", "MCPReceipt"), - "create_challenge": ("mpp.extensions.mcp.verify", "create_challenge"), - "verify_or_challenge": ("mpp.extensions.mcp.verify", "verify_or_challenge"), +_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"), } +_LAZY_IMPORTS = build_lazy_imports(_LAZY_EXPORTS) + __all__ = [ "CODE_MALFORMED_CREDENTIAL", "CODE_PAYMENT_REQUIRED", "CODE_PAYMENT_VERIFICATION_FAILED", "META_CREDENTIAL", "META_RECEIPT", - "payment_capabilities", - "McpClient", - "McpToolResult", - "PaymentOutcomeUnknownError", - "pay", - "MalformedCredentialError", - "PaymentRequiredError", - "PaymentVerificationError", - "MCPChallenge", - "MCPCredential", - "MCPReceipt", - "create_challenge", - "verify_or_challenge", + *_LAZY_IMPORTS, ] def __getattr__(name: str) -> Any: - if name in _LAZY_IMPORTS: - module_path, attr = _LAZY_IMPORTS[name] - try: - mod = importlib.import_module(module_path) - except ImportError as exc: - raise ImportError( - f"Cannot import {name!r} from mpp.extensions.mcp: {exc}. {_EXTRA_INSTALL_HINT}" - ) from exc - value = getattr(mod, attr) - globals()[name] = value - return value - raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + return load_lazy_attr(__name__, name, _LAZY_IMPORTS, 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 3dd5699..18d0188 100644 --- a/src/mpp/methods/tempo/__init__.py +++ b/src/mpp/methods/tempo/__init__.py @@ -1,3 +1,5 @@ +# pyright: reportUnsupportedDunderAll=false + """Tempo payment method for HTTP 402 authentication. Example: @@ -26,9 +28,9 @@ ) """ -import importlib -from typing import TYPE_CHECKING, Any +from typing import Any +from mpp._lazy_exports import build_lazy_imports, load_lazy_attr from mpp.methods.tempo._defaults import ( CHAIN_ID, ESCROW_CONTRACTS, @@ -39,21 +41,16 @@ escrow_contract_for_chain, ) -if TYPE_CHECKING: - 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_IMPORTS: dict[str, tuple[str, str]] = { - "TempoAccount": ("mpp.methods.tempo.account", "TempoAccount"), - "TempoMethod": ("mpp.methods.tempo.client", "TempoMethod"), - "TransactionError": ("mpp.methods.tempo.client", "TransactionError"), - "tempo": ("mpp.methods.tempo.client", "tempo"), - "ChargeIntent": ("mpp.methods.tempo.intents", "ChargeIntent"), +_LAZY_EXPORTS = { + "mpp.methods.tempo.account": ("TempoAccount",), + "mpp.methods.tempo.client": ("TempoMethod", "TransactionError", "tempo"), + "mpp.methods.tempo.intents": ("ChargeIntent",), } +_LAZY_IMPORTS = build_lazy_imports(_LAZY_EXPORTS) + __all__ = [ "CHAIN_ID", "ESCROW_CONTRACTS", @@ -62,24 +59,9 @@ "USDC", "default_currency_for_chain", "escrow_contract_for_chain", - "TempoAccount", - "TempoMethod", - "TransactionError", - "tempo", - "ChargeIntent", + *_LAZY_IMPORTS, ] def __getattr__(name: str) -> Any: - if name in _LAZY_IMPORTS: - module_path, attr = _LAZY_IMPORTS[name] - try: - mod = importlib.import_module(module_path) - except ImportError as exc: - raise ImportError( - f"Cannot import {name!r} from mpp.methods.tempo: {exc}. {_EXTRA_INSTALL_HINT}" - ) from exc - value = getattr(mod, attr) - globals()[name] = value - return value - raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + return load_lazy_attr(__name__, name, _LAZY_IMPORTS, 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/stores/__init__.py b/src/mpp/stores/__init__.py index 98119e6..3aa66e1 100644 --- a/src/mpp/stores/__init__.py +++ b/src/mpp/stores/__init__.py @@ -1,3 +1,5 @@ +# pyright: reportUnsupportedDunderAll=false + """Concrete store backends for replay protection. Available backends: @@ -7,26 +9,20 @@ - ``SQLiteStore`` – local SQLite file, for single-instance production deployments. """ -from typing import TYPE_CHECKING, Any +from typing import Any +from mpp._lazy_exports import load_lazy_attr from mpp.store import MemoryStore -if TYPE_CHECKING: - from mpp.stores.redis import RedisStore - from mpp.stores.sqlite import SQLiteStore - -__all__ = ["MemoryStore", "RedisStore", "SQLiteStore"] +_EXTRA_INSTALL_HINT = "Install the required store extra for this backend." +_LAZY_IMPORTS = { + "RedisStore": "mpp.stores.redis", + "SQLiteStore": "mpp.stores.sqlite", +} -def __getattr__(name: str) -> Any: - if name == "RedisStore": - from mpp.stores.redis import RedisStore +__all__ = ["MemoryStore", *_LAZY_IMPORTS] - globals()[name] = RedisStore - return RedisStore - if name == "SQLiteStore": - from mpp.stores.sqlite import SQLiteStore - globals()[name] = 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_IMPORTS, 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 From 6ae551b0c323cf00a29645ae7ed8a95e90bd332a Mon Sep 17 00:00:00 2001 From: Brendan Ryan Date: Tue, 31 Mar 2026 21:48:35 -0700 Subject: [PATCH 7/7] refactor: simplify lazy package re-exports --- src/mpp/_lazy_exports.py | 12 +++++------- src/mpp/extensions/mcp/__init__.py | 17 ++--------------- src/mpp/methods/tempo/__init__.py | 19 ++----------------- src/mpp/stores/__init__.py | 12 ++++-------- 4 files changed, 13 insertions(+), 47 deletions(-) diff --git a/src/mpp/_lazy_exports.py b/src/mpp/_lazy_exports.py index e4b8117..88b6fe7 100644 --- a/src/mpp/_lazy_exports.py +++ b/src/mpp/_lazy_exports.py @@ -7,15 +7,10 @@ from typing import Any -def build_lazy_imports(exports: Mapping[str, tuple[str, ...]]) -> dict[str, str]: - """Flatten a module -> names mapping into a name -> module lookup.""" - return {name: module_path for module_path, names in exports.items() for name in names} - - def load_lazy_attr( module_name: str, name: str, - lazy_imports: Mapping[str, str], + lazy_exports: Mapping[str, tuple[str, ...]], namespace: dict[str, Any], extra_install_hint: str, ) -> Any: @@ -25,7 +20,10 @@ def load_lazy_attr( AttributeError: If the name is not a known lazy export. ImportError: If the target module cannot be imported. """ - module_path = lazy_imports.get(name) + 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}") diff --git a/src/mpp/extensions/mcp/__init__.py b/src/mpp/extensions/mcp/__init__.py index 8169b54..351cd2d 100644 --- a/src/mpp/extensions/mcp/__init__.py +++ b/src/mpp/extensions/mcp/__init__.py @@ -1,5 +1,3 @@ -# pyright: reportUnsupportedDunderAll=false - """MCP transport support for HTTP 402 Payment Authentication. This module implements the Payment Authentication Scheme for the Model Context @@ -62,7 +60,7 @@ async def expensive_tool(query: str, *, credential, receipt) -> str: from typing import Any -from mpp._lazy_exports import build_lazy_imports, load_lazy_attr +from mpp._lazy_exports import load_lazy_attr from mpp.extensions.mcp.constants import ( CODE_MALFORMED_CREDENTIAL, CODE_PAYMENT_REQUIRED, @@ -90,17 +88,6 @@ async def expensive_tool(query: str, *, credential, receipt) -> str: "mpp.extensions.mcp.verify": ("create_challenge", "verify_or_challenge"), } -_LAZY_IMPORTS = build_lazy_imports(_LAZY_EXPORTS) - -__all__ = [ - "CODE_MALFORMED_CREDENTIAL", - "CODE_PAYMENT_REQUIRED", - "CODE_PAYMENT_VERIFICATION_FAILED", - "META_CREDENTIAL", - "META_RECEIPT", - *_LAZY_IMPORTS, -] - def __getattr__(name: str) -> Any: - return load_lazy_attr(__name__, name, _LAZY_IMPORTS, globals(), _EXTRA_INSTALL_HINT) + return load_lazy_attr(__name__, name, _LAZY_EXPORTS, globals(), _EXTRA_INSTALL_HINT) diff --git a/src/mpp/methods/tempo/__init__.py b/src/mpp/methods/tempo/__init__.py index 18d0188..a7c6ff3 100644 --- a/src/mpp/methods/tempo/__init__.py +++ b/src/mpp/methods/tempo/__init__.py @@ -1,5 +1,3 @@ -# pyright: reportUnsupportedDunderAll=false - """Tempo payment method for HTTP 402 authentication. Example: @@ -30,7 +28,7 @@ from typing import Any -from mpp._lazy_exports import build_lazy_imports, load_lazy_attr +from mpp._lazy_exports import load_lazy_attr from mpp.methods.tempo._defaults import ( CHAIN_ID, ESCROW_CONTRACTS, @@ -49,19 +47,6 @@ "mpp.methods.tempo.intents": ("ChargeIntent",), } -_LAZY_IMPORTS = build_lazy_imports(_LAZY_EXPORTS) - -__all__ = [ - "CHAIN_ID", - "ESCROW_CONTRACTS", - "PATH_USD", - "TESTNET_CHAIN_ID", - "USDC", - "default_currency_for_chain", - "escrow_contract_for_chain", - *_LAZY_IMPORTS, -] - def __getattr__(name: str) -> Any: - return load_lazy_attr(__name__, name, _LAZY_IMPORTS, globals(), _EXTRA_INSTALL_HINT) + return load_lazy_attr(__name__, name, _LAZY_EXPORTS, globals(), _EXTRA_INSTALL_HINT) diff --git a/src/mpp/stores/__init__.py b/src/mpp/stores/__init__.py index 3aa66e1..a707c46 100644 --- a/src/mpp/stores/__init__.py +++ b/src/mpp/stores/__init__.py @@ -1,5 +1,3 @@ -# pyright: reportUnsupportedDunderAll=false - """Concrete store backends for replay protection. Available backends: @@ -16,13 +14,11 @@ _EXTRA_INSTALL_HINT = "Install the required store extra for this backend." -_LAZY_IMPORTS = { - "RedisStore": "mpp.stores.redis", - "SQLiteStore": "mpp.stores.sqlite", +_LAZY_EXPORTS = { + "mpp.stores.redis": ("RedisStore",), + "mpp.stores.sqlite": ("SQLiteStore",), } -__all__ = ["MemoryStore", *_LAZY_IMPORTS] - def __getattr__(name: str) -> Any: - return load_lazy_attr(__name__, name, _LAZY_IMPORTS, globals(), _EXTRA_INSTALL_HINT) + return load_lazy_attr(__name__, name, _LAZY_EXPORTS, globals(), _EXTRA_INSTALL_HINT)