Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ pip install -r requirements-dev.txt
## Architecture

### Entry point
`src/pytest_fly/__main__.py` → `main.py` initializes the stdlib-based logger (`logger.py`) and launches the Qt app.
`src/pytest_fly/__main__.py` → `main.py` initializes the stdlib-based logger (`logger.py`) and launches the Qt app. The GUI package is imported lazily inside `main.fly_main` and `pytest_runner/__init__.py` resolves its exports lazily: every spawn child re-imports `__main__`, and must not load PySide6 or the orchestration layer as a side effect (`tests/test_main_app.py::test_spawn_child_entry_does_not_import_gui`).

### GUI layer (`src/pytest_fly/gui/`)
- `gui_main.py` — `FlyAppMainWindow`: 8-tab Qt window with a periodic timer (default 3 s) that pulls updates from the runner and refreshes all tabs.
Expand All @@ -64,7 +64,7 @@ pip install -r requirements-dev.txt
- `system_monitor.py` — `SystemMonitor` subprocess: system-wide CPU/memory/commit/disk/network sampling for the Run tab charts.
- `commit_memory.py` — Windows commit-charge readers and psutil subtree helpers.
- `coverage.py` — merges per-process coverage data (one `cov.report()` pass yields both the percentage and the TOTAL line).
- `coverage_aggregator.py` — `CoverageAggregator` spawn child + `aggregate_coverage()`: runs `calculate_coverage()` out of the GUI process (it has crashed the interpreter natively); a dead/hung child is a logged warning, not a crash.
- `coverage_aggregator.py` — `CoverageAggregator` spawn child + `aggregate_coverage()` → `CoverageResult`: runs `calculate_coverage()` and the per-test executed-line counts out of the GUI process (constructing `coverage.Coverage` in-process has crashed the interpreter natively — the GUI never does it); a dead/hung child is a logged warning, not a crash.
- `ordering.py` — applies the user's test-ordering aspects; `live_output.py` — per-test live-output file paths.

