1"""
2What a target names.
3
4A target is a path, and after it one of three things: nothing, `::` and a
5name, or `:` and a line.
6
7 tests/checkout every test file under it
8 tests/test_signup.py every test in the file
9 tests/test_signup.py::test_welcome that test, and every case of it
10 tests/test_signup.py:42 the test that line is in
11
12A line is what a failure gives: a traceback says `test_signup.py`, line 42,
13and that is enough to run the test again without knowing what it is called.
14"""
15
16import ast
17import re
18from dataclasses import dataclass
19from pathlib import Path
20
21__all__ = []
22
23
24class TargetError(Exception):
25 """A target the command was given can't be used: nothing is there, or a
26 line is in no test."""
27
28
29@dataclass(frozen=True, kw_only=True)
30class Target:
31 # As the command was given it.
32 written: str
33 path: str
34 # What follows `::`, or "".
35 name: str = ""
36 # What follows `:`, or None.
37 line: int | None = None
38
39
40def read_target(written: str) -> Target:
41 path, _, name = written.partition("::")
42 if name:
43 return Target(written=written, path=path, name=name)
44
45 with_a_line = re.fullmatch(r"(.+):(\d+)", written)
46 if with_a_line is not None:
47 return Target(written=written, path=with_a_line[1], line=int(with_a_line[2]))
48 return Target(written=written, path=written)
49
50
51@dataclass(frozen=True, kw_only=True)
52class _WrittenTest:
53 """A test as its file writes it: its name, and the lines it takes up,
54 from its first decorator to the last line of its body."""
55
56 name: str
57 first_line: int
58 last_line: int
59
60 def __str__(self) -> str:
61 return f"{self.name} (lines {self.first_line} to {self.last_line})"
62
63
64def name_of_the_test_at(file: Path, *, line: int, written: str) -> str | None:
65 """
66 The name of the test a line of a file is in, as a `::` target names it.
67
68 None when the file can't be read as Python: then the file has an error
69 of its own to report, which says more than a target's would.
70
71 Raises TargetError when the line is in no test, naming the nearest tests
72 above and below it.
73 """
74 try:
75 tree = ast.parse(file.read_text(), filename=str(file))
76 except SyntaxError, UnicodeDecodeError:
77 return None
78
79 tests = _tests_written_in(tree)
80 for test in tests:
81 if test.first_line <= line <= test.last_line:
82 return test.name
83
84 above = [test for test in tests if test.last_line < line]
85 below = [test for test in tests if test.first_line > line]
86 if above and below:
87 nearest = f"It is between {above[-1]} and {below[0]}."
88 elif above:
89 nearest = f"It is after the last test, {above[-1]}."
90 elif below:
91 nearest = f"It is before the first test, {below[0]}."
92 else:
93 nearest = "The file defines no tests."
94 raise TargetError(f"No test at {written}: line {line} is in no test. {nearest}")
95
96
97def _tests_written_in(tree: ast.Module) -> list[_WrittenTest]:
98 tests = []
99 for node in tree.body:
100 is_a_function = isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef)
101 if is_a_function and node.name.startswith("test_"):
102 tests.append(_written(node, name=node.name))
103 return sorted(tests, key=lambda test: test.first_line)
104
105
106def _written(
107 function: ast.FunctionDef | ast.AsyncFunctionDef, *, name: str
108) -> _WrittenTest:
109 first_line = min(
110 [function.lineno, *(decorator.lineno for decorator in function.decorator_list)]
111 )
112 assert function.end_lineno is not None
113 return _WrittenTest(name=name, first_line=first_line, last_line=function.end_lineno)