oc_chat
Loading...
Searching...
No Matches
runner.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/testing/runner.py
4
5"""
6The §20 stand-in orchestrator: serial in-process execution (Plan 184).
7
8Purpose
9-------
10Drive one stand-in invocation end to end: argv parse → ADR-T-12
11discovery → §10 undeclared-root scan → profile/marker/seed composition
12→ serial IN-PROCESS ``pytest.main`` with a collector plugin → §20.2
13process-level CWD/env restore checks → §11 report assembly → §4 exit
14mapping. In-process execution is the pinned decision: only it lets the
15post-run ``os.environ``/CWD diff observe anything.
16
17Responsibilities
18----------------
19- ``run_standin`` — the runner API; returns report + exit code + the
20 API-level execution-order witness (the report's ``tests`` array is
21 nodeid-sorted by contract, so order lives here).
22- ``_Collector`` — the pytest plugin: collection accounting, static
23 scan before execution, dry-run/violation short-circuit, per-test
24 watchdog, outcome aggregation, collection-error capture.
25- ``_SignalState`` — the ADR-T-08 subset for a serial in-process run.
26
27Signal-handling subset (ADR-T-08, stated per plan T3)
28-----------------------------------------------------
29First ``SIGINT``/``SIGTERM`` (plus ``SIGBREAK`` on Windows): there are
30no worker processes to TERM and nothing to wait ten seconds for, so the
31stand-in unwinds the in-process pytest session, writes the partial
32report with ``status: "orchestrator_interrupted"`` and the
33``ORCHESTRATOR_INTERRUPTED`` signal entry, and exits 3. A second signal
34hard-exits 130 with no report (the report-less second-signal wording
35carries a known erratum upstream, B_04 P2 — out of scope here).
36Handlers install only when running on the main thread.
37
38Per-test-timeout deviation (ADR-T-07/§16.1, stated per plan T3)
39---------------------------------------------------------------
40The spec pins ``pytest-timeout`` as the serial stand-in's mechanism,
41but its only Windows-viable method (``thread``) terminates the ENTIRE
42process on expiry — for an in-process stand-in that would kill the
43orchestrator and make the §20 surviving-report contract (timeout
44outcome + duration + exit 1) unsatisfiable. The stand-in therefore
45implements the ADR's intent with a watchdog timer that soft-interrupts
46the hung test via ``PyThreadState_SetAsyncExc`` (the run continues;
47the nodeid reports ``outcome: "timeout"`` with its elapsed duration).
48``pytest-timeout`` remains the installed, pinned dependency and the
49v1.0 in-worker backstop. Residual: a test blocked inside a C call is
50interrupted only when that call returns — the same class of residual
51pytest-timeout's signal method carries.
52
53Diagnostics
54-----------
55Domain: OC_TESTING
56Levels:
57 L2 — lifecycle events
58 L3 — semantic details
59 L4 — deep tracing
60
61Contracts
62---------
63- The restore checks consume :data:`TRACKED_ENV_PREFIXES` from its
64 single export (§20.2) — object identity, no copy.
65- The runner restores its own CWD and the recursion-guard variable
66 before diffing, so only TEST leaks are visible.
67- ``report["exit_code"]`` always equals the returned exit code.
68
69Dependencies
70------------
71- pytest (in-process engine)
72- oc_diagnostics (structured debug logging via _dbg)
73- oc_chat.testing.cli (argv contract)
74- oc_chat.testing.package_walk (discovery + root scan)
75- oc_chat.testing.static_scan (static AST guard)
76- oc_chat.testing.report (report constants + exit mapping)
77- oc_chat.testing.env_inventory (tracked prefixes)
78- oc_chat.testing.scheduler (the parallel path - worker plan, unit schedule, bounded child pool and crash reconciliation)
79- _pytest.mark.expression (lazy; Expression - validates the composed marker expression before the run)
80- pytest_randomly (not imported; the seed-pinning plugin loaded with -p randomly when --seed is given, disabled with -p no:randomly otherwise)
81"""
82from __future__ import annotations
83
84from collections.abc import Generator, Iterator, Sequence
85import ctypes
86from contextlib import redirect_stdout
87from dataclasses import dataclass, field
88import io
89import os
90from pathlib import Path
91import signal
92import threading
93import time
94import uuid
95
96import pytest
97
98from oc_diagnostics import _dbg
99
100from oc_chat.testing.cli import StandInConfig, parse_argv
101from oc_chat.testing.env_inventory import TRACKED_ENV_PREFIXES
103 WalkResult,
104 scan_undeclared_roots,
105 walk_for_package,
106)
107from oc_chat.testing.report import (
108 SCHEMA_STAMP,
109 SUPPORTED_FEATURES,
110 compute_exit_and_status,
111 make_error,
112 make_violation,
113)
114from oc_chat.testing.scheduler import (
115 DIST_MODE_PARALLEL,
116 DIST_MODE_SERIAL,
117 SchedulerUnavailable,
118 UnitOutcome,
119 WHOLE_FILE_MARKER,
120 WorkerPlan,
121 build_schedule,
122 clamp_error,
123 crash_error,
124 crash_violation,
125 load_groups,
126 manifest_path_for,
127 resolve_worker_plan,
128 run_units,
129 unclassified_violation,
130)
131from oc_chat.testing.static_scan import scan_files
132
133#: Recursion guard: set while the stand-in's in-process pytest session
134#: runs, so live-suite acceptance tests can skip their own heavy
135#: stand-in invocation instead of recursing.
136STANDIN_ACTIVE_ENV = "OC_CHAT_STANDIN_ACTIVE"
137
138_PROFILE_EXPRESSIONS = {
139 "strict": "not requires_network",
140 "smoke": "smoke and not slow and not requires_mcp and not requires_network",
141 # The §5 network-exclusion lift needs package config
142 # (allow_network=true) that no package declares yet; without it the
143 # exclusion can never lift, so the stand-in composes the same base
144 # exclusion as strict.
145 "full": "not requires_network",
146}
147
148_LONGREPR_CAP = 2000
149
150
151class StandInTimeoutError(Exception):
152 """Raised asynchronously inside a hung test by the watchdog."""
153
154
155@dataclass
157 """The runner API result: report document, exit code, witnesses."""
158
159 report: dict
160 exit_code: int
161 execution_order: list[str] = field(default_factory=list)
162 captured_output: str = ""
163
164
165@dataclass
167 """One guarded in-process pytest session (Plan 240 extraction).
168
169 The serial stand-in and every scheduler child run through the SAME
170 function, so the §20.2 restore contract, the watchdog and the
171 collector plugin cannot drift apart between the two paths.
172 """
173
174 rc: int = 0
175 records: dict[str, dict] = field(default_factory=dict)
176 timed_out: tuple[str, ...] = ()
177 execution_order: list[str] = field(default_factory=list)
178 collected_pre: list[tuple[str, Path]] = field(default_factory=list)
179 selected: list[tuple[str, Path]] = field(default_factory=list)
180 #: Root-relative paths whose tests carried ``whole_file`` at
181 #: collection: the Plan 286 P2 opt-out the scheduler reads. A child
182 #: is handed nodeids and never sees a mark, so the PARENT's collect
183 #: pass is the only place this is observable.
184 whole_file: tuple[str, ...] = ()
185 collect_errors: list[str] = field(default_factory=list)
186 static_violations: list[dict] = field(default_factory=list)
187 blocked: bool = False
188 violations: list[dict] = field(default_factory=list)
189 output: str = ""
190
191
193 """ADR-T-08 subset state machine for a serial in-process run."""
194
195 def __init__(self) -> None:
196 self.interrupted: str | None = None
197 self._entered = False
198 self.hard_exit = os._exit # seam: tests monkeypatch this
199
200 def on_signal(self, signum: int, frame: object) -> None:
201 """First signal: record + unwind pytest. Second: exit 130."""
202 func = "on_signal"
203 if self._entered:
204 _dbg("OC_TESTING", func, "second signal -> hard exit 130", 2)
205 self.hard_exit(130)
206 return
207 self._entered = True
208 try:
209 self.interrupted = signal.Signals(signum).name
210 except ValueError:
211 self.interrupted = f"signal {signum}"
212 _dbg("OC_TESTING", func, f"first signal {self.interrupted}", 2)
213 raise KeyboardInterrupt
214
215
217 """In-process pytest plugin: accounting, static gate, watchdog."""
218
219 def __init__(self, *, dry_run: bool, timeout: int | None,
220 thread_ident: int) -> None:
221 self.dry_run = dry_run
222 self.timeout = timeout
223 self.thread_ident = thread_ident
224 self.collected_pre: list[tuple[str, Path]] = []
225 self.selected: list[tuple[str, Path]] = []
226 self.whole_file: tuple[str, ...] = ()
227 self.static_violations: list[dict] = []
228 self.blocked = False
229 self.records: dict[str, dict] = {}
230 self.execution_order: list[str] = []
231 self.timed_out: set[str] = set()
232 self.collect_errors: list[str] = []
233 self._current_nodeid: str | None = None
234 self.session_start_cwd: str | None = None
235 self.last_seen_cwd: str | None = None
236
237 # -- session CWD observation (§20.2) ---------------------------------
238 # The engine (pytest 8+/plugins) restores the invocation CWD during
239 # session teardown, which masks any test's chdir leak from a diff
240 # taken after pytest.main() returns — verified empirically: the
241 # leaked CWD is visible at the last test's teardown report and
242 # already restored by sessionfinish. The check therefore observes
243 # the CWD at every test-phase report; the final observation is the
244 # post-run state "around the process-level run", taken before the
245 # engine's cosmetic restoration.
246
247 def pytest_sessionstart(self, session: pytest.Session) -> None:
248 self.session_start_cwd = os.getcwd()
249
250 # -- collection ------------------------------------------------------
251
252 @pytest.hookimpl(wrapper=True)
254 self, config: pytest.Config, items: list[pytest.Item],
255 ) -> Iterator[None]:
256 self.collected_pre = [(i.nodeid, Path(i.path)) for i in items]
257 yield
258 # post-deselection view is taken at collection_finish
259
260 def pytest_collection_finish(self, session: pytest.Session) -> None:
261 self.selected = [(i.nodeid, Path(i.path)) for i in session.items]
262 # Plan 286 P2: the opt-out is a MARK, and only a collection
263 # sees marks. Recorded by nodeid prefix, which is the same
264 # root-relative key the scheduler groups by.
265 self.whole_file = tuple(sorted({
266 item.nodeid.split("::")[0] for item in session.items
267 if item.get_closest_marker(WHOLE_FILE_MARKER) is not None
268 }))
269
270 def pytest_collectreport(self, report: pytest.CollectReport) -> None:
271 if report.failed:
272 self.collect_errors.append(
273 str(report.longrepr)[:_LONGREPR_CAP]
274 )
275
276 # -- execution gate --------------------------------------------------
277
278 @pytest.hookimpl(tryfirst=True)
279 def pytest_runtestloop(self, session: pytest.Session) -> bool | None:
280 func = "pytest_runtestloop"
281 files = sorted({path for _n, path in self.selected})
282 self.static_violations = scan_files(files)
283 has_errors = any(
284 v["severity"] == "error" for v in self.static_violations
285 )
286 if has_errors:
287 _dbg("OC_TESTING", func, "static violations -> skip execution", 2)
288 self.blocked = True
289 return True
290 if self.dry_run:
291 _dbg("OC_TESTING", func, "dry-run -> skip execution", 3)
292 return True
293 return None
294
295 # -- per-test watchdog ----------------------------------------------
296
297 def _fire(self, nodeid: str) -> None:
298 func = "_fire"
299 if self._current_nodeid != nodeid:
300 return # test already finished; never interrupt a neighbour
301 self.timed_out.add(nodeid)
302 _dbg("OC_TESTING", func, f"timeout -> soft interrupt {nodeid}", 2)
303 ctypes.pythonapi.PyThreadState_SetAsyncExc(
304 ctypes.c_ulong(self.thread_ident),
305 ctypes.py_object(StandInTimeoutError),
306 )
307
308 @pytest.hookimpl(wrapper=True)
310 self, item: pytest.Item, nextitem: pytest.Item | None,
311 ) -> Generator[None, object, object]:
312 timer: threading.Timer | None = None
313 self._current_nodeid = item.nodeid
314 if self.timeout:
315 timer = threading.Timer(
316 self.timeout, self._fire, args=(item.nodeid,)
317 )
318 timer.daemon = True
319 timer.start()
320 try:
321 result = yield
322 finally:
323 self._current_nodeid = None
324 if timer is not None:
325 timer.cancel()
326 return result
327
328 # -- outcome aggregation ---------------------------------------------
329
330 def pytest_runtest_logreport(self, report: pytest.TestReport) -> None:
331 self.last_seen_cwd = os.getcwd()
332 rec = self.records.setdefault(report.nodeid, {
333 "failed": False, "skipped": False,
334 "duration": 0.0, "longrepr": None,
335 })
336 if report.nodeid not in self.execution_order:
337 self.execution_order.append(report.nodeid)
338 rec["duration"] += getattr(report, "duration", 0.0)
339 if report.failed:
340 rec["failed"] = True
341 rec["longrepr"] = str(report.longrepr)[:_LONGREPR_CAP]
342 elif report.skipped:
343 rec["skipped"] = True
344
345
346def _outcome_for(nodeid: str, rec: dict, timed_out: set[str]) -> str:
347 if nodeid in timed_out:
348 return "timeout"
349 if rec["failed"]:
350 return "failed"
351 if rec["skipped"]:
352 return "skipped"
353 return "passed"
354
355
357 *,
358 pytest_args: Sequence[str],
359 root: Path,
360 timeout: int | None,
361 dry_run: bool,
362 sig_state: _SignalState | None = None,
363 install_signal_handlers: bool = True,
364 violation_file: str | None = None,
365 violation_scope: str = "",
366) -> SessionOutcome:
367 """Run ONE guarded in-process pytest session (§20.2).
368
369 Everything the §20.2 process-level contract needs happens here and
370 only here: the recursion guard, the CWD move and its restoration,
371 the tracked-environment snapshot taken around the run, the static
372 gate, the per-test watchdog and the leak diff. The serial stand-in
373 calls it once; the scheduler calls it once per child, in that
374 child's own process, which is what makes the §20.2 findings
375 attributable to a group at all.
376
377 Parameters
378 ----------
379 violation_file
380 The ``file`` field of any runtime finding; defaults to *root*.
381 violation_scope
382 Prefixed to a finding's message when non-empty, so a child's
383 leak names the worker and group that produced it.
384 """
385 func = "run_session"
386 collector = _Collector(
387 dry_run=dry_run, timeout=timeout,
388 thread_ident=threading.get_ident(),
389 )
390 prior_handlers: list[tuple[int, object]] = []
391 on_main_thread = threading.current_thread() is threading.main_thread()
392 install = (
393 install_signal_handlers and on_main_thread and sig_state is not None
394 )
395 outer_cwd = os.getcwd()
396 guard_prior = os.environ.get(STANDIN_ACTIVE_ENV)
397 env_before = {
398 k: v for k, v in os.environ.items()
399 if k.startswith(TRACKED_ENV_PREFIXES)
400 }
401 buffer = io.StringIO()
402 rc: int
403 try:
404 if install and sig_state is not None:
405 for signame in ("SIGINT", "SIGTERM", "SIGBREAK"):
406 signum = getattr(signal, signame, None)
407 if signum is not None:
408 prior_handlers.append(
409 (signum, signal.signal(signum, sig_state.on_signal))
410 )
411 os.environ[STANDIN_ACTIVE_ENV] = "1"
412 os.chdir(root)
413 ref_cwd = os.getcwd()
414 _dbg("OC_TESTING", func, f"pytest args: {list(pytest_args)}", 3)
415 with redirect_stdout(buffer):
416 try:
417 rc = int(pytest.main(list(pytest_args), plugins=[collector]))
418 except KeyboardInterrupt:
419 rc = 2
420 post_cwd = os.getcwd()
421 finally:
422 for signum, handler in prior_handlers:
423 signal.signal(signum, handler)
424 os.chdir(outer_cwd)
425 if guard_prior is None:
426 os.environ.pop(STANDIN_ACTIVE_ENV, None)
427 else:
428 os.environ[STANDIN_ACTIVE_ENV] = guard_prior
429
430 env_after = {
431 k: v for k, v in os.environ.items()
432 if k.startswith(TRACKED_ENV_PREFIXES)
433 }
434
435 # -- runtime restore findings (run-level; file = the package root) ----
436 # Primary CWD observation: session start vs session finish (inside
437 # the engine — pytest 8+ restores the invocation CWD after the
438 # session, masking leaks from any later diff). The outer ref/post
439 # diff stays as the fallback net for paths where no session ran.
440 where = violation_file if violation_file is not None else str(root)
441 tag = f"[{violation_scope}] " if violation_scope else ""
442 violations: list[dict] = []
443 cwd_leak: tuple[str, str] | None = None
444 start_cwd = collector.session_start_cwd or ref_cwd
445 if (
446 collector.last_seen_cwd is not None
447 and collector.last_seen_cwd != start_cwd
448 ):
449 cwd_leak = (start_cwd, collector.last_seen_cwd)
450 elif post_cwd != ref_cwd:
451 cwd_leak = (ref_cwd, post_cwd)
452 if cwd_leak is not None:
453 violations.append(make_violation(
454 "cwd_chdir", where, None, "error",
455 f"{tag}process CWD not restored around the run: "
456 f"{cwd_leak[0]!r} -> {cwd_leak[1]!r} (section 20.2 "
457 "process-level check)",
458 ))
459 leaked = sorted(
460 set(env_after) - set(env_before)
461 | {k for k in env_before if k in env_after
462 and env_before[k] != env_after[k]}
463 )
464 removed = sorted(set(env_before) - set(env_after))
465 if leaked or removed:
466 details = []
467 if leaked:
468 details.append("set/changed: " + ", ".join(leaked))
469 if removed:
470 details.append("removed: " + ", ".join(removed))
471 violations.append(make_violation(
472 "env_mutation", where, None, "error",
473 f"{tag}tracked environment not restored around the run "
474 f"({'; '.join(details)}) — prefixes {TRACKED_ENV_PREFIXES} "
475 "(section 20.2 process-level check)",
476 ))
477 for key in leaked:
478 os.environ.pop(key, None)
479 for key in removed:
480 os.environ[key] = env_before[key]
481
482 return SessionOutcome(
483 rc=rc,
484 records=collector.records,
485 timed_out=tuple(sorted(collector.timed_out)),
486 execution_order=list(collector.execution_order),
487 collected_pre=list(collector.collected_pre),
488 selected=list(collector.selected),
489 whole_file=collector.whole_file,
490 collect_errors=list(collector.collect_errors),
491 static_violations=list(collector.static_violations),
492 blocked=collector.blocked,
493 violations=violations,
494 output=buffer.getvalue(),
495 )
496
497
499 *,
500 run_id: str,
501 status: str,
502 exit_code: int,
503 workspace_root: str | None,
504 workspace_mode: str | None,
505 packages: list[str],
506 profile: str | None,
507 marker: str | None,
508 seed: int | None,
509 nodeids: list[str],
510 workers_requested: int | str | None,
511 workers_actual: int | None,
512 dist_mode: str | None,
513 collected: int,
514 tests: list[dict],
515 violations: list[dict],
516 errors: list[dict],
517 duration_seconds: float,
518 scheduling: dict | None = None,
519) -> dict:
520 """Assemble the §11/§20.3 report document (every field, always)."""
521 passed = sum(1 for t in tests if t["outcome"] == "passed")
522 failed = sum(1 for t in tests if t["outcome"] == "failed")
523 skipped = sum(1 for t in tests if t["outcome"] == "skipped")
524 timeout = sum(1 for t in tests if t["outcome"] == "timeout")
525 return {
526 "schema": SCHEMA_STAMP,
527 "run_id": run_id,
528 "status": status,
529 "exit_code": exit_code,
530 "workspace": {"root": workspace_root, "mode": workspace_mode},
531 "selection": {
532 "packages": packages,
533 "profile": profile,
534 "marker": marker,
535 "seed": seed,
536 "nodeids": nodeids,
537 "pytest_args": [],
538 },
539 "workers": {
540 "requested": workers_requested,
541 "actual": workers_actual,
542 "dist_mode": dist_mode,
543 },
544 "summary": {
545 "collected": collected,
546 "passed": passed,
547 "failed": failed,
548 "skipped": skipped,
549 "timeout": timeout,
550 "duration_seconds": round(duration_seconds, 2),
551 "tests_count": len(tests),
552 "tests_truncated": False,
553 },
554 "tests": sorted(tests, key=lambda t: t["nodeid"]),
555 "contract_violations": violations,
556 "artifacts": {"junit_xml": None, "ndjson": None,
557 "ndjson_stream": None},
558 "errors": errors,
559 "supported_features": _features_for(dist_mode),
560 "scheduling": scheduling if scheduling is not None
561 else serial_scheduling("not_started"),
562 }
563
564
565def _features_for(dist_mode: str | None) -> dict[str, str]:
566 """§20.3 ``supported_features`` for THIS run.
567
568 ADR-T-11's v0.3 amendment (readiness audit A-19) makes exactly one
569 value run-dependent: a serial stand-in never runs the ``auto``
570 algorithm and reports ``workers_auto: "deferred"``, while a
571 parallel-capable one reports ``"enforced"``. Plan 240 is what makes
572 the second case reachable; the pinned constant stays the serial
573 baseline, so a serial report is unchanged from plan 184's.
574 """
575 features = dict(SUPPORTED_FEATURES)
576 if dist_mode == DIST_MODE_PARALLEL:
577 features["workers_auto"] = "enforced"
578 return features
579
580
581def serial_scheduling(reason: str | None) -> dict:
582 """The ``scheduling`` block of a run that distributed nothing.
583
584 *reason* records WHY, so a run that could have parallelised and did
585 not is never silent about it: ``workers_1``, ``dry_run``,
586 ``no_manifest``, ``single_worker``, ``single_unit``,
587 ``classifier_unavailable`` or ``worker_cap_unusable``.
588 """
589 return {
590 "mode": "serial",
591 "reason": reason,
592 "manifest": None,
593 "workers_cap": None,
594 "workers_resolved": None,
595 "groups": [],
596 "unclassified": [],
597 }
598
599
601 *,
602 run_id: str,
603 t0: float,
604 errors: list[dict],
605 cfg: StandInConfig | None,
606 walk: WalkResult | None,
607 failed_step: str,
608) -> RunResult:
609 """Build a §11 pre-execution failure report along the pinned
610 resolution chain argv → workspace → package → profile → seed →
611 workers: every leaf at-or-after *failed_step* is null."""
612 order = ("argv", "workspace", "package", "profile", "seed", "workers")
613 reached = order.index(failed_step)
614
615 def _resolved(owning_step: str) -> bool:
616 """A field is non-null iff its owning step completed before the
617 failed one."""
618 return order.index(owning_step) < reached
619
620 workspace_root = str(walk.root) if walk and _resolved("workspace") else None
621 workspace_mode = (
622 "single_package" if walk and _resolved("workspace") else None
623 )
624 packages = [walk.package_id] if walk and _resolved("package") else []
625 profile = cfg.profile if cfg and _resolved("profile") else None
626 seed = None
627 workers_requested = cfg.workers if cfg and _resolved("workers") else None
628 report = _build_report(
629 run_id=run_id,
630 status="configuration_error",
631 exit_code=4,
632 workspace_root=workspace_root,
633 workspace_mode=workspace_mode,
634 packages=packages,
635 profile=profile,
636 marker=cfg.marker if cfg else None,
637 seed=seed,
638 nodeids=list(cfg.nodeids) if cfg else [],
639 workers_requested=workers_requested,
640 workers_actual=None,
641 dist_mode=None,
642 collected=0,
643 tests=[],
644 violations=[],
645 errors=errors,
646 duration_seconds=time.monotonic() - t0,
647 )
648 return RunResult(report=report, exit_code=4)
649
650
651def run_standin(argv: list[str], cwd: Path | None = None) -> RunResult:
652 """Run one stand-in invocation; never raises for contract-covered
653 failures (internal surprises become a 9002 orchestrator error)."""
654 func = "run_standin"
655 t0 = time.monotonic()
656 run_id = str(uuid.uuid4())
657 base_cwd = Path(cwd) if cwd is not None else Path.cwd()
658 _dbg("OC_TESTING", func, f"argv={argv} cwd={base_cwd}", 2)
659 try:
660 return _run(argv, base_cwd, run_id, t0)
661 except SystemExit:
662 raise
663 except BaseException as exc: # 9002: never a bare traceback surface
664 _dbg("OC_TESTING", func, f"internal error: {exc!r}", 1)
665 errors = [make_error(
666 "orchestrator", "9002", "fatal",
667 f"stand-in internal error: {type(exc).__name__}: "
668 f"{str(exc)[:_LONGREPR_CAP]}",
669 )]
670 report = _build_report(
671 run_id=run_id, status="orchestrator_error", exit_code=3,
672 workspace_root=None, workspace_mode=None, packages=[],
673 profile=None, marker=None, seed=None, nodeids=[],
674 workers_requested=None, workers_actual=None, dist_mode=None,
675 collected=0, tests=[], violations=[], errors=errors,
676 duration_seconds=time.monotonic() - t0,
677 )
678 return RunResult(report=report, exit_code=3)
679
680
681def _run(argv: list[str], base_cwd: Path, run_id: str, t0: float) -> RunResult:
682 func = "_run"
683 cfg, parse_errors = parse_argv(argv)
684
685 if parse_errors:
686 code = parse_errors[0]["code"]
687 walk: WalkResult | None = None
688 if code == "PROFILE_INVALID":
689 walk, _werr = walk_for_package(base_cwd)
690 failed_step = "profile"
691 elif code == "9003":
692 failed_step = "workspace"
693 else:
694 failed_step = "argv"
696 run_id=run_id, t0=t0, errors=parse_errors, cfg=cfg,
697 walk=walk, failed_step=failed_step,
698 )
699
700 assert cfg is not None
701 walk, walk_error = walk_for_package(base_cwd)
702 if walk_error is not None:
704 run_id=run_id, t0=t0, errors=[walk_error], cfg=cfg,
705 walk=None, failed_step="workspace",
706 )
707 assert walk is not None
708
709 if cfg.package is not None and cfg.package != walk.package_id:
710 error = make_error(
711 "configuration", "PACKAGE_UNKNOWN", "fatal",
712 f"--package {cfg.package!r} does not match the discovered "
713 f"package {walk.package_id!r} (single-package fallback; no "
714 "workspace file in scope)",
715 )
717 run_id=run_id, t0=t0, errors=[error], cfg=cfg,
718 walk=walk, failed_step="package",
719 )
720
721 profile = cfg.profile if cfg.profile_explicit else (
722 walk.profile_pin if walk.profile_pin in _PROFILE_EXPRESSIONS
723 else "strict"
724 )
725 seed = cfg.seed if cfg.seed is not None else 0
726
727 errors: list[dict] = []
728 violations: list[dict] = []
729
730 def _resolved_report(status: str, exit_code: int, *, collected: int = 0,
731 tests: list[dict] | None = None) -> RunResult:
732 report = _build_report(
733 run_id=run_id, status=status, exit_code=exit_code,
734 workspace_root=str(walk.root), workspace_mode="single_package",
735 packages=[walk.package_id], profile=profile, marker=cfg.marker,
736 seed=seed, nodeids=list(cfg.nodeids),
737 workers_requested=cfg.workers, workers_actual=1,
738 dist_mode="serial", collected=collected,
739 tests=tests or [], violations=violations, errors=errors,
740 duration_seconds=time.monotonic() - t0,
741 )
742 return RunResult(report=report, exit_code=exit_code)
743
744 # -- §10 undeclared-root scan (fatal in strict, incl. --dry-run) -----
745 undeclared = scan_undeclared_roots(
746 walk.root, walk.roots, walk.python_files
747 )
748 if undeclared:
749 severity = "fatal" if profile == "strict" else "warning"
750 for directory in undeclared:
751 try:
752 shown = directory.relative_to(walk.root)
753 except ValueError:
754 shown = directory
755 errors.append(make_error(
756 "configuration", "UNDECLARED_TEST_ROOT", severity,
757 f"undeclared test root: {shown} contains test files but "
758 "is absent from the effective test roots (testpaths; the "
759 "A-18 class)",
760 ))
761 if profile == "strict":
762 return _resolved_report("configuration_error", 4)
763
764 # -- marker composition + validation ---------------------------------
765 profile_expr = _PROFILE_EXPRESSIONS[profile]
766 effective_expr = (
767 f"({profile_expr}) and ({cfg.marker})" if cfg.marker else profile_expr
768 )
769 try:
770 from _pytest.mark.expression import Expression
771 try:
772 Expression.compile(effective_expr)
773 except SyntaxError as exc:
774 # pytest 9 raises SyntaxError here (its pre-9 ParseError is
775 # gone).
776 errors.append(make_error(
777 "configuration", "MARKER_PARSE_ERROR", "fatal",
778 f"marker expression {cfg.marker!r} failed to parse when "
779 f"composed with the {profile} profile: {exc}",
780 ))
781 return _resolved_report("configuration_error", 4)
782 except ImportError: # pragma: no cover - pytest internals moved
783 _dbg("OC_TESTING", func, "Expression API unavailable; deferring "
784 "marker validation to pytest", 2)
785
786 # -- root existence ---------------------------------------------------
787 root_pairs = list(zip(walk.testpaths or ["."], walk.roots))
788 existing_pairs = [(tp, rp) for tp, rp in root_pairs if rp.is_dir()]
789 if not cfg.nodeids:
790 for tp, rp in root_pairs:
791 if not rp.is_dir():
792 errors.append(make_error(
793 "configuration", "EMPTY_TEST_ROOT", "warning",
794 f"declared test root {tp!r} does not exist (stale "
795 "declaration)",
796 ))
797 if not existing_pairs:
798 errors.append(make_error(
799 "configuration", "NO_TESTS_COLLECTED", "fatal",
800 "the union of the effective test roots contains no "
801 "existing directories; the configuration points at no "
802 "tests",
803 ))
804 return _resolved_report("configuration_error", 4)
805
806 # -- compose pytest args ----------------------------------------------
807 # The BASE args carry everything but the selection: a scheduler child
808 # appends its own unit's nodeids instead of the run's roots, so the
809 # two paths compose one identical prefix.
810 base_args = ["-c", str(walk.config_file), "--rootdir", str(walk.root)]
811 if cfg.seed is not None:
812 base_args += ["-p", "randomly", f"--randomly-seed={cfg.seed}"]
813 else:
814 base_args += ["-p", "no:randomly"]
815 base_args += ["-m", effective_expr]
816 if cfg.nodeids:
817 selection = list(cfg.nodeids)
818 else:
819 selection = [tp for tp, _rp in existing_pairs]
820 pytest_args = base_args + selection
821
822 # -- decide the execution path (Plan 240 P2/P3) -----------------------
823 sig_state = _SignalState()
824 workspace_root = walk.root.parent
825 worker_plan: WorkerPlan | None = None
826 manifest_path: Path | None = None
827 serial_reason: str | None = None
828
829 if cfg.dry_run:
830 serial_reason = "dry_run"
831 elif cfg.workers == 1:
832 serial_reason = "workers_1"
833 else:
834 try:
835 manifest_path = manifest_path_for(
836 walk.root, walk.testpaths, workspace_root,
837 )
838 except SchedulerUnavailable as exc:
839 _dbg("OC_TESTING", func, f"classifier unavailable: {exc}", 2)
840 manifest_path = None
841 serial_reason = "classifier_unavailable"
842 if manifest_path is not None and not manifest_path.is_file():
843 _dbg("OC_TESTING", func,
844 f"no manifest at {manifest_path}; serial", 2)
845 manifest_path = None
846 serial_reason = "no_manifest"
847 if manifest_path is not None:
848 try:
849 worker_plan = resolve_worker_plan(
850 cfg.workers, workspace_root, walk.package_id,
851 )
852 except SchedulerUnavailable as exc:
853 _dbg("OC_TESTING", func, f"worker cap unusable: {exc}", 1)
854 manifest_path = None
855 serial_reason = "worker_cap_unusable"
856 if worker_plan is not None:
857 if worker_plan.clamped:
858 errors.append(clamp_error(worker_plan))
859 if worker_plan.resolved <= 1:
860 serial_reason = "single_worker"
861
862 # -- run: the scheduler when it can, the plan-184 serial path else ----
863 scheduling = serial_scheduling(serial_reason)
864 workers_actual = 1
865 dist_mode = DIST_MODE_SERIAL
866 session: SessionOutcome | None = None
867 unit_outcomes: list[UnitOutcome] = []
868 pool_interrupted = False
869 scheduled = False
870 units: tuple = ()
871
872 if serial_reason is None and worker_plan is not None:
873 assert manifest_path is not None
874 collect = run_session(
875 pytest_args=pytest_args, root=walk.root,
876 timeout=cfg.per_test_timeout, dry_run=True,
877 sig_state=sig_state, violation_file=str(walk.root),
878 )
879 nodeids_by_file: dict[str, list[str]] = {}
880 for nodeid, _path in collect.selected:
881 nodeids_by_file.setdefault(nodeid.split("::")[0], []).append(
882 nodeid,
883 )
884 missing: tuple[str, ...] = ()
885 try:
886 groups, missing = load_groups(
887 manifest_path, nodeids_by_file, workspace_root,
888 )
889 except SchedulerUnavailable as exc:
890 groups = {}
891 serial_reason = "classifier_unavailable"
892 _dbg("OC_TESTING", func, f"classifier vanished: {exc}", 1)
893 except Exception as exc: # EXTERNAL_ERROR(L2) - unreadable manifest
894 groups = {}
895 serial_reason = "manifest_unreadable"
896 errors.append(make_error(
897 "configuration", "MANIFEST_UNREADABLE", "warning",
898 f"the parallel manifest {manifest_path.name} could not be "
899 f"read ({exc}); the run fell back to serial execution",
900 ))
901 if serial_reason is None:
902 units = build_schedule(
903 groups, nodeids_by_file, whole_file=collect.whole_file,
904 )
905 if collect.blocked:
906 serial_reason = "static_gate"
907 elif sig_state.interrupted is not None:
908 serial_reason = "interrupted_during_collection"
909 elif len(units) <= 1:
910 serial_reason = "single_unit"
911 if serial_reason is None:
912 if missing:
913 violations.append(unclassified_violation(walk.root, missing))
914 workers_actual = min(worker_plan.resolved, len(units))
915 dist_mode = (
916 DIST_MODE_PARALLEL if workers_actual > 1 else DIST_MODE_SERIAL
917 )
918 guard_prior = os.environ.get(STANDIN_ACTIVE_ENV)
919 prior_handlers: list[tuple[int, object]] = []
920 on_main = threading.current_thread() is threading.main_thread()
921 try:
922 if on_main:
923 for signame in ("SIGINT", "SIGTERM", "SIGBREAK"):
924 signum = getattr(signal, signame, None)
925 if signum is not None:
926 prior_handlers.append((
927 signum,
928 signal.signal(signum, sig_state.on_signal),
929 ))
930 # P7: every child inherits the recursion guard, so the
931 # nested pytest session inside it stays serial and the
932 # plan-239 root conftest never re-parallelises it.
933 os.environ[STANDIN_ACTIVE_ENV] = "1"
934 scheduled = True
935 unit_outcomes, pool_interrupted = run_units(
936 units, workers=workers_actual, root=walk.root,
937 pytest_args=base_args, timeout=cfg.per_test_timeout,
938 origin=t0,
939 )
940 finally:
941 for signum, handler in prior_handlers:
942 signal.signal(signum, handler)
943 if guard_prior is None:
944 os.environ.pop(STANDIN_ACTIVE_ENV, None)
945 else:
946 os.environ[STANDIN_ACTIVE_ENV] = guard_prior
947 session = collect
948 scheduling = _parallel_scheduling(
949 manifest_path=manifest_path, root=walk.root,
950 plan=worker_plan, outcomes=unit_outcomes, missing=missing,
951 )
952 else:
953 scheduling = serial_scheduling(serial_reason)
954
955 if not scheduled:
956 if session is not None:
957 # A collect pass ran and then declined to parallelise; the
958 # serial path re-runs so its report is the plan-184 one,
959 # unchanged in every field but ``scheduling``.
960 _dbg("OC_TESTING", func,
961 f"falling back to serial ({serial_reason})", 2)
962 session = run_session(
963 pytest_args=pytest_args, root=walk.root,
964 timeout=cfg.per_test_timeout, dry_run=cfg.dry_run,
965 sig_state=sig_state, violation_file=str(walk.root),
966 )
967 unit_outcomes = []
968 workers_actual = 1
969 dist_mode = DIST_MODE_SERIAL
970 scheduling = serial_scheduling(serial_reason)
971
972 assert session is not None
973 violations = session.static_violations + session.violations + violations
974 for outcome in unit_outcomes:
975 violations.extend(outcome.violations)
976 if outcome.crashed:
977 violations.append(crash_violation(outcome))
978 errors.append(crash_error(outcome))
979
980 # -- classify ----------------------------------------------------------
981 captured = session.output
982 execution_order = list(session.execution_order)
983 if unit_outcomes:
984 captured = "".join(
985 [session.output]
986 + [f"\n--- {o.unit.worker_id} ({o.unit.group}) ---\n{o.output}"
987 for o in unit_outcomes]
988 )
989 execution_order = [
990 nodeid for outcome in unit_outcomes
991 for nodeid in outcome.unit.nodeids
992 if nodeid in outcome.records
993 ]
994
995 def _finish(status: str, exit_code: int, tests: list[dict],
996 collected: int) -> RunResult:
997 report = _build_report(
998 run_id=run_id, status=status, exit_code=exit_code,
999 workspace_root=str(walk.root), workspace_mode="single_package",
1000 packages=[walk.package_id], profile=profile, marker=cfg.marker,
1001 seed=seed, nodeids=list(cfg.nodeids),
1002 workers_requested=cfg.workers, workers_actual=workers_actual,
1003 dist_mode=dist_mode, collected=collected, tests=tests,
1004 violations=violations, errors=errors,
1005 duration_seconds=time.monotonic() - t0,
1006 scheduling=scheduling,
1007 )
1008 return RunResult(
1009 report=report, exit_code=exit_code,
1010 execution_order=execution_order,
1011 captured_output=captured,
1012 )
1013
1014 if unit_outcomes:
1015 tests = _tests_from_units(unit_outcomes)
1016 collect_errors = [
1017 message for outcome in unit_outcomes
1018 for message in outcome.collect_errors
1019 ]
1020 rcs = [outcome.rc for outcome in unit_outcomes
1021 if not outcome.crashed]
1022 else:
1023 tests = [
1024 {
1025 "nodeid": nodeid,
1026 "outcome": _outcome_for(nodeid, rec, set(session.timed_out)),
1027 "worker_id": "gw0",
1028 "duration_seconds": round(rec["duration"], 2),
1029 "failure_summary": (
1030 rec["longrepr"]
1031 if _outcome_for(nodeid, rec, set(session.timed_out))
1032 in ("failed", "timeout")
1033 else None
1034 ),
1035 }
1036 for nodeid, rec in session.records.items()
1037 ]
1038 collect_errors = list(session.collect_errors)
1039 rcs = [session.rc]
1040
1041 collected_pre = len(session.collected_pre)
1042 selected_post = len(session.selected)
1043 interrupted = sig_state.interrupted is not None or pool_interrupted
1044
1045 if interrupted:
1046 errors.append(make_error(
1047 "signal", "ORCHESTRATOR_INTERRUPTED", "fatal",
1048 f"received {sig_state.interrupted}", "orchestrator",
1049 ))
1050 return _finish("orchestrator_interrupted", 3, tests, collected_pre)
1051
1052 if collect_errors:
1053 errors.append(make_error(
1054 "configuration", "COLLECTION_ERROR", "fatal",
1055 "pytest collection raised for the selected package: "
1056 + collect_errors[0],
1057 ))
1058 return _finish("configuration_error", 4, tests, collected_pre)
1059
1060 unexpected = [rc for rc in rcs if rc in (2, 3)]
1061 if unexpected:
1062 errors.append(make_error(
1063 "orchestrator", "9008", "fatal",
1064 f"in-process pytest returned unexpected exit code "
1065 f"{unexpected[0]}",
1066 ))
1067 return _finish("orchestrator_error", 3, tests, collected_pre)
1068 if any(rc == 4 for rc in rcs):
1069 errors.append(make_error(
1070 "configuration", "9008", "fatal",
1071 "in-process pytest reported a usage error (exit 4); see the "
1072 "captured output",
1073 ))
1074 return _finish("configuration_error", 4, tests, collected_pre)
1075
1076 if collected_pre == 0:
1077 errors.append(make_error(
1078 "configuration", "NO_TESTS_COLLECTED", "fatal",
1079 "collection completed but the union of the effective roots "
1080 "yielded zero collectable items",
1081 ))
1082 return _finish("configuration_error", 4, tests, 0)
1083
1084 # per-root accounting (roots mode only): EMPTY_TEST_ROOT warnings
1085 if not cfg.nodeids:
1086 item_paths = [p.resolve() for _n, p in session.collected_pre]
1087 for tp, rp in existing_pairs:
1088 resolved_root = rp.resolve()
1089 count = sum(
1090 1 for p in item_paths
1091 if p == resolved_root or resolved_root in p.parents
1092 )
1093 if count == 0:
1094 errors.append(make_error(
1095 "configuration", "EMPTY_TEST_ROOT", "warning",
1096 f"declared test root {tp!r} collected zero items "
1097 "while the run's union is non-empty",
1098 ))
1099
1100 if selected_post == 0 and not session.blocked:
1101 if profile == "smoke":
1102 _dbg("OC_TESTING", func,
1103 "smoke fallback: empty effective selection; package "
1104 "import/collection checks passed", 2)
1105 else:
1106 errors.append(make_error(
1107 "configuration", "MARKER_PROFILE_CONFLICT", "warning",
1108 "the effective selection (profile + --marker + --nodeid) "
1109 "evaluated to an empty set; nothing ran",
1110 ))
1111
1112 error_violations = any(v["severity"] == "error" for v in violations)
1113 failed_or_timeout = any(
1114 t["outcome"] in ("failed", "timeout") for t in tests
1115 )
1116 exit_code, status = compute_exit_and_status(
1117 fatal_config=False, interrupted=False, orchestrator_error=False,
1118 error_violations=error_violations,
1119 failed_or_timeout=failed_or_timeout,
1120 )
1121 return _finish(status, exit_code, tests, collected_pre)
1122
1123
1124def _tests_from_units(outcomes: Sequence[UnitOutcome]) -> list[dict]:
1125 """Reconcile every unit's records into one §11 ``tests`` array.
1126
1127 A crashed unit contributes EVERY nodeid it was given, reported
1128 ``failed`` with the spec-10 §8.4 crash summary: the child died
1129 without reporting, so nothing it might have finished can be
1130 believed, and a nodeid that vanished silently would be worse than
1131 one reported failed.
1132 """
1133 tests: list[dict] = []
1134 for outcome in outcomes:
1135 worker_id = outcome.unit.worker_id
1136 if outcome.crashed:
1137 summary = f"worker crashed ({outcome.crash_detail})"
1138 tests.extend({
1139 "nodeid": nodeid,
1140 "outcome": "failed",
1141 "worker_id": worker_id,
1142 "duration_seconds": 0.0,
1143 "failure_summary": summary,
1144 } for nodeid in outcome.unit.nodeids)
1145 continue
1146 timed_out = set(outcome.timed_out)
1147 for nodeid, rec in outcome.records.items():
1148 result = _outcome_for(nodeid, rec, timed_out)
1149 tests.append({
1150 "nodeid": nodeid,
1151 "outcome": result,
1152 "worker_id": worker_id,
1153 "duration_seconds": round(rec["duration"], 2),
1154 "failure_summary": (
1155 rec["longrepr"]
1156 if result in ("failed", "timeout") else None
1157 ),
1158 })
1159 return tests
1160
1161
1163 *,
1164 manifest_path: Path,
1165 root: Path,
1166 plan: WorkerPlan,
1167 outcomes: Sequence[UnitOutcome],
1168 missing: Sequence[str],
1169) -> dict:
1170 """The ``scheduling`` block of a run that distributed its groups.
1171
1172 Sorted by group name so the document is byte-stable regardless of
1173 which child finished first (P3's determinism clause).
1174 """
1175 try:
1176 manifest = Path(manifest_path).resolve().relative_to(
1177 Path(root).resolve(),
1178 ).as_posix()
1179 except ValueError: # pragma: no cover - manifest outside the package
1180 manifest = Path(manifest_path).as_posix()
1181 groups = [
1182 {
1183 "group": outcome.unit.group,
1184 "worker_id": outcome.unit.worker_id,
1185 "files": len(outcome.unit.files),
1186 "paths": list(outcome.unit.files),
1187 "tests": len(outcome.unit.nodeids),
1188 "duration_seconds": round(outcome.duration_seconds, 2),
1189 "started_seconds": round(outcome.started_seconds, 2),
1190 "finished_seconds": round(outcome.finished_seconds, 2),
1191 "pid": outcome.pid,
1192 "status": "crashed" if outcome.crashed else "completed",
1193 "violations": len(outcome.violations),
1194 }
1195 for outcome in outcomes
1196 ]
1197 groups.sort(key=lambda entry: (entry["group"], entry["worker_id"]))
1198 return {
1199 "mode": "parallel",
1200 "reason": None,
1201 "manifest": manifest,
1202 "workers_cap": plan.cap,
1203 "workers_resolved": plan.resolved,
1204 "groups": groups,
1205 "unclassified": sorted(missing),
1206 }
1207
1208
1209_dbg("OC_TESTING", "module", "loaded", 4)
1210
1211__all__ = [
1212 "RunResult",
1213 "STANDIN_ACTIVE_ENV",
1214 "SessionOutcome",
1215 "StandInTimeoutError",
1216 "TRACKED_ENV_PREFIXES",
1217 "run_session",
1218 "run_standin",
1219 "serial_scheduling",
1220]
Iterator[None] pytest_collection_modifyitems(self, pytest.Config config, list[pytest.Item] items)
Definition runner.py:255
None _fire(self, str nodeid)
Definition runner.py:297
None pytest_runtest_logreport(self, pytest.TestReport report)
Definition runner.py:330
bool|None pytest_runtestloop(self, pytest.Session session)
Definition runner.py:279
None __init__(self, *, bool dry_run, int|None timeout, int thread_ident)
Definition runner.py:220
None pytest_sessionstart(self, pytest.Session session)
Definition runner.py:247
Generator[None, object, object] pytest_runtest_protocol(self, pytest.Item item, pytest.Item|None nextitem)
Definition runner.py:311
None pytest_collection_finish(self, pytest.Session session)
Definition runner.py:260
None pytest_collectreport(self, pytest.CollectReport report)
Definition runner.py:270
None on_signal(self, int signum, object frame)
Definition runner.py:200
dict serial_scheduling(str|None reason)
Definition runner.py:581
RunResult _pre_execution_result(*, str run_id, float t0, list[dict] errors, StandInConfig|None cfg, WalkResult|None walk, str failed_step)
Definition runner.py:608
RunResult run_standin(list[str] argv, Path|None cwd=None)
Definition runner.py:651
dict[str, str] _features_for(str|None dist_mode)
Definition runner.py:565
list[dict] _tests_from_units(Sequence[UnitOutcome] outcomes)
Definition runner.py:1124
dict _parallel_scheduling(*, Path manifest_path, Path root, WorkerPlan plan, Sequence[UnitOutcome] outcomes, Sequence[str] missing)
Definition runner.py:1169
dict _build_report(*, str run_id, str status, int exit_code, str|None workspace_root, str|None workspace_mode, list[str] packages, str|None profile, str|None marker, int|None seed, list[str] nodeids, int|str|None workers_requested, int|None workers_actual, str|None dist_mode, int collected, list[dict] tests, list[dict] violations, list[dict] errors, float duration_seconds, dict|None scheduling=None)
Definition runner.py:519
str _outcome_for(str nodeid, dict rec, set[str] timed_out)
Definition runner.py:346
SessionOutcome run_session(*, Sequence[str] pytest_args, Path root, int|None timeout, bool dry_run, _SignalState|None sig_state=None, bool install_signal_handlers=True, str|None violation_file=None, str violation_scope="")
Definition runner.py:366
RunResult _run(list[str] argv, Path base_cwd, str run_id, float t0)
Definition runner.py:681