### Crash diagnostics
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[project]
name = "pytest-fly"
description = "pytest runner and observer"
version = "0.9.0"
version = "0.9.1"
readme = "README.md"
requires-python = ">=3.12"
authors = [
Expand Down
28 changes: 6 additions & 22 deletions src/pytest_fly/gui/coverage_tracker.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,9 @@
from pathlib import Path
from threading import Event, Lock, Thread

from coverage import Coverage

from ..file_util import sanitize_test_name
from ..interfaces import PytestRunnerState
from ..logger import get_logger
from ..preferences import get_pref
from ..pytest_runner.coverage import COVERAGE_READ_ERRORS
from ..pytest_runner.coverage_aggregator import aggregate_coverage
from ..tick_data import TickData

Expand Down Expand Up @@ -177,30 +173,18 @@ def _calculate(self, completed: set[str], generation: int, run_start: float | No
previously published values stand and this pass is skipped.
"""
timeout = self._timeout_seconds if self._timeout_seconds is not None else get_pref().coverage_timeout_seconds
result = aggregate_coverage("current", self._data_dir, write_report=False, timeout=timeout)
# One child does both the combine/report pass and the per-test executed-line counts, so
# no coverage.Coverage object is ever constructed in this (GUI) process.
result = aggregate_coverage("current", self._data_dir, write_report=False, timeout=timeout, per_test_names=sorted(completed))
with self._lock:
self._last_calculation_time = time.monotonic() # counts failed attempts too, so a crashing child is not re-spawned every tick
if result is None:
return
coverage_pct, covered_lines, total_lines = result
coverage_pct, covered_lines, total_lines = result.totals

# Recompute per-test coverage for ALL completed tests since the denominator
# Per-test coverage for ALL completed tests is recomputed each pass since the denominator
# (total_lines) may have changed as new tests discover new source files.
per_test_coverage: dict[str, float] = {}
if total_lines > 0:
coverage_dir = Path(self._data_dir, "coverage")
for test_name in completed:
safe_name = sanitize_test_name(test_name)
cov_file = coverage_dir / f"{safe_name}.coverage"
if cov_file.exists():
try:
cov = Coverage(cov_file)
cov.load()
data = cov.get_data()
executed = sum(len(data.lines(f) or []) for f in data.measured_files())
per_test_coverage[test_name] = executed / total_lines
except COVERAGE_READ_ERRORS as e:
log.info(f"per-test coverage for {test_name} failed: {e}")
per_test_coverage = result.per_test_fractions(total_lines)

with self._lock:
if generation != self._generation:
Expand Down
10 changes: 8 additions & 2 deletions src/pytest_fly/gui/run_tab/control_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
from ...preferences import ParallelismControl, duration_to_seconds, get_ordering_aspects_ordered, get_pref
from ...put_version import detect_put_version
from ...pytest_runner.admission import AdmissionGateConfig
from ...pytest_runner.coverage import compute_per_test_coverage
from ...pytest_runner.coverage_aggregator import aggregate_coverage
from ...pytest_runner.ordering import OrderingContext, apply_ordering_aspects
from ...pytest_runner.pytest_runner import PytestRunner
from ...pytest_runner.resource_guard import ResourceGuardConfig
Expand Down Expand Up @@ -60,6 +60,7 @@ class _RunPrepConfig:
gate_config: AdmissionGateConfig
stall_config: StallConfig
resource_guard_config: ResourceGuardConfig
coverage_timeout_seconds: float


@dataclass
Expand Down Expand Up @@ -298,6 +299,7 @@ def run(self):
min_free_disk_gb=pref.resource_guard_min_free_disk_gb,
commit_threshold=pref.resource_guard_commit_threshold,
),
coverage_timeout_seconds=pref.coverage_timeout_seconds,
)
self._run_prep_abort.clear()
self._run_prep_thread = Thread(target=self._prepare_run, args=(config, self.pytest_runner), name="run_prep", daemon=True)
Expand Down Expand Up @@ -430,7 +432,11 @@ def _build_runner(self, config: _RunPrepConfig, prior_runner: PytestRunner | Non
# means "rerun every test," not "forget the durations/failures we know about."
per_test_cov: dict[str, float] = {}
if OrderingAspect.COVERAGE_EFFICIENCY in config.enabled_aspects:
per_test_cov = compute_per_test_coverage(self.data_dir, [t.node_id for t in tests])
# Out of process: constructing coverage.Coverage objects inside the GUI process has
# crashed it natively. A dead or hung child just means no coverage-based ordering
# for this run.
result = aggregate_coverage("ordering", self.data_dir, write_report=False, timeout=config.coverage_timeout_seconds, per_test_names=[t.node_id for t in tests])
per_test_cov = result.per_test_fractions() if result is not None else {}
# Coverage-efficiency reads duration/coverage off the ScheduledTest
# itself, so rebuild the list with those fields populated.
tests = [
Expand Down
17 changes: 16 additions & 1 deletion src/pytest_fly/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@

from .__version__ import application_name
from .faults import enable_faulthandler, report_previous_crashes
from .gui import fly_main
from .logger import get_logger, init_parent_logger
from .paths import get_default_data_dir, get_workspace_dir, init_workspace
from .platform.wer import report_previous_crash_dumps
Expand All @@ -17,6 +16,22 @@
log = get_logger(application_name)


def fly_main(data_dir: Path, *, auto_start: bool = False, auto_quit_on_done: bool = False) -> None:
"""Launch the Qt GUI (lazy wrapper around :func:`pytest_fly.gui.fly_main`).

pytest-fly's own GUI package is imported here, at call time, rather than at module
level: every spawn child (test process, process monitor, system monitor, coverage
aggregator, test discovery) re-imports ``pytest_fly.__main__`` and therefore this module,
and a module-level ``from .gui import fly_main`` loaded PySide6 and all of pytest-fly's
widgets into every one of them — ~0.3 s and tens of MB of commit per child, and
pytest-fly's Qt binding resident in the test process before the program under test's own
tests (which may use a different Qt binding) ever run. The PUT's own imports are unaffected.
"""
from .gui import fly_main as gui_fly_main

gui_fly_main(data_dir, auto_start=auto_start, auto_quit_on_done=auto_quit_on_done)


def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(prog=application_name, description="pytest-fly: pytest runner and observer GUI")
parser.add_argument(
Expand Down
30 changes: 27 additions & 3 deletions src/pytest_fly/pytest_runner/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,27 @@
from .pytest_runner import PytestRunner as PytestRunner
from .pytest_runner import PytestRunState as PytestRunState
from .test_list import GetTests as GetTests
"""pytest-fly test runner package.

The runner classes are resolved lazily (PEP 562): spawn children unpickle
:class:`ProcessMonitor` / :class:`PytestProcess` / :class:`GetTests` through this package, and
an eager ``from .pytest_runner import PytestRunner`` here would drag the whole orchestration
layer (and, transitively, pytest) into every child.
"""

from typing import Any

__all__ = ["GetTests", "PytestRunState", "PytestRunner"]

_LAZY = {
"PytestRunner": ("pytest_fly.pytest_runner.pytest_runner", "PytestRunner"),
"PytestRunState": ("pytest_fly.pytest_runner.pytest_runner", "PytestRunState"),
"GetTests": ("pytest_fly.pytest_runner.test_list", "GetTests"),
}


def __getattr__(name: str) -> Any:
try:
module_name, attribute = _LAZY[name]
except KeyError:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from None
from importlib import import_module

return getattr(import_module(module_name), attribute)
44 changes: 31 additions & 13 deletions src/pytest_fly/pytest_runner/coverage.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@
Combines individual ``.coverage`` files produced by each :class:`PytestProcess`
into a unified report and calculates per-test coverage fractions used for
coverage-efficiency ordering.

Everything here constructs :class:`coverage.Coverage` objects, which has crashed the
interpreter natively when run on a thread inside the GUI process. The GUI therefore never
calls these functions directly — it goes through
:func:`pytest_fly.pytest_runner.coverage_aggregator.aggregate_coverage`, which runs them in a
short-lived spawn child. Direct calls are for that child and for tests.
"""

import io
Expand Down Expand Up @@ -155,22 +161,19 @@ def calculate_coverage(test_identifier: str, coverage_parent_directory: Path, wr
return coverage_value, covered_statements, total_statements


def compute_per_test_coverage(data_dir: Path, test_names: list[str]) -> dict[str, float]:
"""Compute per-test coverage fractions from stored per-test coverage files.

Loads each test's individual ``.coverage`` file, counts executed lines,
and divides by the total lines across all tests to produce a fraction.
def per_test_executed_lines(data_dir: Path, test_names: list[str]) -> tuple[dict[str, int], int]:
"""Count executed lines per test from the stored per-test ``.coverage`` files.

:param data_dir: The application data directory containing the ``coverage/`` subdirectory.
:param test_names: List of test node_ids (e.g. ``"tests/test_foo.py"``).
:return: Mapping of test name to coverage fraction (0.0--1.0). Tests
without a coverage file are omitted.
:return: ``(executed_lines_by_test, union_executed_lines)`` — the executed line count of
every test that has a readable coverage file, and the size of the union of executed
lines across all of them (the denominator for coverage-efficiency ordering).
"""
coverage_dir = Path(data_dir, "coverage")
if not coverage_dir.exists():
return {}
return {}, 0

# Load each test's coverage data and count executed lines
per_test_lines: dict[str, int] = {}
all_file_lines: dict[str, set[int]] = {} # source_file -> set of executed line numbers (union across all tests)

Expand All @@ -192,8 +195,23 @@ def compute_per_test_coverage(data_dir: Path, test_names: list[str]) -> dict[str
except COVERAGE_READ_ERRORS as e:
log.info(f"per-test coverage load for {test_name} failed: {e}")

total_lines = sum(len(lines) for lines in all_file_lines.values())
if total_lines == 0:
return {}
union_lines = sum(len(lines) for lines in all_file_lines.values())
return per_test_lines, union_lines

return {name: executed / total_lines for name, executed in per_test_lines.items()}

def compute_per_test_coverage(data_dir: Path, test_names: list[str]) -> dict[str, float]:
"""Compute per-test coverage fractions from stored per-test coverage files.

Each test's executed line count divided by the union of executed lines across all the
given tests. Runs in-process — see the module docstring; the GUI obtains the same numbers
via the aggregator child.

:param data_dir: The application data directory containing the ``coverage/`` subdirectory.
:param test_names: List of test node_ids (e.g. ``"tests/test_foo.py"``).
:return: Mapping of test name to coverage fraction (0.0--1.0). Tests
without a coverage file are omitted.
"""
per_test_lines, union_lines = per_test_executed_lines(data_dir, test_names)
if union_lines == 0:
return {}
return {name: executed / union_lines for name, executed in per_test_lines.items()}
62 changes: 53 additions & 9 deletions src/pytest_fly/pytest_runner/coverage_aggregator.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,40 +6,82 @@
``python.dll``) while running on a thread inside the GUI process, taking the orchestrator and
the run down with it.

Nothing about that work needs to be in-process: it reads files and returns three numbers. So it
Nothing about that work needs to be in-process: it reads files and returns numbers. So it
runs in a short-lived spawn child, like :class:`ProcessMonitor` and :class:`GetTests`. A child
that dies or hangs is a logged warning and the last good values stand; the run continues.

The same child also counts per-test executed lines (for the Coverage tab's per-test view and
for coverage-efficiency ordering) when asked, so that no :class:`coverage.Coverage` object is
ever constructed in the GUI process at all.
"""

import time
from dataclasses import dataclass, field
from multiprocessing import Process, Queue
from pathlib import Path
from queue import Empty

from ..faults import enable_faulthandler
from ..logger import EVENT_EXTRA, configure_child_logger, get_logger
from .coverage import calculate_coverage
from .coverage import calculate_coverage, per_test_executed_lines

log = get_logger()

CoverageResult = tuple[float | None, int, int] # (coverage 0.0-1.0 or None, covered statements, total statements)

@dataclass(frozen=True)
class CoverageResult:
"""What one aggregation pass produced.

``per_test_executed_lines`` and ``union_executed_lines`` are only populated when the
caller supplied ``per_test_names``; they describe those tests only.
"""

coverage: float | None # overall coverage 0.0-1.0, or None when there is no data yet
covered_statements: int
total_statements: int
per_test_executed_lines: dict[str, int] = field(default_factory=dict) # test node_id -> executed line count
union_executed_lines: int = 0 # size of the union of executed lines across the requested tests

@property
def totals(self) -> tuple[float | None, int, int]:
"""The ``(coverage, covered, total)`` triple, as :func:`calculate_coverage` returns it."""
return self.coverage, self.covered_statements, self.total_statements

def per_test_fractions(self, denominator: int | None = None) -> dict[str, float]:
"""Per-test executed lines as fractions of *denominator*.

:param denominator: line count to divide by; ``None`` uses ``union_executed_lines``
(the coverage-efficiency ordering convention). The Coverage tab passes
``total_statements`` instead.
:return: empty when the denominator is zero.
"""
if denominator is None:
denominator = self.union_executed_lines
if denominator <= 0:
return {}
return {name: executed / denominator for name, executed in self.per_test_executed_lines.items()}


class CoverageAggregator(Process):
"""Spawn child that runs :func:`calculate_coverage` once and reports the result on a queue."""
"""Spawn child that runs :func:`calculate_coverage` once and reports a :class:`CoverageResult` on a queue."""

def __init__(self, test_identifier: str, coverage_parent_directory: Path, write_report: bool) -> None:
def __init__(self, test_identifier: str, coverage_parent_directory: Path, write_report: bool, per_test_names: list[str] | None = None) -> None:
super().__init__(name="coverage_aggregator", daemon=True)
self._test_identifier = test_identifier
self._coverage_parent_directory = coverage_parent_directory
self._write_report = write_report
self._per_test_names = list(per_test_names) if per_test_names else []
self._result_queue: Queue = Queue()

def run(self) -> None:
configure_child_logger("coverage_aggregator.log")
enable_faulthandler() # reads PYTEST_FLY_FAULTHANDLER from the inherited environment
result = calculate_coverage(self._test_identifier, self._coverage_parent_directory, self._write_report)
self._result_queue.put(result)
coverage, covered, total = calculate_coverage(self._test_identifier, self._coverage_parent_directory, self._write_report)
per_test: dict[str, int] = {}
union = 0
if self._per_test_names:
per_test, union = per_test_executed_lines(self._coverage_parent_directory, self._per_test_names)
self._result_queue.put(CoverageResult(coverage, covered, total, per_test, union))

def result(self) -> CoverageResult | None:
"""The child's result, or ``None`` if it produced none (crashed, killed, or still running)."""
Expand All @@ -49,15 +91,17 @@ def result(self) -> CoverageResult | None:
return None


def aggregate_coverage(test_identifier: str, coverage_parent_directory: Path, write_report: bool, timeout: float) -> CoverageResult | None:
def aggregate_coverage(test_identifier: str, coverage_parent_directory: Path, write_report: bool, timeout: float, per_test_names: list[str] | None = None) -> CoverageResult | None:
"""Run :func:`calculate_coverage` in a child process and wait for it.

:param timeout: seconds before the child is terminated as hung.
:param per_test_names: tests whose executed-line counts should also be returned (see
:attr:`CoverageResult.per_test_executed_lines`); ``None`` skips that pass.
:return: the coverage result, or ``None`` when the child crashed, was killed, or timed out —
never raises for those; the caller keeps its last good values.
"""
start = time.monotonic()
child = CoverageAggregator(test_identifier, coverage_parent_directory, write_report)
child = CoverageAggregator(test_identifier, coverage_parent_directory, write_report, per_test_names)
child.start()
child.join(timeout)
if child.is_alive():
Expand Down
Loading
Loading