1import os
2import sys
3from pathlib import Path
4from typing import TYPE_CHECKING, Protocol
5
6import click
7
8if TYPE_CHECKING:
9 from .execution import TestResult
10 from .loading import RewriterWork
11 from .output_capture import OutputCapture
12 from .phases import Part
13 from .report import Command, RunReport
14
15__all__ = []
16
17
18class Reporter(Protocol):
19 """What a run tells as it goes: the text reporter, or the JSON one."""
20
21 def collected(self, count: int) -> None: ...
22
23 def result(self, result: TestResult) -> None: ...
24
25 def finished(self, report: RunReport) -> None: ...
26
27
28@click.command()
29@click.argument("targets", nargs=-1)
30@click.option(
31 "--match",
32 metavar="TEXT",
33 default=None,
34 help="Run only tests whose id contains TEXT",
35)
36@click.option(
37 "--tag",
38 "tags",
39 metavar="NAME",
40 multiple=True,
41 help="Run only tests tagged NAME (repeat for any of several)",
42)
43@click.option(
44 "--exclude-tag",
45 "exclude_tags",
46 metavar="NAME",
47 multiple=True,
48 help="Leave out tests tagged NAME (repeatable)",
49)
50@click.option("--fail-fast", is_flag=True, help="Stop at the first failure")
51@click.option(
52 "--verbose",
53 is_flag=True,
54 help="Print one line per test (not with --json)",
55)
56@click.option(
57 "--full-values",
58 is_flag=True,
59 help="Print every value and all the output in a failure, however long",
60)
61@click.option(
62 "--show-output",
63 is_flag=True,
64 help="Let what tests print and log through as they write it (not with --json)",
65)
66@click.option(
67 "--json",
68 "as_json",
69 is_flag=True,
70 help="Print the run as one JSON document, when it is over",
71)
72@click.option(
73 "--list-passed",
74 is_flag=True,
75 help="With --json, list the tests that passed too",
76)
77def main(
78 targets: tuple[str, ...],
79 match: str | None,
80 tags: tuple[str, ...],
81 exclude_tags: tuple[str, ...],
82 fail_fast: bool,
83 verbose: bool,
84 full_values: bool,
85 show_output: bool,
86 as_json: bool,
87 list_passed: bool,
88) -> None:
89 """Run tests
90
91 With no TARGETS, runs every test in every `test_*.py` file under the
92 directory the command runs from. A target narrows that to a directory, a
93 file, or one test, and a path is relative to the same directory.
94
95 \b
96 tests/accounts a directory
97 tests/test_signup.py a file
98 tests/test_signup.py::test_welcome a test
99 tests/test_signup.py:42 the test line 42 is in
100 'tests/test_price.py::test_total[annual]' one case of a test
101
102 Quote a target that names a case. A failure prints the command that
103 runs it again, already quoted.
104
105 What a test prints or logs is held while it runs. A test that fails has
106 it printed with its failure, and a test that passes has it thrown away.
107
108 With --json nothing is printed until the run is over, and then one
109 document is: what was asked for, what ran, and for each failure
110 everything the text report would have said, as data.
111
112 \b
113 Exits with:
114 0 every test that ran passed
115 1 a test failed, or a file couldn't be collected
116 2 the command can't be used as given
117 3 setting up failed, so no test was run
118 4 no tests matched
119 130 stopped with Ctrl-C
120 """
121 if as_json and verbose:
122 raise click.UsageError(
123 "--json prints one document when the run is over, and --verbose"
124 " has nothing to add to it. Pass --list-passed to have every test"
125 " in the document."
126 )
127 if as_json and show_output:
128 raise click.UsageError(
129 "--json writes one document to stdout and nothing else, so it"
130 " can't let output through with --show-output. A failure in the"
131 " document carries what its test wrote."
132 )
133 if list_passed and not as_json:
134 raise click.UsageError(
135 "--list-passed says what goes in the --json document. For a line"
136 " per test as they run, pass --verbose."
137 )
138
139 # Tests run with PLAIN_ENV=test. The runner reads no `.env` files itself:
140 # plain.dev does, for every command, from the hook it registers with
141 # `plain.runtime.setup()`. With this set first, that hook picks
142 # `.env.test*` and skips `.env.local`, so a run doesn't depend on what
143 # one machine has in it.
144 os.environ.setdefault("PLAIN_ENV", "test")
145 # Marks the process as a test run for code that behaves differently under
146 # one — CLI color, for instance, which would otherwise add escape codes to
147 # output a test is asserting on.
148 os.environ["PLAIN_TEST_RUNNING"] = "1"
149
150 from .json_report import JsonReporter
151 from .output_capture import OutputCapture
152 from .report import Command
153 from .reporting import TextReporter
154
155 command = Command(
156 argv=tuple(sys.argv),
157 directory=str(Path.cwd()),
158 targets=targets,
159 match=match,
160 tags=tags,
161 exclude_tags=exclude_tags,
162 fail_fast=fail_fast,
163 full_values=full_values,
164 )
165
166 # Held from here, before the app is set up: setting it up writes too.
167 with OutputCapture(show_output=show_output, full_output=full_values) as capture:
168 reporter: Reporter
169 if as_json:
170 reporter = JsonReporter(out=capture.real_stdout, list_passed=list_passed)
171 else:
172 reporter = TextReporter(
173 out=capture.real_stdout,
174 err=capture.real_stderr,
175 verbose=verbose,
176 # For someone watching a terminal. What is let through with
177 # --show-output is written where a line of progress would
178 # be, so there is none then.
179 progress=capture.real_stdout.isatty() and not show_output,
180 )
181 report = _run(command, capture=capture, reporter=reporter)
182 reporter.finished(report)
183
184 sys.exit(report.exit_code)
185
186
187def _run(command: Command, *, capture: OutputCapture, reporter: Reporter) -> RunReport:
188 import plain.runtime
189
190 from ..definition import TestDefinitionError
191 from .collection import collect_tests
192 from .execution import TestRun, run_tests
193 from .failure import describe_collection_error, format_traceback, shown_path
194 from .layout import find_tests_directory, import_helper_modules_from
195 from .lifecycle_discovery import load_app_lifecycle, load_package_lifecycles
196 from .loading import rewriter_work
197 from .output_capture import joined
198 from .phases import RunTimeline, runtime_setup_parts
199 from .report import RunReport, StoppedRun
200 from .reporting import collection_error_text
201 from .targets import TargetError
202
203 # The time before this point went on the `plain` command finding this
204 # one and reading its options.
205 timeline = RunTimeline()
206 timeline.phase_ended("command")
207
208 # What is written outside any test and any file being collected, a
209 # piece at a time as the run gets on.
210 outside_tests = []
211
212 def stopped(
213 reason: str,
214 message: str,
215 *,
216 traceback: str | None = None,
217 run: TestRun | None = None,
218 ) -> RunReport:
219 outside_tests.append(capture.take())
220 return RunReport(
221 command=command,
222 run=run,
223 stopped=StoppedRun(
224 reason=reason,
225 message=message,
226 traceback=traceback,
227 output=joined(outside_tests),
228 ),
229 collection_failures=(),
230 selected=0,
231 phases=timeline.phases(),
232 )
233
234 def could_not_set_up(what: str, error: BaseException) -> RunReport:
235 if isinstance(error, SystemExit):
236 message = f"{what} exited, with {error.code!r}."
237 else:
238 message = f"{what} raised {type(error).__qualname__}: {error}"
239 return stopped("setup_error", message, traceback=format_traceback(error))
240
241 try:
242 # App mode: a resolvable Plain app gets the packages' lifecycles.
243 # Library mode: no app, kernel only — collection, assertions, and
244 # runner still work. The runtime is the authority on whether there's
245 # an app.
246 try:
247 plain.runtime.setup()
248 except plain.runtime.AppPathNotFound:
249 has_app = False
250 application_directory = None
251 except KeyboardInterrupt:
252 raise
253 except BaseException as error:
254 return could_not_set_up("Setting up the app", error)
255 else:
256 has_app = True
257 # Tests aren't kept in the application: it is what is imported
258 # as `app` and what gets deployed. Collection leaves its test
259 # files out and says that it did.
260 application_directory = plain.runtime.APP_PATH
261 timeline.phase_ended("runtime_setup", parts=runtime_setup_parts())
262
263 # Where this run's tests are is worked out here, once. Helper modules
264 # are imported from the tests directory. A project with no tests
265 # directory keeps them beside its test files, at the root.
266 root = Path(command.directory)
267 tests_directory = find_tests_directory(root)
268 has_tests_directory = tests_directory.is_dir()
269 import_helper_modules_from(tests_directory if has_tests_directory else root)
270 timeline.phase_ended("helper_modules")
271
272 lifecycles = []
273 if has_app:
274 try:
275 lifecycles = load_package_lifecycles()
276 except KeyboardInterrupt:
277 raise
278 except BaseException as error:
279 return could_not_set_up("Loading the packages' test lifecycles", error)
280
281 # The project's own lifecycle goes last, so it wraps closest to the
282 # test: it enters with the packages' protection already in place (the
283 # database transaction is open) and exits before theirs is taken
284 # down.
285 try:
286 app_lifecycle = load_app_lifecycle(
287 root=root, tests_directory=tests_directory
288 )
289 except TestDefinitionError as e:
290 cause = e.__cause__
291 return stopped(
292 "lifecycle_error",
293 str(e),
294 traceback=collection_error_text(cause) if cause is not None else None,
295 )
296 if app_lifecycle is not None:
297 lifecycles.append(app_lifecycle)
298 timeline.phase_ended("lifecycles_loaded")
299
300 # What setting up wrote is the run's. From here to the first test,
301 # what is written is a file being loaded, and is kept by that file
302 # if it can't be collected.
303 outside_tests.append(capture.take())
304
305 rewriter_work_before = rewriter_work()
306 try:
307 tests, collection_errors = collect_tests(
308 list(command.targets),
309 root=root,
310 application_directory=application_directory,
311 helper_directory=tests_directory if has_tests_directory else None,
312 capture=capture,
313 )
314 except TargetError as e:
315 return stopped("target_not_found", str(e))
316
317 if command.match:
318 tests = [t for t in tests if command.match in t.id]
319 if command.tags:
320 tests = [t for t in tests if any(tag in t.tags for tag in command.tags)]
321 if command.exclude_tags:
322 tests = [
323 t
324 for t in tests
325 if not any(tag in t.tags for tag in command.exclude_tags)
326 ]
327 timeline.phase_ended(
328 "collection",
329 parts=_rewriter_parts(rewriter_work().since(rewriter_work_before)),
330 )
331
332 if not tests and not collection_errors:
333 return stopped("no_tests_found", "No tests found")
334
335 # Each file that couldn't be collected has kept what loading it
336 # wrote. What is left was written between them.
337 outside_tests.append(capture.take())
338
339 reporter.collected(len(tests))
340
341 run = run_tests(
342 tests,
343 lifecycles=lifecycles,
344 fail_fast=command.fail_fast,
345 full_values=command.full_values,
346 on_result=reporter.result,
347 capture=capture,
348 )
349 timeline.run_ended(run)
350 except KeyboardInterrupt:
351 # Stopped before the first test, or while the lifecycles were being
352 # taken down. A test that was running when it was stopped is in the
353 # run, which reports it.
354 return stopped("interrupted", "Interrupted before any test was run.")
355
356 outside_tests.extend([run.setup_output, run.teardown_output])
357
358 failed_setup = run.setup_failure
359 if failed_setup is not None:
360 return stopped(
361 "setup_error",
362 f"{failed_setup.lifecycle}.setup_worker() "
363 + (
364 f"exited, with {failed_setup.error_message}."
365 if failed_setup.error_type == "SystemExit"
366 else f"raised {failed_setup.error_type}: {failed_setup.error_message}"
367 ),
368 traceback=failed_setup.traceback,
369 run=run,
370 )
371
372 collection_failures = tuple(
373 describe_collection_error(
374 error, file=shown_path(str(error.path)), output=error.output
375 )
376 for error in collection_errors
377 )
378 # Printing the report comes after this, and isn't in it.
379 timeline.phase_ended("report")
380
381 return RunReport(
382 command=command,
383 run=run,
384 stopped=None,
385 collection_failures=collection_failures,
386 selected=len(tests),
387 phases=timeline.phases(),
388 output=joined(outside_tests),
389 )
390
391
392def _rewriter_parts(work: RewriterWork) -> tuple[Part, ...]:
393 """
394 What collecting spent on the test files: the ones rewritten and compiled
395 (a cold cache, or a file that changed) and the ones read back from the
396 cache. Reading the directories and finding the tests is the rest.
397 """
398 from .phases import Part
399
400 parts = []
401 if work.rewritten_files:
402 plural = "" if work.rewritten_files == 1 else "s"
403 parts.append(
404 Part(
405 name=f"rewrote {work.rewritten_files} file{plural}",
406 seconds=work.rewritten_seconds,
407 )
408 )
409 if work.cached_files:
410 plural = "" if work.cached_files == 1 else "s"
411 parts.append(
412 Part(
413 name=f"read {work.cached_files} file{plural} from the cache",
414 seconds=work.cached_seconds,
415 )
416 )
417 return tuple(parts)