v0.166.0
  1"""
  2What came of a run, as data.
  3
  4A `RunReport` is everything the runner knows when a run is over: what was
  5asked for, what ran, what failed and why, and how the command will exit.
  6It is put together once. The text reporter prints it and `--json` writes
  7it as a document, and neither works out anything the other would have to
  8work out again.
  9"""
 10
 11from dataclasses import dataclass
 12
 13from .execution import RaisedWarning, TestRun
 14from .failure import CollectionFailure
 15from .output_capture import NO_OUTPUT, Output
 16from .phases import Phase
 17
 18__all__ = []
 19
 20EXIT_PASSED = 0
 21# A test failed, or a file couldn't be collected.
 22EXIT_FAILED = 1
 23# The command was given something it can't use: a target that isn't there,
 24# flags that don't go together, a `tests/lifecycle.py` that declares no
 25# lifecycle.
 26EXIT_UNUSABLE = 2
 27# The run couldn't start: setting up the app or a lifecycle failed. The
 28# tests are no more wrong than they were; what they run on isn't there.
 29EXIT_SETUP_FAILED = 3
 30EXIT_NO_TESTS_FOUND = 4
 31# Stopped from outside, with Ctrl-C. 128 + SIGINT, as a shell reports it.
 32EXIT_INTERRUPTED = 130
 33
 34EXIT_CODE_OF_A_STOPPED_RUN = {
 35    "lifecycle_error": EXIT_UNUSABLE,
 36    "target_not_found": EXIT_UNUSABLE,
 37    "setup_error": EXIT_SETUP_FAILED,
 38    "no_tests_found": EXIT_NO_TESTS_FOUND,
 39    "interrupted": EXIT_INTERRUPTED,
 40}
 41
 42
 43@dataclass(frozen=True, kw_only=True)
 44class Command:
 45    """What the run was asked to do."""
 46
 47    # The command line, as the process was given it.
 48    argv: tuple[str, ...]
 49    # Where the command was run from. Every path in a report is relative
 50    # to it.
 51    directory: str
 52    targets: tuple[str, ...]
 53    # The text `--match` was given.
 54    match: str | None
 55    tags: tuple[str, ...]
 56    exclude_tags: tuple[str, ...]
 57    fail_fast: bool
 58    full_values: bool
 59
 60
 61@dataclass(frozen=True, kw_only=True)
 62class StoppedRun:
 63    """Why a run ended before any test was run."""
 64
 65    # "lifecycle_error", "target_not_found", "setup_error", "no_tests_found"
 66    # or "interrupted"
 67    reason: str
 68    message: str
 69    traceback: str | None = None
 70    # What had been written by then, outside any file being collected.
 71    output: Output = NO_OUTPUT
 72
 73
 74@dataclass(frozen=True, kw_only=True)
 75class Counts:
 76    # The tests the command's targets and options chose.
 77    selected: int
 78    passed: int
 79    failed: int
 80    skipped: int
 81    # Chosen and never finished: the run stopped at a failure, with
 82    # `--fail-fast`, or was interrupted. The test that was running when it
 83    # was interrupted is one of them.
 84    not_run: int
 85    collection_errors: int
 86    # Distinct warnings, not how many times each was raised.
 87    warnings: int
 88
 89
 90@dataclass(frozen=True, kw_only=True)
 91class ParameterNothingPasses:
 92    """A parameter that tests across the run take and nothing passes in."""
 93
 94    name: str
 95    # How many tests take it, and in how many files.
 96    tests: int
 97    files: int
 98
 99
100@dataclass(frozen=True, kw_only=True)
101class RunReport:
102    command: Command
103    # None when the run ended before any test was run. Then `stopped` says
104    # why.
105    run: TestRun | None
106    stopped: StoppedRun | None
107    collection_failures: tuple[CollectionFailure, ...]
108    selected: int
109    # Where the time went, phase by phase, as far as the run got.
110    phases: tuple[Phase, ...]
111    # What was written outside any test and any file being collected:
112    # setting up the app, setting up the lifecycles and taking them down.
113    # Empty for a stopped run, whose `stopped.output` it is.
114    output: Output = NO_OUTPUT
115
116    @property
117    def parameters_nothing_passes(self) -> tuple[ParameterNothingPasses, ...]:
118        """
119        Each parameter that tests take and nothing passes in, added up over
120        the run: the one taken by the most tests first. A file's error has
121        that file's. A suite with sixty such files is sixty tables, and this
122        is their total.
123        """
124        tests: dict[str, int] = {}
125        files: dict[str, int] = {}
126        for failure in self.collection_failures:
127            for name, count in failure.parameters:
128                tests[name] = tests.get(name, 0) + count
129                files[name] = files.get(name, 0) + 1
130        return tuple(
131            ParameterNothingPasses(name=name, tests=tests[name], files=files[name])
132            for name in sorted(tests, key=lambda name: (-tests[name], name))
133        )
134
135    @property
136    def warnings(self) -> tuple[RaisedWarning, ...]:
137        return tuple(self.run.warnings) if self.run is not None else ()
138
139    @property
140    def counts(self) -> Counts:
141        run = self.run
142        if run is None:
143            return Counts(
144                selected=self.selected,
145                passed=0,
146                failed=0,
147                skipped=0,
148                not_run=self.selected,
149                collection_errors=len(self.collection_failures),
150                warnings=0,
151            )
152        return Counts(
153            selected=self.selected,
154            passed=len(run.passed),
155            failed=len(run.failed),
156            skipped=len(run.skipped),
157            not_run=self.selected - len(run.results),
158            collection_errors=len(self.collection_failures),
159            warnings=len(run.warnings),
160        )
161
162    @property
163    def outcome(self) -> str:
164        """ "passed", "failed", "interrupted" or "stopped" """
165        if self.stopped is not None:
166            return "stopped"
167        assert self.run is not None
168        if self.run.interrupted is not None:
169            return "interrupted"
170        if self.run.failed or self.collection_failures:
171            return "failed"
172        return "passed"
173
174    @property
175    def exit_code(self) -> int:
176        if self.stopped is not None:
177            return EXIT_CODE_OF_A_STOPPED_RUN[self.stopped.reason]
178        return {
179            "passed": EXIT_PASSED,
180            "failed": EXIT_FAILED,
181            "interrupted": EXIT_INTERRUPTED,
182        }[self.outcome]