signal-bot/tests/test_external.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

94 lines
3.4 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""The single subprocess site: every way a command can fail, reported as one.
The property under test is that nothing here raises and nothing looks like it
worked when it did not. An exit code that lied is what put ffmpeg in this
codebase's history, so "ok" has to mean ok.
"""
from __future__ import annotations
import stat
from pathlib import Path
from typing import TYPE_CHECKING
from signal_bot.external import DEFAULT_TIMEOUT, run
if TYPE_CHECKING:
import pytest
def _script(tmp_path: Path, name: str, body: str) -> str:
path = tmp_path / name
path.write_text(f"#!/usr/bin/env python3\nimport sys\n{body}\n", encoding="utf-8")
path.chmod(path.stat().st_mode | stat.S_IEXEC)
return str(path)
def test_a_command_that_succeeds_reports_its_output(tmp_path: Path) -> None:
script = _script(tmp_path, "ok", 'sys.stdout.write("hello")')
result = run([script])
assert result.ok
assert result.stdout == "hello"
def test_arguments_are_passed_through(tmp_path: Path) -> None:
script = _script(tmp_path, "echo", 'sys.stdout.write(" ".join(sys.argv[1:]))')
assert run([script, "-all=", "-o", "out.jpg"]).stdout == "-all= -o out.jpg"
def test_a_nonzero_exit_is_not_ok(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
script = _script(tmp_path, "fails", 'sys.stderr.write("no")\nsys.exit(3)')
result = run([script])
assert not result.ok
assert "exited 3" in caplog.text
def test_a_binary_that_is_not_there_is_not_ok(tmp_path: Path) -> None:
result = run([str(tmp_path / "nothing-here")])
assert not result.ok
assert result.stdout == ""
def test_a_command_that_hangs_is_killed_and_not_ok(tmp_path: Path) -> None:
script = _script(tmp_path, "hangs", "import time\ntime.sleep(30)")
result = run([script], timeout=0.2)
assert not result.ok
def test_the_failure_log_never_prints_the_command_output(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""exiftool prints tag *values* on failure; a journal must not carry them."""
script = _script(tmp_path, "leaks", 'sys.stdout.write("GPS 52.2297 21.0122")\nsys.exit(1)')
run([script])
assert "52.2297" not in caplog.text
def test_the_default_timeout_is_long_enough_for_a_video() -> None:
assert DEFAULT_TIMEOUT >= 30
def test_it_is_the_only_module_that_imports_subprocess() -> None:
"""Two files may shell out, and the list is closed.
A further file would need a third S603 exemption, and the point of naming
them here is that adding one is a visible decision rather than a habit
that spreads.
tool/venv_bootstrap.py is the second, added on kuhy's call of 2026-08-31.
It cannot route through this module: it installs the package this module
is part of, and runs on an interpreter where that package does not import
yet. That is the only reason it qualifies -- being inconvenient is not.
"""
here = Path(__file__).resolve()
root = here.parent.parent
importers = sorted(
str(path.relative_to(root))
for directory in ("signal_bot", "fake_signal", "tool", "scripts", "tests")
for path in (root / directory).rglob("*.py")
# This file names the import in order to look for it.
if path != here and "import subprocess" in path.read_text(encoding="utf-8")
)
assert importers == ["signal_bot/external.py", "tool/venv_bootstrap.py"]