signal-bot/tests/test_tool_imports.py
Krzysztof kuhy Rudnicki d3d89c890e feat: python tool/tagger.py sets up .venv too, not just tag.sh
The shell entry points got this in 99aa194; running the .py directly still
died with ModuleNotFoundError, which is the command the file's own docstring
had been telling people to use for months.

tool/venv_bootstrap.py creates .venv, installs the project and re-executes
the same command line through it. It is stdlib-only by necessity: it runs on
an interpreter where this package does not import yet, so it cannot route
through signal_bot.external, the one module allowed to start a process. That
is why it gets the second scoped per-file-ignore rather than the rule being
bent -- kuhy's call. test_external.py now asserts a closed list of two, so a
third file still fails the gate.

Two things that are less obvious than they look:

It runs on import rather than exposing a function to call. A call would have
to happen before the caller's own third-party imports, and code before
imports is E402 -- so a call-based design needs a lint suppression in every
file it protects. Importing the module is the whole interface.

And isort would sort "venv_bootstrap" after "fastapi", silently breaking the
ordering the module depends on. It gets its own isort section ahead of the
standard library, so the formatter enforces the order instead of undoing it.

Caught while verifying: ensure() used one check to decide both whether to
re-exec and whether to install. The interpreter you typed never grows a
fastapi, so every launch reinstalled the project first. Splitting them
(ready() probes the venv, missing() probes the caller) takes a warm run from
a full pip install to 0.47s. Pinned by a test.

Verified on a clone with no .venv: first run installs and prints one line,
second and third print nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WBrE1b9HvFw3xvG9bJiDFt
2026-08-31 13:34:27 +02:00

112 lines
4.5 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Every script under tool/ imports, and the tagger answers --help.
This exists because ``tool/tagger.py`` shipped an unconditional ImportError
and survived two releases with it: commit 48957a1 renamed
``pack.default_dir`` to ``default_dirs`` and missed the caller. Nothing could
have caught it. ``tool/`` is outside ``--cov`` in pyproject.toml, mypy ran
with ``args: [signal_bot, fake_signal]``, and ruff lints these files but
cannot resolve a name in another module.
So this is deliberately the cheapest possible test: import each file and, for
the one with a CLI, run it. It adds no coverage burden -- ``tests/`` is not
measured -- and no module-scope ImportError can survive it.
"""
from __future__ import annotations
import importlib.util
import sys
from pathlib import Path
import pytest
from signal_bot.external import run
TOOL = Path(__file__).resolve().parent.parent / "tool"
def _modules() -> list[Path]:
"""Every Python file under tool/, so a new one is covered automatically."""
return sorted(path for path in TOOL.glob("*.py") if path.name != "__init__.py")
def test_there_is_something_to_check() -> None:
# Guards against the glob silently matching nothing and the whole file
# passing vacuously.
assert _modules()
def _load(stem: str) -> object:
"""Import one tool/ module by path, the way the parametrised test does."""
spec = importlib.util.spec_from_file_location(f"_probe_{stem}", TOOL / f"{stem}.py")
assert spec is not None
assert spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
@pytest.mark.parametrize("path", _modules(), ids=lambda path: path.name)
def test_the_module_imports(path: Path) -> None:
spec = importlib.util.spec_from_file_location(f"_tool_{path.stem}", path)
assert spec is not None
assert spec.loader is not None
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
# tool/ is a directory of scripts, not a package, and a couple of them
# import a sibling by bare name. That resolves in real use -- running
# "python tool/x.py" puts tool/ at sys.path[0] -- so the harness has to
# reproduce it rather than the scripts having to work around it.
sys.path.insert(0, str(TOOL))
try:
spec.loader.exec_module(module)
finally:
sys.path.remove(str(TOOL))
sys.modules.pop(spec.name, None)
def test_the_tagger_answers_help() -> None:
# Through signal_bot.external, like every other subprocess here: S603 is
# suppressed for exactly one file and widening it is what that entry
# exists to prevent.
result = run([sys.executable, str(TOOL / "tagger.py"), "--help"])
assert result.ok, result.stderr
for flag in ("--import", "--untagged", "--dry-run"):
assert flag in result.stdout
def test_the_bootstrap_reports_what_this_interpreter_cannot_import() -> None:
# The whole decision rests on this: an interpreter that can already
# import everything must be left alone, or every run pays for a venv it
# does not need.
bootstrap = _load("venv_bootstrap")
assert bootstrap.missing() == []
assert bootstrap.missing(("definitely_not_a_real_module",)) == ["definitely_not_a_real_module"]
# A name that is not importable *and* not a valid spec query has to be
# reported, not raised: find_spec raises ValueError for some of them.
assert bootstrap.missing((".relative",)) == [".relative"]
def test_the_bootstrap_module_is_stdlib_only() -> None:
# It runs before .venv exists, so a third-party import here would be the
# same bug one level down.
source = (TOOL / "venv_bootstrap.py").read_text(encoding="utf-8")
for banned in ("import fastapi", "import httpx", "import uvicorn", "from signal_bot"):
assert banned not in source
def test_the_bootstrap_only_installs_when_the_venv_cannot_already_do_it(
tmp_path: Path,
) -> None:
# Measured bug: ensure() tested the *current* interpreter to decide both
# whether to re-exec and whether to install. The interpreter you typed
# never grows a fastapi, so every single launch reinstalled the project
# before re-execing. The two decisions are separate.
bootstrap = _load("venv_bootstrap")
bootstrap.VENV_PYTHON = tmp_path / "nonexistent" / "python"
assert bootstrap.ready() is False
bootstrap.VENV_PYTHON = Path(sys.executable)
assert bootstrap.ready(("sys",)) is True
assert bootstrap.ready(("definitely_not_a_real_module",)) is False