mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-06-16 09:45:24 -04:00
test: add focused test selection runner (#3556)
This commit is contained in:
committed by
GitHub
parent
9180847c0e
commit
d4ab09e8e1
@@ -33,6 +33,20 @@ the sub-area. The `area_*` names are registered in `pyproject.toml`; the dynamic
|
||||
`sub_*` names are registered before collection by `pytest_configure` in
|
||||
`tests/conftest.py`, so unknown-mark warnings still flag genuine typos.
|
||||
|
||||
For common focused runs, use `tests/run_focus.py`. It validates area and
|
||||
sub-area names, accepts sub-areas with or without the `sub_` prefix, and passes
|
||||
extra pytest arguments after `--`:
|
||||
|
||||
```bash
|
||||
python3 tests/run_focus.py --area security
|
||||
python3 tests/run_focus.py --area services --sub-area cookbook
|
||||
python3 tests/run_focus.py --sub-area sub_cookbook
|
||||
python3 tests/run_focus.py --keyword taxonomy
|
||||
python3 tests/run_focus.py --last-failed
|
||||
python3 tests/run_focus.py --dry-run --area services --sub-area cookbook
|
||||
python3 tests/run_focus.py --area services -- --maxfail=1 -q
|
||||
```
|
||||
|
||||
## Core principles
|
||||
|
||||
- Keep PRs small and homogeneous: one kind of change per PR.
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Focused test selection runner for the pytest taxonomy markers (issue #3442).
|
||||
|
||||
This wraps ``pytest -m`` selection over the ``area_*`` / ``sub_*`` markers that
|
||||
``tests/conftest.py`` adds at collection time (issue #3491) so focused
|
||||
validation is repeatable and less error-prone than hand-written marker
|
||||
expressions. It builds a pytest command line and either prints it (``--dry-run``)
|
||||
or runs it.
|
||||
|
||||
Examples:
|
||||
tests/run_focus.py --area security
|
||||
tests/run_focus.py --area services --sub-area cookbook
|
||||
tests/run_focus.py --keyword taxonomy -- --maxfail=1 -q
|
||||
|
||||
This script imports no production code and changes no test behavior. It only
|
||||
constructs and (optionally) executes a pytest invocation.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
from collections.abc import Callable, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
TESTS_DIR = Path(__file__).resolve().parent
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from tests._taxonomy import discover_markers, normalize_marker_name # noqa: E402
|
||||
|
||||
# The canonical taxonomy areas, mirroring the ``area_*`` markers declared in
|
||||
# pyproject.toml and produced by tests/_taxonomy.py.
|
||||
AREAS: tuple[str, ...] = (
|
||||
"security",
|
||||
"routes",
|
||||
"services",
|
||||
"cli",
|
||||
"js",
|
||||
"helpers",
|
||||
"unit",
|
||||
"uncategorized",
|
||||
)
|
||||
|
||||
|
||||
def normalize_sub_area(value: str) -> str:
|
||||
"""Normalize a CLI sub-area value and remove an optional ``sub_`` prefix."""
|
||||
token = normalize_marker_name(value)
|
||||
if token.startswith("sub_"):
|
||||
token = token.removeprefix("sub_")
|
||||
if not token:
|
||||
raise argparse.ArgumentTypeError(
|
||||
f"invalid sub-area {value!r}: must contain at least one letter or digit"
|
||||
)
|
||||
return token
|
||||
|
||||
|
||||
def discover_sub_areas(tests_dir: Path = TESTS_DIR) -> frozenset[str]:
|
||||
"""Discover valid taxonomy sub-areas from Python test filenames."""
|
||||
paths = list(tests_dir.rglob("test_*.py"))
|
||||
paths += list(tests_dir.rglob("*_test.py"))
|
||||
markers = discover_markers(paths)
|
||||
return frozenset(
|
||||
marker.removeprefix("sub_")
|
||||
for marker in markers
|
||||
if marker.startswith("sub_")
|
||||
)
|
||||
|
||||
|
||||
def sub_area_type(valid_sub_areas: frozenset[str]) -> Callable[[str], str]:
|
||||
"""Build an argparse converter that accepts only discovered sub-areas."""
|
||||
|
||||
def validate(value: str) -> str:
|
||||
sub_area = normalize_sub_area(value)
|
||||
if sub_area not in valid_sub_areas:
|
||||
raise argparse.ArgumentTypeError(
|
||||
f"unknown sub-area {value!r}; choose a discovered taxonomy sub-area"
|
||||
)
|
||||
return sub_area
|
||||
|
||||
return validate
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FocusSelection:
|
||||
"""A single focused-selection request, decoupled from argparse and pytest."""
|
||||
|
||||
area: str | None = None
|
||||
sub_area: str | None = None
|
||||
keyword: str | None = None
|
||||
last_failed: bool = False
|
||||
pytest_args: tuple[str, ...] = field(default_factory=tuple)
|
||||
|
||||
@property
|
||||
def has_focus(self) -> bool:
|
||||
"""True when at least one focusing selector (not just pass-through) is set."""
|
||||
return bool(self.area or self.sub_area or self.keyword or self.last_failed)
|
||||
|
||||
|
||||
def build_marker_expression(area: str | None, sub_area: str | None) -> str | None:
|
||||
"""Build the ``-m`` marker expression from an area and/or sub-area.
|
||||
|
||||
Returns ``None`` when neither is given so the caller can omit ``-m``.
|
||||
"""
|
||||
parts: list[str] = []
|
||||
if area:
|
||||
parts.append(f"area_{area}")
|
||||
if sub_area:
|
||||
parts.append(f"sub_{sub_area}")
|
||||
if not parts:
|
||||
return None
|
||||
return " and ".join(parts)
|
||||
|
||||
|
||||
def build_pytest_command(
|
||||
selection: FocusSelection, python: str | None = None
|
||||
) -> list[str]:
|
||||
"""Build the pytest argv list for ``selection``.
|
||||
|
||||
No shell is involved; the result is a plain argv list for subprocess. The
|
||||
interpreter defaults to the one running this script (the project venv when
|
||||
invoked as ``.venv/bin/python tests/run_focus.py``).
|
||||
"""
|
||||
command = [python or sys.executable, "-m", "pytest"]
|
||||
marker_expression = build_marker_expression(selection.area, selection.sub_area)
|
||||
if marker_expression:
|
||||
command += ["-m", marker_expression]
|
||||
if selection.keyword:
|
||||
command += ["-k", selection.keyword]
|
||||
if selection.last_failed:
|
||||
command += ["--last-failed", "--last-failed-no-failures=none"]
|
||||
command += list(selection.pytest_args)
|
||||
return command
|
||||
|
||||
|
||||
def selection_from_args(namespace: argparse.Namespace) -> FocusSelection:
|
||||
"""Convert parsed argparse values into a ``FocusSelection``."""
|
||||
return FocusSelection(
|
||||
area=namespace.area,
|
||||
sub_area=namespace.sub_area,
|
||||
keyword=namespace.keyword,
|
||||
last_failed=namespace.last_failed,
|
||||
pytest_args=tuple(namespace.pytest_args),
|
||||
)
|
||||
|
||||
|
||||
def build_parser(
|
||||
valid_sub_areas: frozenset[str] | None = None,
|
||||
) -> argparse.ArgumentParser:
|
||||
"""Build the argument parser for the focused runner."""
|
||||
if valid_sub_areas is None:
|
||||
valid_sub_areas = discover_sub_areas()
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="run_focus.py",
|
||||
description=(
|
||||
"Run a focused subset of the test suite using the area_*/sub_* "
|
||||
"taxonomy markers. Combine --area and --sub-area to intersect them."
|
||||
),
|
||||
epilog=(
|
||||
"Pass extra pytest arguments after a literal -- separator, e.g.: "
|
||||
"run_focus.py --area services -- --maxfail=1 -q"
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--area",
|
||||
choices=AREAS,
|
||||
help="select tests in one taxonomy area (marker area_<area>)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--sub-area",
|
||||
type=sub_area_type(valid_sub_areas),
|
||||
metavar="NAME",
|
||||
help="select tests in a sub-area (marker sub_<name>); combinable with --area",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-k",
|
||||
"--keyword",
|
||||
help="pass a keyword expression through to pytest -k",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--last-failed",
|
||||
action="store_true",
|
||||
help="re-run only tests that failed on the last run (pytest --last-failed)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="print the pytest command without executing it",
|
||||
)
|
||||
parser.add_argument(
|
||||
"pytest_args",
|
||||
nargs="*",
|
||||
metavar="-- PYTEST_ARGS",
|
||||
help="extra arguments forwarded to pytest after a literal --",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def run(
|
||||
argv: Sequence[str] | None = None,
|
||||
executor: Callable[[list[str]], int] = subprocess.call,
|
||||
) -> int:
|
||||
"""Parse ``argv``, build the pytest command, and run or print it.
|
||||
|
||||
``executor`` is injected so tests can assert on the constructed command
|
||||
without spawning a process. It must accept an argv list and return an exit
|
||||
code, matching ``subprocess.call``.
|
||||
"""
|
||||
parser = build_parser()
|
||||
namespace = parser.parse_args(argv)
|
||||
selection = selection_from_args(namespace)
|
||||
if not selection.has_focus:
|
||||
parser.error(
|
||||
"no focus selected: pass at least one of --area, --sub-area, "
|
||||
"--keyword, or --last-failed"
|
||||
)
|
||||
command = build_pytest_command(selection)
|
||||
if namespace.dry_run:
|
||||
print(shlex.join(command))
|
||||
return 0
|
||||
return executor(command)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Console entry point."""
|
||||
return run(sys.argv[1:])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,218 @@
|
||||
"""Direct tests for the focused test-selection runner (tests/run_focus.py).
|
||||
|
||||
Command construction is tested separately from process execution: the pure
|
||||
builder functions are asserted directly, and ``run`` is exercised with an
|
||||
injected fake executor so no pytest subprocess is ever spawned.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.run_focus import (
|
||||
FocusSelection,
|
||||
build_marker_expression,
|
||||
build_pytest_command,
|
||||
discover_sub_areas,
|
||||
normalize_sub_area,
|
||||
run,
|
||||
)
|
||||
|
||||
PY = "PY" # placeholder interpreter for deterministic command assertions
|
||||
|
||||
|
||||
def _cmd(**kwargs) -> list[str]:
|
||||
"""Build a pytest command for a FocusSelection made from kwargs."""
|
||||
return build_pytest_command(FocusSelection(**kwargs), python=PY)
|
||||
|
||||
|
||||
# --- marker expression building -------------------------------------------
|
||||
|
||||
|
||||
def test_area_only_marker_expression():
|
||||
assert build_marker_expression("security", None) == "area_security"
|
||||
|
||||
|
||||
def test_sub_area_only_marker_expression():
|
||||
assert build_marker_expression(None, "cookbook") == "sub_cookbook"
|
||||
|
||||
|
||||
def test_area_and_sub_area_marker_expression():
|
||||
assert build_marker_expression("services", "cookbook") == "area_services and sub_cookbook"
|
||||
|
||||
|
||||
def test_no_selection_marker_expression_is_none():
|
||||
assert build_marker_expression(None, None) is None
|
||||
|
||||
|
||||
# --- command construction --------------------------------------------------
|
||||
|
||||
|
||||
def test_area_only_command():
|
||||
assert _cmd(area="security") == [PY, "-m", "pytest", "-m", "area_security"]
|
||||
|
||||
|
||||
def test_sub_area_only_command():
|
||||
assert _cmd(sub_area="cookbook") == [PY, "-m", "pytest", "-m", "sub_cookbook"]
|
||||
|
||||
|
||||
def test_area_and_sub_area_command():
|
||||
assert _cmd(area="services", sub_area="cookbook") == [
|
||||
PY, "-m", "pytest", "-m", "area_services and sub_cookbook",
|
||||
]
|
||||
|
||||
|
||||
def test_keyword_only_command():
|
||||
assert _cmd(keyword="taxonomy") == [PY, "-m", "pytest", "-k", "taxonomy"]
|
||||
|
||||
|
||||
def test_area_and_keyword_command():
|
||||
assert _cmd(area="services", keyword="cookbook") == [
|
||||
PY, "-m", "pytest", "-m", "area_services", "-k", "cookbook",
|
||||
]
|
||||
|
||||
|
||||
def test_passthrough_pytest_args_appended_last():
|
||||
command = _cmd(area="services", pytest_args=("--maxfail=1", "-q"))
|
||||
assert command == [PY, "-m", "pytest", "-m", "area_services", "--maxfail=1", "-q"]
|
||||
|
||||
|
||||
def test_last_failed_appends_safe_flags():
|
||||
assert _cmd(last_failed=True) == [
|
||||
PY,
|
||||
"-m",
|
||||
"pytest",
|
||||
"--last-failed",
|
||||
"--last-failed-no-failures=none",
|
||||
]
|
||||
|
||||
|
||||
def test_default_python_is_current_interpreter():
|
||||
command = build_pytest_command(FocusSelection(area="cli"))
|
||||
assert command[0] == sys.executable
|
||||
|
||||
|
||||
# --- sub-area normalization ------------------------------------------------
|
||||
|
||||
|
||||
def test_normalize_sub_area_lowercases_and_collapses():
|
||||
assert normalize_sub_area("Cook Book") == "cook_book"
|
||||
|
||||
|
||||
def test_normalize_sub_area_strips_separators():
|
||||
assert normalize_sub_area("--owner.scope--") == "owner_scope"
|
||||
|
||||
|
||||
def test_normalize_sub_area_removes_marker_prefix():
|
||||
assert normalize_sub_area("sub_cookbook") == "cookbook"
|
||||
|
||||
|
||||
def test_normalize_sub_area_rejects_empty_after_normalization():
|
||||
with pytest.raises(argparse.ArgumentTypeError):
|
||||
normalize_sub_area("!!!")
|
||||
|
||||
|
||||
def test_discover_sub_areas_from_test_filename(tmp_path):
|
||||
(tmp_path / "test_cookbook_helpers.py").write_text("", encoding="utf-8")
|
||||
|
||||
assert discover_sub_areas(tmp_path) == frozenset({"cookbook"})
|
||||
|
||||
|
||||
# --- run(): dry-run, execution, validation ---------------------------------
|
||||
|
||||
|
||||
class _FakeExecutor:
|
||||
"""Records the command it was asked to run and returns a fixed code."""
|
||||
|
||||
def __init__(self, returncode: int = 0):
|
||||
self.returncode = returncode
|
||||
self.calls: list[list[str]] = []
|
||||
|
||||
def __call__(self, command: list[str]) -> int:
|
||||
self.calls.append(command)
|
||||
return self.returncode
|
||||
|
||||
|
||||
def test_dry_run_prints_command_and_does_not_execute(capsys):
|
||||
executor = _FakeExecutor()
|
||||
code = run(
|
||||
["--dry-run", "--area", "services", "--sub-area", "cookbook"],
|
||||
executor=executor,
|
||||
)
|
||||
out = capsys.readouterr().out
|
||||
assert code == 0
|
||||
assert executor.calls == []
|
||||
assert out == (
|
||||
f"{sys.executable} -m pytest "
|
||||
"-m 'area_services and sub_cookbook'\n"
|
||||
)
|
||||
|
||||
|
||||
def test_dry_run_last_failed_prints_safe_flags(capsys):
|
||||
executor = _FakeExecutor()
|
||||
code = run(["--dry-run", "--last-failed"], executor=executor)
|
||||
out = capsys.readouterr().out
|
||||
assert code == 0
|
||||
assert executor.calls == []
|
||||
assert out == (
|
||||
f"{sys.executable} -m pytest "
|
||||
"--last-failed --last-failed-no-failures=none\n"
|
||||
)
|
||||
|
||||
|
||||
def test_run_invokes_executor_with_built_command():
|
||||
executor = _FakeExecutor(returncode=3)
|
||||
code = run(["--keyword", "taxonomy", "--", "--maxfail=1"], executor=executor)
|
||||
assert code == 3
|
||||
assert executor.calls == [[sys.executable, "-m", "pytest", "-k", "taxonomy", "--maxfail=1"]]
|
||||
|
||||
|
||||
def test_run_last_failed_only():
|
||||
executor = _FakeExecutor()
|
||||
run(["--last-failed"], executor=executor)
|
||||
assert executor.calls == [[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"pytest",
|
||||
"--last-failed",
|
||||
"--last-failed-no-failures=none",
|
||||
]]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["cookbook", "sub_cookbook"])
|
||||
def test_run_accepts_both_sub_area_forms(value):
|
||||
executor = _FakeExecutor()
|
||||
run(["--sub-area", value], executor=executor)
|
||||
assert executor.calls == [[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"pytest",
|
||||
"-m",
|
||||
"sub_cookbook",
|
||||
]]
|
||||
|
||||
|
||||
def test_invalid_area_exits_with_error():
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
run(["--area", "bogus"], executor=_FakeExecutor())
|
||||
assert excinfo.value.code == 2
|
||||
|
||||
|
||||
def test_invalid_sub_area_exits_with_error(capsys):
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
run(
|
||||
["--sub-area", "definitely_not_a_real_sub_area"],
|
||||
executor=_FakeExecutor(),
|
||||
)
|
||||
assert excinfo.value.code == 2
|
||||
assert "unknown sub-area" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_no_focus_selector_is_rejected():
|
||||
executor = _FakeExecutor()
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
run(["--", "-q"], executor=executor)
|
||||
assert excinfo.value.code == 2
|
||||
assert executor.calls == []
|
||||
Reference in New Issue
Block a user