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]