Coverage for pytest_beehave/stub_reader.py: 100%
96 statements
« prev ^ index » next coverage.py v7.8.0, created at 2026-04-21 04:49 +0000
« prev ^ index » next coverage.py v7.8.0, created at 2026-04-21 04:49 +0000
1"""Test stub reader for pytest-beehave."""
3from __future__ import annotations
5import re
6from dataclasses import dataclass
7from pathlib import Path
9from pytest_beehave.models import ExampleId, FeatureSlug
11_ID_SUFFIX_RE: re.Pattern[str] = re.compile(r"test_\w+_([a-f0-9]{8})$")
12_FUNC_RE: re.Pattern[str] = re.compile(
13 r"^( {0,4})def (test_[a-z0-9_]+_([a-f0-9]{8}))\(",
14 re.MULTILINE,
15)
16_CLASS_RE: re.Pattern[str] = re.compile(r"^class (Test\w+)", re.MULTILINE)
19@dataclass(frozen=True, slots=True)
20class ExistingStub:
21 """Represents an existing test stub function found in a test file.
23 Attributes:
24 function_name: The full function name.
25 example_id: The ExampleId extracted from the function name.
26 feature_slug: The FeatureSlug inferred from the function name.
27 class_name: The enclosing class name, or None for top-level.
28 file_path: Path to the file containing this stub.
29 markers: Tuple of decorator strings present on the function.
30 docstring: The docstring body, or empty string.
31 """
33 function_name: str
34 example_id: ExampleId
35 feature_slug: FeatureSlug
36 class_name: str | None
37 file_path: Path
38 markers: tuple[str, ...]
39 docstring: str
42def extract_example_id_from_name(name: str) -> ExampleId | None:
43 """Extract the ExampleId from a test function name.
45 Args:
46 name: The test function name.
48 Returns:
49 ExampleId if found, else None.
50 """
51 match = _ID_SUFFIX_RE.search(name)
52 if match:
53 return ExampleId(match.group(1))
54 return None
57def _extract_feature_slug_from_name(name: str) -> FeatureSlug:
58 """Extract the FeatureSlug from a test function name.
60 Args:
61 name: The test function name like 'test_my_feature_aabbccdd'.
63 Returns:
64 FeatureSlug for 'my_feature'.
65 """
66 # Strip 'test_' prefix and '_<8hex>' suffix
67 without_prefix = name[len("test_") :]
68 # remove '_aabbccdd' (9 chars: underscore + 8hex)
69 without_suffix = without_prefix[:-9]
70 return FeatureSlug(without_suffix)
73def _find_triple_quote_end(content: str, start: int, quote: str) -> int:
74 """Find the end position of a triple-quoted string.
76 Args:
77 content: Full file content.
78 start: Start position of the opening triple-quote.
79 quote: The triple-quote delimiter.
81 Returns:
82 Position after the closing triple-quote.
83 """
84 end = content.find(quote, start + 3)
85 if end == -1:
86 return len(content)
87 return end + 3
90def _find_string_ranges(content: str) -> list[tuple[int, int]]:
91 """Find all triple-quoted string ranges in content.
93 Args:
94 content: Full file content.
96 Returns:
97 List of (start, end) ranges.
98 """
99 ranges: list[tuple[int, int]] = []
100 pos = 0
101 while pos < len(content):
102 matched = _try_triple_quote(content, pos, ranges)
103 pos = matched if matched is not None else pos + 1
104 return ranges
107def _try_triple_quote(
108 content: str, pos: int, ranges: list[tuple[int, int]]
109) -> int | None:
110 """Try to match a triple-quoted string at pos.
112 Args:
113 content: Full file content.
114 pos: Current position.
115 ranges: List to append (start, end) to.
117 Returns:
118 New position after the string, or None.
119 """
120 for quote in ('"""', "'''"):
121 if content[pos : pos + 3] == quote:
122 end = _find_triple_quote_end(content, pos, quote)
123 ranges.append((pos, end))
124 return end
125 return None
128def _in_string(pos: int, string_ranges: list[tuple[int, int]]) -> bool:
129 """Check if a position is inside a triple-quoted string.
131 Args:
132 pos: Character position.
133 string_ranges: List of (start, end) ranges.
135 Returns:
136 True if pos is inside any string range.
137 """
138 return any(start < pos < end for start, end in string_ranges)
141def _extract_docstring(content: str, func_start: int) -> str:
142 """Extract the docstring body from a function at the given position.
144 Args:
145 content: Full file content.
146 func_start: Start position of the def line.
148 Returns:
149 Docstring body string, or empty string.
150 """
151 # Find the first triple-quote after the def line
152 def_end = content.find("\n", func_start)
153 if def_end == -1:
154 return ""
155 after_def = content[def_end:]
156 stripped = after_def.lstrip("\n")
157 if not stripped.startswith(' """'):
158 return ""
159 open_pos = after_def.find('"""')
160 close_pos = after_def.find('"""', open_pos + 3)
161 if close_pos == -1:
162 return ""
163 return after_def[open_pos + 3 : close_pos]
166def _is_decorator_line(stripped: str) -> bool:
167 """Return True if a stripped line is a decorator.
169 Args:
170 stripped: A stripped source line.
172 Returns:
173 True if the line starts with '@'.
174 """
175 return stripped.startswith("@")
178def _is_blank_or_comment(stripped: str) -> bool:
179 """Return True if a stripped line is blank or a comment.
181 Args:
182 stripped: A stripped source line.
184 Returns:
185 True if the line is blank or starts with '#'.
186 """
187 return stripped == "" or stripped.startswith("#")
190def _collect_markers_reversed(lines: list[str]) -> list[str]:
191 """Collect decorator strings in reverse order from a list of source lines.
193 Scans backwards, accumulating decorator lines until a non-decorator,
194 non-blank, non-comment line is found.
196 Args:
197 lines: Source lines before a function definition.
199 Returns:
200 Decorator strings in reverse order (innermost first).
201 """
202 markers: list[str] = []
203 for line in reversed(lines):
204 stripped = line.strip()
205 if _is_decorator_line(stripped):
206 markers.append(stripped[1:])
207 continue
208 if not _is_blank_or_comment(stripped):
209 break
210 return markers
213def _extract_markers(content: str, func_start: int) -> tuple[str, ...]:
214 """Extract decorator strings before a function.
216 Args:
217 content: Full file content.
218 func_start: Start position of the def line.
220 Returns:
221 Tuple of decorator strings (without @ prefix).
222 """
223 lines = content[:func_start].splitlines()
224 return tuple(reversed(_collect_markers_reversed(lines)))
227def _extract_class_name(content: str, func_start: int, indent: str) -> str | None:
228 """Extract the enclosing class name for an indented method.
230 Args:
231 content: Full file content.
232 func_start: Start position of the def line.
233 indent: Leading whitespace of the def line.
235 Returns:
236 Class name string, or None for module-level functions.
237 """
238 if not indent:
239 return None
240 before = content[:func_start]
241 class_matches = list(_CLASS_RE.finditer(before))
242 if not class_matches:
243 return None
244 return class_matches[-1].group(1)
247def _build_stub(
248 content: str,
249 match: re.Match[str],
250 path: Path,
251) -> ExistingStub:
252 """Build an ExistingStub from a regex match in file content.
254 Args:
255 content: Full file content.
256 match: Regex match for the function definition.
257 path: Path to the file.
259 Returns:
260 ExistingStub for the matched function.
261 """
262 indent = match.group(1)
263 func_name = match.group(2)
264 example_id = ExampleId(match.group(3))
265 return ExistingStub(
266 function_name=func_name,
267 example_id=example_id,
268 feature_slug=_extract_feature_slug_from_name(func_name),
269 class_name=_extract_class_name(content, match.start(), indent),
270 file_path=path,
271 markers=_extract_markers(content, match.start()),
272 docstring=_extract_docstring(content, match.start()),
273 )
276def read_stubs_from_file(path: Path) -> list[ExistingStub]:
277 """Read all test stub functions from a test file.
279 Args:
280 path: Path to the test file.
282 Returns:
283 List of ExistingStub objects found in the file.
284 """
285 if not path.exists():
286 return []
287 content = path.read_text(encoding="utf-8")
288 string_ranges = _find_string_ranges(content)
289 return [
290 _build_stub(content, match, path)
291 for match in _FUNC_RE.finditer(content)
292 if not _in_string(match.start(), string_ranges)
293 ]