mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 22:47:16 +01:00
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
94 lines
3.4 KiB
Python
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"]
|