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