v0.166.0
  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)