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