v0.166.0
  1"""
  2Lifecycle discovery.
  3
  4A lifecycle is what the runner does around every test without being asked:
  5protection, not setup. There are two places one can come from, and no others.
  6
  7- Packages register a TestLifecycle subclass under the `plain.testing` entry
  8  point group.
  9- The project declares its own in `tests/lifecycle.py`.
 10
 11A file that is there and wrong stops the run, and so does one that holds a
 12lifecycle under a name the runner doesn't look for. Someone wrote it
 13expecting it to protect their tests. Both are a TestDefinitionError, like a
 14test that is written wrongly.
 15"""
 16
 17import importlib.util
 18import inspect
 19import sys
 20from importlib.metadata import entry_points
 21from pathlib import Path
 22
 23from ..definition import TestDefinitionError
 24from ..lifecycle import TestLifecycle
 25
 26__all__ = []
 27
 28_APP_LIFECYCLE_MODULE_NAME = "plain_tests_app_lifecycle"
 29
 30_APP_LIFECYCLE_EXAMPLE = (
 31    "    from contextlib import contextmanager\n"
 32    "\n"
 33    "    from plain.testing import TestLifecycle\n"
 34    "\n"
 35    "\n"
 36    "    class AppTestLifecycle(TestLifecycle):\n"
 37    "        @contextmanager\n"
 38    "        def around_test(self, test):\n"
 39    "            ...\n"
 40    "            yield"
 41)
 42
 43
 44def load_package_lifecycles() -> list[TestLifecycle]:
 45    """The lifecycles of the installed packages, in entry point name order."""
 46    from plain.runtime import settings
 47
 48    lifecycles = []
 49    for entry_point in sorted(
 50        entry_points(group="plain.testing"), key=lambda e: e.name
 51    ):
 52        lifecycle_class = entry_point.load()
 53        required = lifecycle_class.required_package
 54        if required is not None and required not in settings.INSTALLED_PACKAGES:
 55            continue
 56        lifecycles.append(lifecycle_class())
 57    return lifecycles
 58
 59
 60def misplaced_app_lifecycle_paths(*, root: Path, tests_directory: Path) -> list[Path]:
 61    """
 62    The places a project lifecycle gets written by mistake. The runner reads
 63    none of them.
 64    """
 65    paths = [
 66        tests_directory / "lifecycles.py",
 67        tests_directory / "life_cycle.py",
 68        tests_directory / "lifecycle" / "__init__.py",
 69    ]
 70    if tests_directory != root:
 71        # Beside `tests/` instead of inside it.
 72        paths.append(root / "lifecycle.py")
 73        paths.append(root / "lifecycles.py")
 74    return paths
 75
 76
 77def _check_for_a_misplaced_app_lifecycle(*, root: Path, tests_directory: Path) -> None:
 78    for path in misplaced_app_lifecycle_paths(
 79        root=root, tests_directory=tests_directory
 80    ):
 81        if not path.is_file():
 82            continue
 83        # A file with one of these names that never mentions TestLifecycle is
 84        # something else, and none of the runner's business.
 85        if "TestLifecycle" not in path.read_text(errors="replace"):
 86            continue
 87        raise TestDefinitionError(
 88            f"{path} mentions TestLifecycle, but nothing reads it. A "
 89            "project's lifecycle is found by its path, and the path is\n\n"
 90            f"  {tests_directory / 'lifecycle.py'}\n\n"
 91            "Move it there. If it isn't meant to be the project's lifecycle, "
 92            "give the file another name."
 93        )
 94
 95
 96def load_app_lifecycle(*, root: Path, tests_directory: Path) -> TestLifecycle | None:
 97    """
 98    The lifecycle declared in `tests/lifecycle.py`, or None when the project
 99    has no such file.
100
101    `root` is the directory `plain test` runs from, and `tests_directory` is
102    the tests directory found from it. The file imports the project's helper
103    modules by their paths from it, as a test file does, so the caller has
104    already put the directory they live in on `sys.path`.
105
106    A file that is there but doesn't hold exactly one usable TestLifecycle
107    subclass raises TestDefinitionError. It is never quietly ignored: someone
108    wrote that file expecting it to protect their tests. When the file, or
109    the class in it, raised an error of its own, that error is the cause.
110    """
111    _check_for_a_misplaced_app_lifecycle(root=root, tests_directory=tests_directory)
112
113    path = tests_directory / "lifecycle.py"
114    if not path.exists():
115        return None
116
117    spec = importlib.util.spec_from_file_location(_APP_LIFECYCLE_MODULE_NAME, path)
118    if spec is None or spec.loader is None:
119        raise TestDefinitionError(f"{path} could not be loaded as a Python module.")
120    module = importlib.util.module_from_spec(spec)
121    sys.modules[_APP_LIFECYCLE_MODULE_NAME] = module
122    try:
123        spec.loader.exec_module(module)
124    except Exception as e:
125        sys.modules.pop(_APP_LIFECYCLE_MODULE_NAME, None)
126        raise TestDefinitionError(f"{path} could not be imported.") from e
127
128    declared = [
129        obj
130        for obj in vars(module).values()
131        if inspect.isclass(obj)
132        and issubclass(obj, TestLifecycle)
133        and obj.__module__ == module.__name__  # defined here, not imported
134    ]
135
136    if not declared:
137        raise TestDefinitionError(
138            f"{path} doesn't define a TestLifecycle subclass, so it would "
139            "protect nothing. Define one:\n\n"
140            f"{_APP_LIFECYCLE_EXAMPLE}"
141        )
142    if len(declared) > 1:
143        names = ", ".join(lifecycle_class.__name__ for lifecycle_class in declared)
144        raise TestDefinitionError(
145            f"{path} defines {len(declared)} TestLifecycle subclasses ({names}). "
146            "A project has one lifecycle. Put everything it does around a "
147            "test in one class."
148        )
149
150    lifecycle_class = declared[0]
151    try:
152        return lifecycle_class()
153    except Exception as e:
154        raise TestDefinitionError(
155            f"{path}: {lifecycle_class.__name__}() could not be created. The "
156            "runner creates it with no arguments."
157        ) from e