testsAndMisc/python_pkg/code_tutor/_pytest_runner.py
Krzysztof kuhy Rudnicki 40a4eaf5b5 Split the code_tutor pytest runner and verdict streaming out
_challenge_support.py 402 -> 237. _pytest_clean and _patch_and_test go
to _pytest_runner.py with the subprocess/os/sys imports they solely used;
_stream_verdict and _parse_verdict go to _verdict.py with rich's Live.

code_tutor patches by absolute string rather than a MOD constant, so the
seam cost 15 per-name rewrites instead of one sed. Two intermediate
failures caught by running it: the test modules import the moved names
directly, and five patch strings still named the old module.

Pass count is 216 before and after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011w2Pj4Ek3ZNMQVYcFHgXDY
2026-08-16 17:33:58 +02:00

112 lines
3.2 KiB
Python

"""Running the user's tests in an isolated pytest subprocess.
Split out of :mod:`python_pkg.code_tutor._challenge_support` to keep it under
the 250-line cap. ``_challenge`` imports both entry points back, because
``tests/test_challenge_part3.py`` patches them on that module.
"""
from __future__ import annotations
import ast
import os
from pathlib import Path
import subprocess
import sys
from typing import TYPE_CHECKING
from python_pkg.code_tutor._challenge_support import _project_root
if TYPE_CHECKING:
from rich.console import Console
from python_pkg.code_tutor._analyzer import CodeItem
def _pytest_clean(
test_ids_or_file: list[str],
project_root: Path,
console: Console,
extra_env: dict[str, str] | None = None,
) -> bool:
"""Run pytest on *test_ids_or_file* and return True if all pass.
Coverage is disabled so the project's ``fail_under`` threshold does not
interfere with isolated challenge runs.
Args:
test_ids_or_file: Pytest node IDs or a single temp-file path.
project_root: Working directory for the pytest subprocess.
console: Rich console for output.
extra_env: Extra environment variables (e.g. PYTHONPATH override).
Returns:
``True`` when all collected tests pass.
"""
env = {**os.environ, **(extra_env or {})}
result = subprocess.run(
[
sys.executable,
"-m",
"pytest",
*test_ids_or_file,
"-v",
"--tb=short",
"--no-header",
"-p",
"no:cov",
"--override-ini=addopts=",
],
capture_output=True,
text=True,
cwd=str(project_root),
env=env,
check=False,
)
output = (result.stdout + result.stderr).strip()
if output:
console.print(output)
return result.returncode == 0
def _patch_and_test(
item: CodeItem,
codebase_path: str,
user_code: str,
test_entries: list[tuple[Path, list[str]]],
console: Console,
) -> bool:
"""Replace *item* in source with *user_code*, run tests, then restore.
Args:
item: Code item to patch.
codebase_path: Absolute codebase root path.
user_code: User's implementation text.
test_entries: ``(test_file, node_ids)`` pairs to run.
console: Rich console for output.
Returns:
``True`` if all tests passed.
"""
source_file = Path(codebase_path) / item.file
original = source_file.read_text(encoding="utf-8")
orig_lines = original.splitlines()
before = orig_lines[: item.start_line - 1]
after = orig_lines[item.end_line :]
new_source = "\n".join(before + user_code.splitlines() + after) + "\n"
try:
ast.parse(new_source)
except SyntaxError as exc:
console.print(f"[red]Syntax error in your implementation: {exc}[/red]")
return False
test_ids = [f"{tf}::{nid}" for tf, nids in test_entries for nid in nids]
project_root = _project_root(Path(codebase_path))
try:
source_file.write_text(new_source, encoding="utf-8")
return _pytest_clean(test_ids, project_root, console)
finally:
source_file.write_text(original, encoding="utf-8")