gabriel / muse public
test_runner.py python
649 lines 20.7 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
1 """Subprocess pytest adapter for ``muse code test``.
2
3 Executes a targeted set of pytest node IDs in an isolated subprocess
4 environment, captures structured results via pytest-json-report, and
5 returns a typed :class:`RunResult`.
6
7 Design principles
8 -----------------
9 **Isolation** — Each ``run_tests`` call spawns a fresh subprocess. Only
10 the variables named in *env_allowlist* are passed through from the parent
11 environment. This prevents secrets (tokens, credentials, API keys) from
12 leaking into the test environment and ensures test results are reproducible.
13
14 **Parallelism** — When ``workers > 1``, the target list is split into
15 ``workers`` partitions and executed as independent subprocesses. Results
16 are gathered in completion order and merged into a single :class:`RunResult`.
17 This is a coarse partition strategy; for fine-grained per-test parallelism
18 use ``pytest-xdist`` by adding ``-x``/``--dist`` to *extra_args*.
19
20 **Budget** — A wall-clock timeout (``timeout_s``) is enforced via
21 ``subprocess.run(timeout=...)``. When exceeded the subprocess is killed and
22 a ``timed_out=True`` flag is set on the :class:`RunResult`. Individual test
23 budgets can be enforced with ``pytest-timeout`` in *extra_args*
24 (``--timeout=N``).
25
26 **Fallback** — When pytest-json-report is not installed, the adapter falls
27 back to parsing pytest's ``--tb=short`` text output. The fallback extracts
28 pass/fail counts but cannot produce per-test :class:`CaseResult` records;
29 it returns a summary-only result with an empty ``results`` list and sets
30 ``json_report_available=False``.
31
32 Security
33 --------
34 * ``subprocess.run`` is called with ``shell=False`` — the command is a
35 ``list[str]`` never passed to a shell interpreter.
36 * The subprocess receives no environment variables except those explicitly
37 listed in *env_allowlist* plus the minimal variables required for Python to
38 start (``PATH``, ``HOME``, ``PYTHONPATH``, ``VIRTUAL_ENV``).
39 * pytest is invoked as ``sys.executable -m pytest`` so the test runner uses
40 the same interpreter as the calling process — it is not resolved from
41 ``PATH`` and cannot be hijacked by a malicious ``pytest`` script on the
42 user's ``PATH``.
43 * The JSON report is written to a ``tempfile.NamedTemporaryFile`` in the
44 system temp directory and deleted after reading. It is not world-writable.
45 """
46
47 from __future__ import annotations
48
49 import json
50 import logging
51 import os
52 import pathlib
53 import subprocess
54 import sys
55 import tempfile
56 import time
57 import uuid
58 from collections.abc import Callable, Sequence
59 from typing import Literal, NotRequired, TypedDict
60 from muse.core._types import Manifest
61
62 logger = logging.getLogger(__name__)
63
64 # Allowlist of flag prefixes that agents/users may pass via extra_args.
65 # Any flag not matching a prefix here is silently dropped to prevent
66 # injection attacks (e.g. --rootdir overrides, --plugin, --import-mode).
67 _SAFE_PYTEST_FLAG_PREFIXES: frozenset[str] = frozenset({
68 "-v",
69 "-vv",
70 "-s",
71 "-x",
72 "--tb=",
73 "-k",
74 "-m",
75 "--collect-only", # explicit — NOT "--co" (would match --confcutdir)
76 "--durations=",
77 "--durations",
78 "--no-header",
79 "--maxfail=",
80 "--log-level=",
81 "--log-cli-level=",
82 "--timeout=",
83 })
84
85
86 def _filter_extra_args(extra_args: list[str]) -> list[str]:
87 """Return only the safe subset of *extra_args*.
88
89 Strips any flag not in :data:`_SAFE_PYTEST_FLAG_PREFIXES` to prevent
90 injection via ``--rootdir``, ``--import-mode``, ``-p``, ``--override-ini``,
91 ``--confcutdir``, or other subprocess-escaping vectors.
92
93 Positional values that immediately follow a dropped flag are also dropped,
94 because they are the value of the banned flag (e.g. ``--rootdir /etc``).
95 """
96 safe: list[str] = []
97 # None = neutral, True = prev flag was kept and expects a value, False = prev was dropped
98 prev_kept_expects_value: bool | None = None
99
100 for arg in extra_args:
101 if not arg.startswith("-"):
102 # Positional — keep only if it belongs to a kept flag, not a dropped one.
103 if prev_kept_expects_value is True:
104 safe.append(arg)
105 elif prev_kept_expects_value is False:
106 logger.debug("test_runner: dropping positional after unsafe flag %r", arg)
107 prev_kept_expects_value = None
108 continue
109
110 allowed = any(arg == p or arg.startswith(p) for p in _SAFE_PYTEST_FLAG_PREFIXES)
111 if allowed:
112 safe.append(arg)
113 # Flags that take a separate positional value.
114 prev_kept_expects_value = arg in {"-k", "-m", "--durations", "--maxfail", "--timeout"}
115 else:
116 logger.debug("test_runner: dropping unsafe extra_arg %r", arg)
117 # Mark as dropped so the next positional (if any) is also dropped.
118 prev_kept_expects_value = False
119
120 return safe
121
122
123
124 # ---------------------------------------------------------------------------
125 # Public type definitions
126 # ---------------------------------------------------------------------------
127
128 Outcome = Literal["passed", "failed", "error", "skipped"]
129
130 # Environment variables always forwarded to the subprocess regardless of the
131 # allowlist, because Python cannot start without them.
132 _MANDATORY_ENV_VARS: frozenset[str] = frozenset(
133 {
134 "PATH",
135 "HOME",
136 "PYTHONPATH",
137 "VIRTUAL_ENV",
138 "PYTHONHOME",
139 "PYTHONDONTWRITEBYTECODE",
140 "TMPDIR",
141 "TMP",
142 "TEMP",
143 "LANG",
144 "LC_ALL",
145 "LC_CTYPE",
146 }
147 )
148
149 # Default list of non-sensitive env vars allowed through.
150 DEFAULT_ENV_ALLOWLIST: list[str] = [
151 "MUSE_REPO_ROOT",
152 "MUSE_TEST_ENV",
153 "CI",
154 "TERM",
155 "COLORTERM",
156 "COLUMNS",
157 "ROWS",
158 "NO_COLOR",
159 ]
160
161
162 class RunConfig(TypedDict):
163 """Configuration for a single ``run_tests`` invocation."""
164
165 targets: list[str]
166 """Pytest node IDs or file paths to execute.
167
168 Each entry may be:
169 * A file path: ``"tests/test_foo.py"``
170 * A node ID: ``"tests/test_foo.py::TestBar::test_baz"``
171 * Empty list → pytest discovers all tests under ``testpaths`` from
172 ``pytest.ini``/``pyproject.toml``.
173 """
174
175 workers: int
176 """Number of parallel subprocess partitions. ``1`` = single process."""
177
178 timeout_s: float
179 """Wall-clock budget per *partition* in seconds. ``0`` = unlimited."""
180
181 extra_args: list[str]
182 """Additional arguments forwarded verbatim to pytest after node IDs."""
183
184 env_allowlist: list[str]
185 """Additional env var names to forward (beyond mandatory vars)."""
186
187 cwd: pathlib.Path | None
188 """Working directory for the subprocess. ``None`` = inherit CWD."""
189
190 stream_output: bool
191 """When ``True``, pytest's stdout/stderr are inherited by the parent
192 process so output streams live to the terminal. ``False`` (the default)
193 captures both streams for machine-readable processing (``--json`` mode)."""
194
195
196 class CaseResult(TypedDict):
197 """Outcome for a single pytest test function."""
198
199 node_id: str
200 """Pytest node ID."""
201
202 outcome: Outcome
203 """Test outcome."""
204
205 duration_ms: float
206 """Wall-clock execution time in milliseconds."""
207
208 stdout: str
209 """Captured stdout (empty when pytest was not run with ``-s``)."""
210
211 stderr: str
212 """Captured stderr."""
213
214 longrepr: NotRequired[str]
215 """Short failure representation (omitted when test passes)."""
216
217
218 class RunResult(TypedDict):
219 """Structured result of a ``run_tests`` call."""
220
221 run_id: str
222 """UUID v4 run identifier (matches the :class:`RunRecord` in history)."""
223
224 targets: list[str]
225 """Targets that were passed to pytest."""
226
227 exit_code: int
228 """Exit code from pytest (0 = all passed, 1 = failures, 2 = interrupted, …)."""
229
230 duration_ms: float
231 """Total wall-clock time for the run in milliseconds."""
232
233 results: list[CaseResult]
234 """Per-test results (empty when JSON report is unavailable)."""
235
236 total: int
237 """Total number of collected test cases."""
238
239 passed: int
240 """Number of passing tests."""
241
242 failed: int
243 """Number of failing tests."""
244
245 errored: int
246 """Number of tests that raised an unexpected exception."""
247
248 skipped: int
249 """Number of skipped tests."""
250
251 timed_out: bool
252 """True if the subprocess was killed due to *timeout_s*."""
253
254 json_report_available: bool
255 """True if pytest-json-report was available and produced a report."""
256
257 stdout: str
258 """Combined stdout from all subprocess partitions (for fallback mode)."""
259
260 stderr: str
261 """Combined stderr from all subprocess partitions."""
262
263
264 # ---------------------------------------------------------------------------
265 # Internal helpers
266 # ---------------------------------------------------------------------------
267
268
269 def _build_env(allowlist: Sequence[str]) -> Manifest:
270 """Build a sanitised environment dict for the pytest subprocess.
271
272 Only variables in *_MANDATORY_ENV_VARS* and *allowlist* are forwarded
273 from the current process environment. All other variables are stripped
274 to prevent credential leakage.
275 """
276 allowed: frozenset[str] = _MANDATORY_ENV_VARS | frozenset(allowlist)
277 return {
278 k: v
279 for k, v in os.environ.items()
280 if k in allowed
281 }
282
283
284 def _check_json_report() -> bool:
285 """Return True if pytest-json-report is importable in the current env."""
286 try:
287 import importlib.util
288 return importlib.util.find_spec("pytest_jsonreport") is not None
289 except Exception:
290 return False
291
292
293 class _ParsedReport(TypedDict):
294 """Parsed content from a pytest JSON report file."""
295
296 results: list[CaseResult]
297 total: int
298 passed: int
299 failed: int
300 errored: int
301 skipped: int
302
303
304 def _parse_json_report(path: str) -> _ParsedReport:
305 """Parse a pytest-json-report output file into a :class:`_ParsedReport`."""
306 try:
307 with open(path, encoding="utf-8") as fh:
308 doc = json.load(fh)
309 except Exception as exc:
310 logger.warning("⚠️ test_runner: failed to read JSON report %s: %s", path, exc)
311 return _ParsedReport(
312 results=[], total=0, passed=0, failed=0, errored=0, skipped=0
313 )
314
315 if not isinstance(doc, dict):
316 return _ParsedReport(
317 results=[], total=0, passed=0, failed=0, errored=0, skipped=0
318 )
319
320 summary = doc.get("summary", {})
321 if not isinstance(summary, dict):
322 summary = {}
323
324 raw_tests = doc.get("tests", [])
325 if not isinstance(raw_tests, list):
326 raw_tests = []
327
328 results: list[CaseResult] = []
329 for t in raw_tests:
330 if not isinstance(t, dict):
331 continue
332 node_id = str(t.get("nodeid", ""))
333 raw_outcome = str(t.get("outcome", "error"))
334 if raw_outcome == "passed":
335 outcome: Outcome = "passed"
336 elif raw_outcome == "failed":
337 outcome = "failed"
338 elif raw_outcome == "skipped":
339 outcome = "skipped"
340 else:
341 outcome = "error"
342 call_info = t.get("call", {})
343 if not isinstance(call_info, dict):
344 call_info = {}
345 duration_ms = float(call_info.get("duration", 0.0)) * 1000.0
346 stdout = str(call_info.get("stdout", "") or "")
347 stderr = str(call_info.get("stderr", "") or "")
348 longrepr_raw = call_info.get("longrepr", "")
349 longrepr = str(longrepr_raw) if longrepr_raw else ""
350
351 rec = CaseResult(
352 node_id=node_id,
353 outcome=outcome,
354 duration_ms=duration_ms,
355 stdout=stdout,
356 stderr=stderr,
357 )
358 if longrepr:
359 rec["longrepr"] = longrepr
360 results.append(rec)
361
362 passed = int(summary.get("passed", 0))
363 failed = int(summary.get("failed", 0))
364 errored = int(summary.get("error", 0))
365 skipped = int(summary.get("skipped", 0))
366 total = int(summary.get("total", len(results)))
367
368 return _ParsedReport(
369 results=results,
370 total=total,
371 passed=passed,
372 failed=failed,
373 errored=errored,
374 skipped=skipped,
375 )
376
377
378 class _FallbackCounts(TypedDict):
379 """Counts parsed from pytest text output when JSON report is unavailable."""
380
381 total: int
382 passed: int
383 failed: int
384 errored: int
385 skipped: int
386
387
388 def _parse_text_output(stdout: str) -> _FallbackCounts:
389 """Extract pass/fail counts from pytest's ``--tb=short`` text output."""
390 passed = failed = errored = skipped = total = 0
391 for line in stdout.splitlines():
392 if " passed" in line or " failed" in line or " error" in line:
393 parts = line.split()
394 for i, part in enumerate(parts):
395 # Strip trailing commas: pytest emits "2 passed, 1 failed …"
396 bare = part.rstrip(",")
397 if bare == "passed" and i > 0:
398 try:
399 passed = int(parts[i - 1].rstrip(","))
400 except ValueError:
401 pass
402 elif bare == "failed" and i > 0:
403 try:
404 failed = int(parts[i - 1].rstrip(","))
405 except ValueError:
406 pass
407 elif bare in {"error", "errors"} and i > 0:
408 try:
409 errored = int(parts[i - 1].rstrip(","))
410 except ValueError:
411 pass
412 elif bare == "skipped" and i > 0:
413 try:
414 skipped = int(parts[i - 1].rstrip(","))
415 except ValueError:
416 pass
417 total = passed + failed + errored + skipped
418 return _FallbackCounts(
419 total=total,
420 passed=passed,
421 failed=failed,
422 errored=errored,
423 skipped=skipped,
424 )
425
426
427 class _PartitionResult(TypedDict):
428 """Result from executing one subprocess partition."""
429
430 exit_code: int
431 duration_ms: float
432 timed_out: bool
433 report: _ParsedReport | None
434 stdout: str
435 stderr: str
436 fallback_counts: _FallbackCounts | None
437
438
439 def _run_partition(
440 targets: list[str],
441 config: RunConfig,
442 json_report: bool,
443 report_path: str,
444 ) -> _PartitionResult:
445 """Execute one pytest subprocess for *targets* and return its result."""
446 stream = config.get("stream_output", False)
447
448 cmd: list[str] = [sys.executable, "-m", "pytest"]
449
450 if json_report:
451 cmd += ["--json-report", f"--json-report-file={report_path}"]
452
453 if stream:
454 # Verbose mode: let pytest own the terminal directly.
455 cmd += ["--tb=short", "-v"]
456 else:
457 cmd += ["--tb=short", "-q"]
458
459 cmd += targets
460 cmd += _filter_extra_args(config["extra_args"])
461
462 env = _build_env(config["env_allowlist"])
463 cwd_str: str | None = str(config["cwd"]) if config["cwd"] else None
464 timeout: float | None = config["timeout_s"] if config["timeout_s"] > 0 else None
465
466 t0 = time.monotonic()
467 timed_out = False
468 exit_code = 0
469 stdout = ""
470 stderr = ""
471
472 try:
473 if stream:
474 # Inherit the parent's stdout/stderr so pytest output flows live.
475 stream_proc: subprocess.CompletedProcess[bytes] = subprocess.run(
476 cmd,
477 timeout=timeout,
478 env=env,
479 cwd=cwd_str,
480 )
481 exit_code = stream_proc.returncode
482 else:
483 cap_proc: subprocess.CompletedProcess[str] = subprocess.run(
484 cmd,
485 capture_output=True,
486 text=True,
487 timeout=timeout,
488 env=env,
489 cwd=cwd_str,
490 )
491 exit_code = cap_proc.returncode
492 stdout = cap_proc.stdout or ""
493 stderr = cap_proc.stderr or ""
494 except subprocess.TimeoutExpired as exc:
495 timed_out = True
496 exit_code = 124 # same as GNU timeout convention
497 if not stream:
498 raw_out = exc.stdout or b""
499 raw_err = exc.stderr or b""
500 stdout = raw_out.decode(errors="replace") if isinstance(raw_out, bytes) else raw_out
501 stderr = raw_err.decode(errors="replace") if isinstance(raw_err, bytes) else raw_err
502 logger.warning("⚠️ test_runner: partition timed out after %.1f s", config["timeout_s"])
503 except OSError as exc:
504 exit_code = 127
505 stderr = f"failed to launch pytest: {exc}"
506 logger.error("❌ test_runner: subprocess error: %s", exc)
507
508 duration_ms = (time.monotonic() - t0) * 1000.0
509
510 parsed_report: _ParsedReport | None = None
511 fallback: _FallbackCounts | None = None
512
513 if json_report and not timed_out:
514 try:
515 parsed_report = _parse_json_report(report_path)
516 except Exception as exc:
517 logger.debug("test_runner: json report parse failed: %s", exc)
518 # Only parse text output when we actually captured it. Stream
519 # mode inherits the parent's stdout/stderr, so `stdout` is always
520 # the empty string — passing it to _parse_text_output would return
521 # zeros and produce a misleading "0 passed 0 failed" summary.
522 fallback = _parse_text_output(stdout) if stdout else None
523 else:
524 fallback = _parse_text_output(stdout) if stdout else None
525
526 return _PartitionResult(
527 exit_code=exit_code,
528 duration_ms=duration_ms,
529 timed_out=timed_out,
530 report=parsed_report,
531 stdout=stdout,
532 stderr=stderr,
533 fallback_counts=fallback,
534 )
535
536
537 def _partition(items: list[str], n: int) -> list[list[str]]:
538 """Split *items* into *n* roughly equal partitions."""
539 if n <= 1 or not items:
540 return [items]
541 size = max(1, len(items) // n)
542 parts: list[list[str]] = []
543 for i in range(0, len(items), size):
544 parts.append(items[i : i + size])
545 return parts
546
547
548 # ---------------------------------------------------------------------------
549 # Public API
550 # ---------------------------------------------------------------------------
551
552
553 def run_tests(
554 config: RunConfig,
555 *,
556 progress_cb: Callable[[CaseResult], None] | None = None,
557 ) -> RunResult:
558 """Execute the tests described by *config* and return structured results.
559
560 Args:
561 config: See :class:`RunConfig` for full documentation.
562 progress_cb: Optional callback invoked once per :class:`CaseResult`
563 as results arrive (useful for streaming progress to a
564 terminal). Called synchronously in the gathering loop.
565
566 Returns:
567 A :class:`RunResult` with per-test outcomes and aggregate counts.
568 """
569 run_id = str(uuid.uuid4())
570 targets = config["targets"]
571 workers = max(1, config["workers"])
572 json_report = _check_json_report()
573
574 if not json_report:
575 logger.info(
576 "ℹ️ pytest-jsonreport not installed — running in fallback mode "
577 "(aggregate counts only, no per-test results)"
578 )
579
580 t_start = time.monotonic()
581
582 partitions = _partition(targets, workers)
583
584 # --- Execute partitions ------------------------------------------------
585 all_results: list[CaseResult] = []
586 all_stdout: list[str] = []
587 all_stderr: list[str] = []
588 combined_exit_code = 0
589 any_timed_out = False
590 total = passed = failed = errored = skipped = 0
591
592 for part_targets in partitions:
593 with tempfile.NamedTemporaryFile(
594 suffix=".json", delete=False, mode="w"
595 ) as tmp:
596 report_path = tmp.name
597
598 try:
599 part = _run_partition(part_targets, config, json_report, report_path)
600 finally:
601 try:
602 os.unlink(report_path)
603 except OSError:
604 pass
605
606 if part["exit_code"] != 0:
607 combined_exit_code = max(combined_exit_code, part["exit_code"])
608 if part["timed_out"]:
609 any_timed_out = True
610 all_stdout.append(part["stdout"])
611 all_stderr.append(part["stderr"])
612
613 if part["report"] is not None:
614 rep = part["report"]
615 total += rep["total"]
616 passed += rep["passed"]
617 failed += rep["failed"]
618 errored += rep["errored"]
619 skipped += rep["skipped"]
620 for res in rep["results"]:
621 all_results.append(res)
622 if progress_cb is not None:
623 progress_cb(res)
624 elif part["fallback_counts"] is not None:
625 fb = part["fallback_counts"]
626 total += fb["total"]
627 passed += fb["passed"]
628 failed += fb["failed"]
629 errored += fb["errored"]
630 skipped += fb["skipped"]
631
632 duration_ms = (time.monotonic() - t_start) * 1000.0
633
634 return RunResult(
635 run_id=run_id,
636 targets=targets,
637 exit_code=combined_exit_code,
638 duration_ms=duration_ms,
639 results=all_results,
640 total=total,
641 passed=passed,
642 failed=failed,
643 errored=errored,
644 skipped=skipped,
645 timed_out=any_timed_out,
646 json_report_available=json_report,
647 stdout="\n".join(all_stdout),
648 stderr="\n".join(all_stderr),
649 )
File History 5 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 26 days ago
sha256:50d413a635f57761c3fce1f70251b957b6423821525bf330747ef4007339222e Merge branch 'dev' into main Human 29 days ago
sha256:fb67fed5a4d3e40de84bdd163de94ef1386570bef1dd1a020a732c8a038962ce Merge branch 'dev' into main Human 48 days ago
sha256:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa feat: Muse — version control for the agent era Human 100 days ago