1"""
2Where a project keeps its tests.
3"""
4
5import sys
6from dataclasses import dataclass
7from pathlib import Path
8
9__all__ = []
10
11# Test modules are named for where they are, under this name, so two files
12# called `test_views.py` in different directories are different modules.
13TEST_MODULES_PACKAGE = "plain_tests"
14
15
16@dataclass(frozen=True, kw_only=True)
17class Layout:
18 """Where one run's tests and helper modules are."""
19
20 root: Path
21 helper_directory: Path
22 # The top-level name a test module may not import through (`tests`).
23 refused_import_name: str | None
24
25 def shown(self, path: Path) -> str:
26 """A path the way the run's output writes it: relative to the root."""
27 return path_as_shown(path, root=self.root)
28
29
30def path_as_shown(path: Path, *, root: Path) -> str:
31 """
32 A path the way a run's output writes it: relative to `root` when it is
33 under it, and as it was given when it isn't.
34 """
35 if path.is_absolute() and path.is_relative_to(root):
36 return path.relative_to(root).as_posix()
37 return str(path)
38
39
40def find_tests_directory(root: Path) -> Path:
41 """
42 The tests directory for a run started in `root`: the one place helper
43 modules are imported from, and where `lifecycle.py` lives.
44
45 `root` is normally the project root, with `tests/` beside `app/`. A
46 package whose test app is `tests/app` runs from inside `tests/`, so there
47 the root is the tests directory.
48 """
49 if root.name == "tests":
50 return root
51 return root / "tests"
52
53
54def import_helper_modules_from(directory: Path) -> None:
55 """
56 Put the directory helper modules live in on `sys.path`, so a test file
57 and `lifecycle.py` both import `<directory>/helpers.py` as `helpers`.
58 """
59 if str(directory) not in sys.path:
60 sys.path.insert(0, str(directory))
61
62
63def without_test_module_names(text: str) -> str:
64 """
65 Text with the names test modules are loaded under taken out of it, so
66 that what a test file defines is called what the file calls it.
67
68 A test file is a module named for where it is
69 (`plain_tests.tests.billing.test_refunds`), and Python puts a module's
70 name in front of what it defines: `<plain_tests.tests.billing.
71 test_refunds.FakeGateway object at 0x...>`. Nobody wrote that name, and
72 a report already says which file it is about. This is `<FakeGateway
73 object at 0x...>`.
74 """
75 if TEST_MODULES_PACKAGE not in text:
76 return text
77 names = [
78 name
79 for name, module in sys.modules.items()
80 if name.startswith(f"{TEST_MODULES_PACKAGE}.")
81 and getattr(module, "__file__", None)
82 ]
83 # The longest first: `plain_tests.tests.test_a` is the start of
84 # `plain_tests.tests.test_a_b`.
85 for name in sorted(names, key=len, reverse=True):
86 text = text.replace(f"{name}.", "")
87 return text