From f1e5887bf4371194de6cf03aa83dac7b37746dce Mon Sep 17 00:00:00 2001 From: Rohit Behera <126186063+r0h1tb@users.noreply.github.com> Date: Tue, 25 Aug 2026 01:56:20 +0530 Subject: [PATCH] fix(packaging): name the extra when an optional entry point is missing `[project.scripts]` installs `ast-rag-mcp` and `ast-rag-watch` on every `pip install ast-rag`, but `mcp` and `watchdog` ship only in the `[mcp]` extra. Both commands died at import with a bare ModuleNotFoundError that never mentions the extra that fixes it. Guard each shim on `find_spec` and fall back to a `main` that prints the install line. `ast_rag.cli` was already fixed this way; this covers the two entry points that test did not reach. --- ast_rag/_optional.py | 34 +++++++ ast_rag/ast_rag_mcp.py | 9 +- ast_rag/watcher.py | 11 ++- tests/test_console_scripts_without_extras.py | 98 ++++++++++++++++++++ 4 files changed, 148 insertions(+), 4 deletions(-) create mode 100644 ast_rag/_optional.py create mode 100644 tests/test_console_scripts_without_extras.py diff --git a/ast_rag/_optional.py b/ast_rag/_optional.py new file mode 100644 index 0000000..bfbdd4f --- /dev/null +++ b/ast_rag/_optional.py @@ -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 diff --git a/ast_rag/ast_rag_mcp.py b/ast_rag/ast_rag_mcp.py index 7bbafa8..c4bcc8a 100644 --- a/ast_rag/ast_rag_mcp.py +++ b/ast_rag/ast_rag_mcp.py @@ -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") diff --git a/ast_rag/watcher.py b/ast_rag/watcher.py index aefb392..4d85bc6 100644 --- a/ast_rag/watcher.py +++ b/ast_rag/watcher.py @@ -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" + ) diff --git a/tests/test_console_scripts_without_extras.py b/tests/test_console_scripts_without_extras.py new file mode 100644 index 0000000..ca65489 --- /dev/null +++ b/tests/test_console_scripts_without_extras.py @@ -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}"