aboutsummaryrefslogtreecommitdiff
path: root/tests/discipline/orphans.test.py
blob: 6596680532e3de8b52a3da72dbaf84f9fa091e26 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
"""Orphan detection — every committed snapshot must trace back to a live source.

Runs three checks, one per snapshot root:

* ``tests/snapshots/library/<name>.regex`` — must correspond to a
  :class:`edify.Pattern` exported from :mod:`edify.library`.
* ``tests/snapshots/fixtures/<name>.regex`` — must correspond to a fixture
  registered in ``tests/fixtures/fixtures.test.py``.
* ``tests/snapshots/docs/<file>/<block>.regex`` — must correspond to a
  ``.. code-block:: python`` block at that line in that RST.

The reverse direction (a source without a snapshot) is caught by
``SnapshotMissingError`` inside the per-source test; adding a new source
without generating its snapshot fails the direct test, not this one.
"""

from pathlib import Path

import edify.library as library_module
from edify import Pattern

_REPO_ROOT = Path(__file__).parent.parent.parent
_SNAPSHOT_ROOT = _REPO_ROOT / "tests" / "snapshots"
_LIBRARY_SNAPSHOT_ROOT = _SNAPSHOT_ROOT / "library"
_FIXTURES_SNAPSHOT_ROOT = _SNAPSHOT_ROOT / "fixtures"
_DOCS_SNAPSHOT_ROOT = _SNAPSHOT_ROOT / "docs"
_DOCS_SOURCE_ROOT = _REPO_ROOT / "docs"
_CODE_BLOCK_HEADER = ".. code-block:: python"


def test_every_library_snapshot_has_a_registered_pattern():
    snapshot_stems = {path.stem for path in _LIBRARY_SNAPSHOT_ROOT.glob("*.regex")}
    pattern_names = _library_pattern_names()
    orphaned_snapshots = snapshot_stems - pattern_names
    assert orphaned_snapshots == set(), (
        f"library snapshot files without a registered Pattern in edify.library: "
        f"{sorted(orphaned_snapshots)}"
    )


def test_every_fixture_snapshot_has_a_registered_fixture():
    snapshot_stems = {path.stem for path in _FIXTURES_SNAPSHOT_ROOT.glob("*.regex")}
    fixture_names = _registered_fixture_names()
    orphaned_snapshots = snapshot_stems - fixture_names
    assert orphaned_snapshots == set(), (
        f"fixture snapshot files without a registered fixture in "
        f"tests/fixtures/fixtures.test.py: {sorted(orphaned_snapshots)}"
    )


def test_every_doc_snapshot_has_a_matching_rst_code_block():
    snapshot_pairs = _collect_doc_snapshot_pairs()
    doc_block_pairs = _collect_doc_code_block_pairs()
    orphaned_snapshots = snapshot_pairs - doc_block_pairs
    assert orphaned_snapshots == set(), (
        f"doc snapshot files without a matching .. code-block:: python: "
        f"{sorted(orphaned_snapshots)}"
    )


def _library_pattern_names() -> set[str]:
    names: set[str] = set()
    for export_name in dir(library_module):
        if export_name.startswith("_"):
            continue
        value = getattr(library_module, export_name)
        if isinstance(value, Pattern):
            names.add(export_name)
    return names


def _registered_fixture_names() -> set[str]:
    fixture_source = (_REPO_ROOT / "tests" / "fixtures" / "fixtures.test.py").read_text()
    return _fixture_names_from_source(fixture_source)


def _fixture_names_from_source(source: str) -> set[str]:
    names: set[str] = set()
    for line in source.splitlines():
        stripped = line.strip()
        if not stripped.startswith('("'):
            continue
        closing_quote_index = stripped.find('"', 2)
        if closing_quote_index == -1:
            continue
        names.add(stripped[2:closing_quote_index])
    return names


def _collect_doc_snapshot_pairs() -> set[tuple[str, int]]:
    pairs: set[tuple[str, int]] = set()
    for snapshot in _DOCS_SNAPSHOT_ROOT.rglob("*.regex"):
        relative_stem = snapshot.parent.relative_to(_DOCS_SNAPSHOT_ROOT).as_posix()
        block_start = int(snapshot.stem)
        pairs.add((relative_stem, block_start))
    return pairs


def _collect_doc_code_block_pairs() -> set[tuple[str, int]]:
    pairs: set[tuple[str, int]] = set()
    for rst_path in _DOCS_SOURCE_ROOT.rglob("*.rst"):
        relative_stem = rst_path.relative_to(_DOCS_SOURCE_ROOT).with_suffix("").as_posix()
        for block_start in _code_block_start_lines(rst_path):
            pairs.add((relative_stem, block_start))
    return pairs


def _code_block_start_lines(rst_path: Path) -> list[int]:
    starts: list[int] = []
    lines = rst_path.read_text().splitlines()
    line_index = 0
    while line_index < len(lines):
        if lines[line_index].strip() != _CODE_BLOCK_HEADER:
            line_index += 1
            continue
        body_start = _first_content_line(lines, line_index + 1)
        if body_start is not None:
            starts.append(body_start)
        line_index += 1
    return starts


def _first_content_line(lines: list[str], from_index: int) -> int | None:
    line_index = from_index
    while line_index < len(lines) and lines[line_index].strip() == "":
        line_index += 1
    if line_index >= len(lines):
        return None
    if lines[line_index].startswith(" ") or lines[line_index].startswith("\t"):
        return line_index
    return None