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
34 changes: 34 additions & 0 deletions ast_rag/_optional.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
"""Guards for entry points whose dependencies live in an optional extra.

``[project.scripts]`` installs every console script unconditionally, so
``ast-rag-mcp`` and ``ast-rag-watch`` land on the PATH of a user who ran a
plain ``pip install ast-rag``. Without a guard they die at import with a
``ModuleNotFoundError`` that never mentions the extra that fixes it.
"""

from __future__ import annotations

import importlib.util
from typing import Callable, NoReturn


def module_available(name: str) -> bool:
"""Whether *name* can be imported, without importing it."""
try:
return importlib.util.find_spec(name) is not None
except (ImportError, ValueError):
return False


def missing_dependency_entry_point(
module: str, *, feature: str, extra: str
) -> Callable[..., NoReturn]:
"""Build a ``main`` that names the extra to install and exits non-zero."""

def main(*_args, **_kwargs) -> NoReturn:
raise SystemExit(
f"{feature} needs the optional '{module}' package, which is not installed.\n"
f"Install it with: pip install 'ast-rag[{extra}]'"
)

return main
9 changes: 7 additions & 2 deletions ast_rag/ast_rag_mcp.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
"""Compatibility shim for the legacy MCP module path."""

from ast_rag.mcp.server import * # noqa: F403
from ast_rag.mcp.server import main # noqa: F401
from ast_rag._optional import missing_dependency_entry_point, module_available

if module_available("mcp"):
from ast_rag.mcp.server import * # noqa: F403
from ast_rag.mcp.server import main # noqa: F401
else: # the `mcp` extra is not installed; exercised in a subprocess by the tests
main = missing_dependency_entry_point("mcp", feature="The AST-RAG MCP server", extra="mcp")
11 changes: 9 additions & 2 deletions ast_rag/watcher.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
"""Compatibility shim for the legacy watcher module path."""

from ast_rag.services.watcher_service import * # noqa: F403
from ast_rag.services.watcher_service import main # noqa: F401
from ast_rag._optional import missing_dependency_entry_point, module_available

if module_available("watchdog"):
from ast_rag.services.watcher_service import * # noqa: F403
from ast_rag.services.watcher_service import main # noqa: F401
else: # the `mcp` extra is not installed; exercised in a subprocess by the tests
main = missing_dependency_entry_point(
"watchdog", feature="The AST-RAG file watcher", extra="mcp"
)
98 changes: 98 additions & 0 deletions tests/test_console_scripts_without_extras.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
"""Every console script must survive a plain ``pip install ast-rag``.

``[project.scripts]`` puts ``ast-rag-mcp`` and ``ast-rag-watch`` on the user's
PATH unconditionally, but ``mcp`` and ``watchdog`` ship only in the ``[mcp]``
extra. After the documented plain install both commands died at import with a
bare ``ModuleNotFoundError`` traceback that never names the extra that fixes
it.

``test_optional_watchdog`` pins the same rule for ``ast_rag.cli`` and
``ast_rag.services``; this covers the two entry points it does not reach.
"""

from __future__ import annotations

import re
import subprocess
import sys
from pathlib import Path

import pytest

PYPROJECT = Path(__file__).parent.parent / "pyproject.toml"

# Top-level distributions that live only in an optional extra.
OPTIONAL_MODULES = ("mcp", "watchdog")

_BLOCKER = f"""
import sys
_BLOCKED = {OPTIONAL_MODULES!r}


class _Missing:
def find_spec(self, name, path=None, target=None):
top = name.split(".")[0]
if top in _BLOCKED:
raise ModuleNotFoundError("No module named %r" % top, name=top)
return None


sys.meta_path.insert(0, _Missing())
"""


def _console_scripts() -> dict[str, str]:
"""``{script name: "module:attr"}`` from ``[project.scripts]``."""
text = PYPROJECT.read_text()
block = re.search(r"^\[project\.scripts\]\n(.*?)(?=^\[|\Z)", text, re.M | re.S)
assert block, "pyproject.toml has no [project.scripts] table"
return dict(re.findall(r'^(\S+)\s*=\s*"([^"]+)"', block.group(1), re.M))


def _run_without_extras(code: str) -> subprocess.CompletedProcess:
return subprocess.run(
[sys.executable, "-c", _BLOCKER + code],
capture_output=True,
text=True,
timeout=120,
check=False,
)


@pytest.mark.parametrize("script,target", sorted(_console_scripts().items()))
def test_console_script_imports_without_optional_extras(script: str, target: str):
"""Resolving the entry point must not need an optional dependency."""
module, _, attr = target.partition(":")
result = _run_without_extras(
f"import importlib;"
f"m = importlib.import_module({module!r});"
f"getattr(m, {attr!r});"
f"print('resolved')"
)
assert result.returncode == 0, (
f"`{script}` cannot even be resolved after a plain install:\n{result.stderr}"
)
assert "resolved" in result.stdout


@pytest.mark.parametrize(
"script,target,extra",
[
("ast-rag-mcp", "ast_rag.ast_rag_mcp:main", "mcp"),
("ast-rag-watch", "ast_rag.watcher:main", "mcp"),
],
)
def test_optional_script_names_the_extra_instead_of_crashing(script: str, target: str, extra: str):
"""Running it without the extra must say how to install it."""
module, _, attr = target.partition(":")
result = _run_without_extras(
f"import importlib;getattr(importlib.import_module({module!r}), {attr!r})()"
)
output = result.stdout + result.stderr

assert result.returncode != 0, f"`{script}` reported success without {extra!r} installed"
assert "Traceback" not in output, (
f"`{script}` failed with a raw traceback instead of install guidance:\n{output}"
)
assert "pip install" in output, f"`{script}` did not tell the user how to fix it:\n{output}"
assert f"ast-rag[{extra}]" in output, f"`{script}` did not name the `{extra}` extra:\n{output}"
Loading