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

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 ]