mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-18 19:44:19 +02:00
A bare pyproject.toml in an examples/ subdir isn't evidence of an incompatible dependency graph -- most just declare extras (qrcode, phue2, atproto). Only a directory with its own uv.lock is genuinely independently resolved. Narrowing the exclusion brought 20 more files into scope and caught exactly the bug class the gate exists for: qr_server.py's `from mcp import types` / `mimeType=` are stale v1 idioms that would raise on the current SDK.
145 lines
5.9 KiB
Python
145 lines
5.9 KiB
Python
"""Import-resolution gate for the ``examples/`` directory.
|
|
|
|
The example scripts sit outside the ty and pytest import gates, so stale
|
|
SDK idioms (renamed modules, moved symbols, v1 import paths) shipped there
|
|
repeatedly without CI noticing. This test closes that gap: it discovers every
|
|
``.py`` under ``examples/`` and asserts, WITHOUT executing any of them, that
|
|
|
|
1. the file parses as valid Python (AST), and
|
|
2. every top-level ``fastmcp`` / ``mcp`` / ``mcp_types`` import resolves
|
|
against the installed package — both the module path and the imported
|
|
names. This catches the recurring ``from mcp.types import X`` regression
|
|
(the module is now ``mcp_types``) as well as renamed or moved symbols.
|
|
|
|
Execution is deliberately avoided: examples spin up servers, hit external
|
|
services, and pull heavy optional dependencies. Static resolution catches the
|
|
class of breakage we actually keep reintroducing (renamed imports) cheaply.
|
|
|
|
Standalone example sub-projects with their own ``uv.lock`` (e.g.
|
|
``examples/testing_demo`` targets v1 on purpose) are excluded — their
|
|
dependency graph is independently resolved, so their imports are validated
|
|
against a different package than the one installed here. A bare
|
|
``pyproject.toml`` without its own lock (e.g. ``examples/apps/qr_server``)
|
|
still resolves against this tree's install and is checked like any other
|
|
example — it is not, on its own, evidence of an incompatible dependency.
|
|
|
|
Run:
|
|
uv run pytest tests/test_examples_importable.py -v -s
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import ast
|
|
import importlib
|
|
from pathlib import Path
|
|
|
|
EXAMPLES_DIR = Path(__file__).parent.parent / "examples"
|
|
|
|
# Snapshot baseline — ratchet DOWN as examples are fixed, never up.
|
|
MAX_IMPORT_FAILURES = 0
|
|
|
|
_CHECKED_ROOTS = ("fastmcp", "mcp", "mcp_types")
|
|
|
|
|
|
def _standalone_dirs() -> set[Path]:
|
|
"""Directories that are self-contained example sub-projects.
|
|
|
|
A ``uv.lock`` under ``examples/`` marks a genuinely independent dependency
|
|
graph (``examples/testing_demo`` locks and targets fastmcp v1 on purpose),
|
|
so its imports must not be validated against the package installed for
|
|
the main test suite. A ``pyproject.toml`` alone is not sufficient — most
|
|
example sub-projects (``examples/apps/qr_server``, ``examples/smart_home``,
|
|
``examples/atproto_mcp``) have one purely to declare extra dependencies
|
|
(qrcode, phue2, atproto) but share this tree's fastmcp/mcp install, so
|
|
their fastmcp/mcp imports are still checked. Everything below a directory
|
|
with its own lock is excluded.
|
|
"""
|
|
return {lockfile.parent for lockfile in EXAMPLES_DIR.rglob("uv.lock")}
|
|
|
|
|
|
def _find_example_files() -> list[Path]:
|
|
standalone = _standalone_dirs()
|
|
|
|
def is_standalone(path: Path) -> bool:
|
|
return any(root in path.parents for root in standalone)
|
|
|
|
return sorted(p for p in EXAMPLES_DIR.rglob("*.py") if not is_standalone(p))
|
|
|
|
|
|
def _check_imports(path: Path) -> list[str]:
|
|
"""Return descriptions of unresolved fastmcp/mcp_types imports."""
|
|
try:
|
|
tree = ast.parse(path.read_text("utf-8"))
|
|
except SyntaxError as e:
|
|
rel = path.relative_to(EXAMPLES_DIR.parent)
|
|
return [f"{rel}:{e.lineno}: syntax error: {e.msg}"]
|
|
|
|
errors: list[str] = []
|
|
rel = path.relative_to(EXAMPLES_DIR.parent)
|
|
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, ast.Import):
|
|
for alias in node.names:
|
|
if alias.name.split(".")[0] in _CHECKED_ROOTS:
|
|
if _import_module(alias.name) is None:
|
|
errors.append(
|
|
f"{rel}:{node.lineno}: cannot import '{alias.name}'"
|
|
)
|
|
elif isinstance(node, ast.ImportFrom):
|
|
if node.level: # relative import — not a fastmcp/mcp_types path
|
|
continue
|
|
if node.module and node.module.split(".")[0] in _CHECKED_ROOTS:
|
|
mod = _import_module(node.module)
|
|
if mod is None:
|
|
errors.append(
|
|
f"{rel}:{node.lineno}: cannot import module '{node.module}'"
|
|
)
|
|
continue
|
|
for alias in node.names:
|
|
name = alias.name
|
|
if name == "*":
|
|
continue
|
|
# ``from pkg import sub`` where ``sub`` is itself a
|
|
# submodule resolves even though ``pkg`` has no such
|
|
# attribute until the submodule is imported.
|
|
if hasattr(mod, name) or _import_module(f"{node.module}.{name}"):
|
|
continue
|
|
errors.append(
|
|
f"{rel}:{node.lineno}: '{node.module}' has no '{name}'"
|
|
)
|
|
return errors
|
|
|
|
|
|
def _import_module(name: str):
|
|
"""Import ``name``, returning the module or None if it cannot be imported."""
|
|
try:
|
|
return importlib.import_module(name)
|
|
except ImportError:
|
|
return None
|
|
|
|
|
|
def test_examples_imports_resolve():
|
|
"""Example scripts must not regress in fastmcp/mcp_types import resolution.
|
|
|
|
Parses every ``.py`` under ``examples/`` (excluding standalone sub-projects
|
|
that pin their own fastmcp/mcp) and verifies its top-level fastmcp and
|
|
mcp_types imports resolve against the installed package. Nothing is
|
|
executed.
|
|
"""
|
|
files = _find_example_files()
|
|
assert files, "no example files discovered — check EXAMPLES_DIR"
|
|
|
|
import_failures: list[str] = []
|
|
for path in files:
|
|
import_failures.extend(_check_imports(path))
|
|
|
|
print(f"\nExample files checked: {len(files)}")
|
|
print(f"Import failures: {len(import_failures)}")
|
|
if import_failures:
|
|
print("\nUnresolved imports:")
|
|
for failure in import_failures:
|
|
print(f" {failure}")
|
|
|
|
assert len(import_failures) <= MAX_IMPORT_FAILURES, (
|
|
f"Import failures regressed: {len(import_failures)} > {MAX_IMPORT_FAILURES}"
|
|
)
|