pytest_beehave.stub_reader
Test stub reader for pytest-beehave.
1"""Test stub reader for pytest-beehave.""" 2 3from __future__ import annotations 4 5import re 6from dataclasses import dataclass 7from pathlib import Path 8 9from pytest_beehave.models import ExampleId, FeatureSlug 10 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) 17 18 19@dataclass(frozen=True, slots=True) 20class ExistingStub: 21 """Represents an existing test stub function found in a test file. 22 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 """ 32 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 40 41 42def extract_example_id_from_name(name: str) -> ExampleId | None: 43 """Extract the ExampleId from a test function name. 44 45 Args: 46 name: The test function name. 47 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 55 56 57def _extract_feature_slug_from_name(name: str) -> FeatureSlug: 58 """Extract the FeatureSlug from a test function name. 59 60 Args: 61 name: The test function name like 'test_my_feature_aabbccdd'. 62 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) 71 72 73def _find_triple_quote_end(content: str, start: int, quote: str) -> int: 74 """Find the end position of a triple-quoted string. 75 76 Args: 77 content: Full file content. 78 start: Start position of the opening triple-quote. 79 quote: The triple-quote delimiter. 80 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 88 89 90def _find_string_ranges(content: str) -> list[tuple[int, int]]: 91 """Find all triple-quoted string ranges in content. 92 93 Args: 94 content: Full file content. 95 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 105 106 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. 111 112 Args: 113 content: Full file content. 114 pos: Current position. 115 ranges: List to append (start, end) to. 116 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 126 127 128def _in_string(pos: int, string_ranges: list[tuple[int, int]]) -> bool: 129 """Check if a position is inside a triple-quoted string. 130 131 Args: 132 pos: Character position. 133 string_ranges: List of (start, end) ranges. 134 135 Returns: 136 True if pos is inside any string range. 137 """ 138 return any(start < pos < end for start, end in string_ranges) 139 140 141def _extract_docstring(content: str, func_start: int) -> str: 142 """Extract the docstring body from a function at the given position. 143 144 Args: 145 content: Full file content. 146 func_start: Start position of the def line. 147 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] 164 165 166def _is_decorator_line(stripped: str) -> bool: 167 """Return True if a stripped line is a decorator. 168 169 Args: 170 stripped: A stripped source line. 171 172 Returns: 173 True if the line starts with '@'. 174 """ 175 return stripped.startswith("@") 176 177 178def _is_blank_or_comment(stripped: str) -> bool: 179 """Return True if a stripped line is blank or a comment. 180 181 Args: 182 stripped: A stripped source line. 183 184 Returns: 185 True if the line is blank or starts with '#'. 186 """ 187 return stripped == "" or stripped.startswith("#") 188 189 190def _collect_markers_reversed(lines: list[str]) -> list[str]: 191 """Collect decorator strings in reverse order from a list of source lines. 192 193 Scans backwards, accumulating decorator lines until a non-decorator, 194 non-blank, non-comment line is found. 195 196 Args: 197 lines: Source lines before a function definition. 198 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 211 212 213def _extract_markers(content: str, func_start: int) -> tuple[str, ...]: 214 """Extract decorator strings before a function. 215 216 Args: 217 content: Full file content. 218 func_start: Start position of the def line. 219 220 Returns: 221 Tuple of decorator strings (without @ prefix). 222 """ 223 lines = content[:func_start].splitlines() 224 return tuple(reversed(_collect_markers_reversed(lines))) 225 226 227def _extract_class_name(content: str, func_start: int, indent: str) -> str | None: 228 """Extract the enclosing class name for an indented method. 229 230 Args: 231 content: Full file content. 232 func_start: Start position of the def line. 233 indent: Leading whitespace of the def line. 234 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) 245 246 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. 253 254 Args: 255 content: Full file content. 256 match: Regex match for the function definition. 257 path: Path to the file. 258 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 ) 274 275 276def read_stubs_from_file(path: Path) -> list[ExistingStub]: 277 """Read all test stub functions from a test file. 278 279 Args: 280 path: Path to the test file. 281 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 ]
20@dataclass(frozen=True, slots=True) 21class ExistingStub: 22 """Represents an existing test stub function found in a test file. 23 24 Attributes: 25 function_name: The full function name. 26 example_id: The ExampleId extracted from the function name. 27 feature_slug: The FeatureSlug inferred from the function name. 28 class_name: The enclosing class name, or None for top-level. 29 file_path: Path to the file containing this stub. 30 markers: Tuple of decorator strings present on the function. 31 docstring: The docstring body, or empty string. 32 """ 33 34 function_name: str 35 example_id: ExampleId 36 feature_slug: FeatureSlug 37 class_name: str | None 38 file_path: Path 39 markers: tuple[str, ...] 40 docstring: str
Represents an existing test stub function found in a test file.
Attributes: function_name: The full function name. example_id: The ExampleId extracted from the function name. feature_slug: The FeatureSlug inferred from the function name. class_name: The enclosing class name, or None for top-level. file_path: Path to the file containing this stub. markers: Tuple of decorator strings present on the function. docstring: The docstring body, or empty string.
43def extract_example_id_from_name(name: str) -> ExampleId | None: 44 """Extract the ExampleId from a test function name. 45 46 Args: 47 name: The test function name. 48 49 Returns: 50 ExampleId if found, else None. 51 """ 52 match = _ID_SUFFIX_RE.search(name) 53 if match: 54 return ExampleId(match.group(1)) 55 return None
Extract the ExampleId from a test function name.
Args: name: The test function name.
Returns: ExampleId if found, else None.
277def read_stubs_from_file(path: Path) -> list[ExistingStub]: 278 """Read all test stub functions from a test file. 279 280 Args: 281 path: Path to the test file. 282 283 Returns: 284 List of ExistingStub objects found in the file. 285 """ 286 if not path.exists(): 287 return [] 288 content = path.read_text(encoding="utf-8") 289 string_ranges = _find_string_ranges(content) 290 return [ 291 _build_stub(content, match, path) 292 for match in _FUNC_RE.finditer(content) 293 if not _in_string(match.start(), string_ranges) 294 ]
Read all test stub functions from a test file.
Args: path: Path to the test file.
Returns: List of ExistingStub objects found in the file.