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
|