Coverage for pytest_beehave/stub_writer.py: 100%

206 statements  

« prev     ^ index     » next       coverage.py v7.8.0, created at 2026-04-21 04:49 +0000

1"""Test stub writer for pytest-beehave.""" 

2 

3from __future__ import annotations 

4 

5import re 

6from dataclasses import dataclass 

7from pathlib import Path 

8 

9from pytest_beehave.config import StubFormat 

10from pytest_beehave.feature_parser import ( 

11 ParsedExample, 

12 ParsedFeature, 

13 ParsedRule, 

14 ParsedStep, 

15) 

16from pytest_beehave.models import ExampleId, FeatureSlug, RuleSlug 

17 

18_DECORATOR_RE: re.Pattern[str] = re.compile( 

19 r"^( *)((?:@pytest\.mark\.\w+(?:\(.*?\))?\n\1)*)def test_\w+_([a-f0-9]{8})\b", 

20 re.MULTILINE, 

21) 

22_ORPHAN_MARKER_LINE = ( 

23 '@pytest.mark.skip(reason="orphan: no matching @id in .feature files")\n' 

24) 

25 

26 

27@dataclass(frozen=True, slots=True) 

28class SyncAction: 

29 """Description of a stub sync action taken. 

30 

31 Attributes: 

32 action: The action type (CREATE, UPDATE, ORPHAN, DEPRECATED). 

33 path: Path to the affected test file. 

34 detail: Optional extra detail string. 

35 """ 

36 

37 action: str 

38 path: Path 

39 detail: str = "" 

40 

41 def __str__(self) -> str: 

42 """Return a human-readable summary of the action.""" 

43 if self.detail: 

44 return f"{self.action} {self.path} ({self.detail})" 

45 return f"{self.action} {self.path}" 

46 

47 

48@dataclass(frozen=True, slots=True) 

49class StubSpec: 

50 """Specification for a single test stub to write. 

51 

52 Attributes: 

53 feature_slug: The feature slug (underscored). 

54 rule_slug: The rule slug (underscore-separated), or None for top-level stubs. 

55 example: The parsed example. 

56 feature: The full parsed feature (for docstring context). 

57 stub_format: The output format for the stub ("functions" or "classes"). 

58 """ 

59 

60 feature_slug: FeatureSlug 

61 rule_slug: RuleSlug | None 

62 example: ParsedExample 

63 feature: ParsedFeature 

64 stub_format: StubFormat = "functions" 

65 

66 

67def build_function_name(feature_slug: FeatureSlug, example_id: ExampleId) -> str: 

68 """Build the test function name from slug and ID. 

69 

70 Args: 

71 feature_slug: The feature slug. 

72 example_id: The example ID. 

73 

74 Returns: 

75 String like 'test_my_feature_aabbccdd'. 

76 """ 

77 return f"test_{feature_slug}_{example_id}" 

78 

79 

80def build_class_name(rule_slug: RuleSlug) -> str: 

81 """Build the test class name from a rule slug. 

82 

83 Args: 

84 rule_slug: The rule slug (underscore-separated). 

85 

86 Returns: 

87 String like 'TestMyRule'. 

88 """ 

89 parts = str(rule_slug).split("_") 

90 return "Test" + "".join(p.capitalize() for p in parts if p) 

91 

92 

93def _render_step(step: ParsedStep) -> str: 

94 """Render a single step for a docstring. 

95 

96 Args: 

97 step: The step to render. 

98 

99 Returns: 

100 Rendered step text with optional doc_string/data_table. 

101 """ 

102 rendered = f" {step.keyword}: {step.text}" 

103 if step.doc_string is not None: 

104 lines = step.doc_string.splitlines() 

105 rendered = f"{rendered}\n" + "\n".join(f" {ln}" for ln in lines) 

106 if step.data_table is not None: 

107 lines = step.data_table.splitlines() 

108 rendered = f"{rendered}\n" + "\n".join(f" {ln}" for ln in lines) 

109 return rendered 

110 

111 

112def _render_background_section(steps: tuple[ParsedStep, ...]) -> list[str]: 

113 """Render a single background section as docstring lines. 

114 

115 Args: 

116 steps: The background steps. 

117 

118 Returns: 

119 List of rendered lines. 

120 """ 

121 lines = [" Background:"] 

122 lines.extend(_render_step(step) for step in steps) 

123 return lines 

124 

125 

126def build_docstring( 

127 feature: ParsedFeature, 

128 rule: ParsedRule | None, 

129 example: ParsedExample, 

130) -> str: 

131 """Build the docstring body for a test stub. 

132 

133 Args: 

134 feature: The parsed feature (not used directly, kept for interface). 

135 rule: The parsed rule containing this example, or None. 

136 example: The parsed example. 

137 

138 Returns: 

139 Docstring content (without surrounding triple-quotes). 

140 """ 

141 lines: list[str] = [] 

142 for bg_steps in example.background_sections: 

143 lines.extend(_render_background_section(bg_steps)) 

144 lines.extend(_render_step(step) for step in example.steps) 

145 if example.outline_examples is not None: 

146 lines.append(f" {example.outline_examples}") 

147 return "\n".join(lines) 

148 

149 

150def _stub_decorator(is_deprecated: bool) -> str: 

151 """Return the decorator line for a new stub. 

152 

153 Args: 

154 is_deprecated: If True, return deprecated marker; else skip marker. 

155 

156 Returns: 

157 Decorator line string. 

158 """ 

159 if is_deprecated: 

160 return "@pytest.mark.deprecated\n" 

161 return '@pytest.mark.skip(reason="not yet implemented")\n' 

162 

163 

164def _stub_function_source( 

165 function_name: str, 

166 docstring_body: str, 

167 is_deprecated: bool, 

168 *, 

169 is_method: bool = False, 

170) -> str: 

171 """Build full source text for a single test stub function. 

172 

173 Args: 

174 function_name: The test function name. 

175 docstring_body: The docstring body (without triple-quotes). 

176 is_deprecated: If True, add @pytest.mark.deprecated. 

177 is_method: If True, emit (self) as the parameter. 

178 

179 Returns: 

180 Full function source as a string. 

181 """ 

182 decorator = _stub_decorator(is_deprecated) 

183 params = "self" if is_method else "" 

184 return ( 

185 f"{decorator}" 

186 f"def {function_name}({params}) -> None:\n" 

187 f' """\n{docstring_body}\n """\n' 

188 f" raise NotImplementedError\n" 

189 ) 

190 

191 

192def _build_file_header(story_slug: str) -> str: 

193 """Build the header for a new test stub file. 

194 

195 Args: 

196 story_slug: The story file stem (underscore-separated). 

197 

198 Returns: 

199 File header string including module docstring and imports. 

200 """ 

201 title = story_slug.replace("_", " ") 

202 return f'"""Tests for {title} story."""\n\nimport pytest\n\n\n' 

203 

204 

205def _indent_stub(source: str, indent: str = " ") -> str: 

206 """Indent all lines of a stub by the given prefix. 

207 

208 Args: 

209 source: The stub source code. 

210 indent: Indentation prefix. 

211 

212 Returns: 

213 Indented source. 

214 """ 

215 return "\n".join( 

216 indent + line if line.strip() else line for line in source.splitlines() 

217 ) 

218 

219 

220def _append_stub_to_file(path: Path, function_source: str) -> None: 

221 """Append a stub function to an existing file. 

222 

223 Args: 

224 path: Path to the test file. 

225 function_source: The stub function source code. 

226 """ 

227 existing = path.read_text(encoding="utf-8") 

228 updated = existing.rstrip("\n") + "\n\n\n" + function_source + "\n" 

229 path.write_text(updated, encoding="utf-8") 

230 

231 

232def _write_top_level_stub(path: Path, function_source: str) -> SyncAction: 

233 """Write a top-level (no Rule) stub to file. 

234 

235 Args: 

236 path: Path to the test file. 

237 function_source: Full function source code. 

238 

239 Returns: 

240 SyncAction describing what was done. 

241 """ 

242 if not path.exists(): 

243 stem = path.stem 

244 story_slug = stem.removesuffix("_test") 

245 parent = path.parent 

246 parent.mkdir(parents=True, exist_ok=True) 

247 path.write_text( 

248 _build_file_header(story_slug) + function_source + "\n", 

249 encoding="utf-8", 

250 ) 

251 return SyncAction(action="CREATE", path=path) 

252 _append_stub_to_file(path, function_source) 

253 return SyncAction(action="UPDATE", path=path) 

254 

255 

256def write_stub_to_file(path: Path, spec: StubSpec) -> SyncAction: 

257 """Write a test stub for a single example to a test file. 

258 

259 Creates the file if it doesn't exist, appends if it does. 

260 

261 Args: 

262 path: Path to the test file. 

263 spec: The stub specification. 

264 

265 Returns: 

266 A SyncAction describing what was done. 

267 """ 

268 example = spec.example 

269 function_name = build_function_name(spec.feature_slug, example.example_id) 

270 rule = _find_rule(spec.feature, spec.rule_slug) if spec.rule_slug else None 

271 docstring_body = build_docstring(spec.feature, rule, example) 

272 is_class_method = spec.rule_slug is not None and spec.stub_format == "classes" 

273 function_source = _stub_function_source( 

274 function_name, docstring_body, example.is_deprecated, is_method=is_class_method 

275 ) 

276 if is_class_method: 

277 return _write_class_based_stub(path, spec, function_name, function_source) 

278 return _write_top_level_stub(path, function_source) 

279 

280 

281def _create_class_file(path: Path, class_name: str, method_source: str) -> None: 

282 """Create a new test file with a class containing a method stub. 

283 

284 Args: 

285 path: Path to create. 

286 class_name: The test class name. 

287 method_source: Indented method source. 

288 """ 

289 stem = path.stem 

290 story_slug = stem.removesuffix("_test") 

291 parent = path.parent 

292 parent.mkdir(parents=True, exist_ok=True) 

293 class_block = f"class {class_name}:\n{method_source}\n" 

294 path.write_text( 

295 _build_file_header(story_slug) + class_block + "\n", encoding="utf-8" 

296 ) 

297 

298 

299def _append_to_class_file(path: Path, class_name: str, method_source: str) -> None: 

300 """Append a method stub to an existing test file with a class. 

301 

302 Args: 

303 path: Path to the test file. 

304 class_name: The test class name. 

305 method_source: Indented method source. 

306 """ 

307 content = path.read_text(encoding="utf-8") 

308 if f"class {class_name}:" not in content: 

309 class_block = f"class {class_name}:\n{method_source}\n" 

310 updated = content.rstrip("\n") + "\n\n\n" + class_block + "\n" 

311 else: 

312 updated = content.rstrip("\n") + "\n\n" + method_source + "\n" 

313 path.write_text(updated, encoding="utf-8") 

314 

315 

316def _write_class_based_stub( 

317 path: Path, 

318 spec: StubSpec, 

319 function_name: str, 

320 function_source: str, 

321) -> SyncAction: 

322 """Write a class-method stub for a Rule-based spec. 

323 

324 Args: 

325 path: Path to the test file. 

326 spec: The stub specification. 

327 function_name: The test function name. 

328 function_source: The function source code (module-level style). 

329 

330 Returns: 

331 SyncAction describing the action taken. 

332 """ 

333 if spec.rule_slug is None: 

334 raise ValueError("rule_slug must not be None for class-based stubs") 

335 class_name = build_class_name(spec.rule_slug) 

336 method_source = _indent_stub(function_source) 

337 if not path.exists(): 

338 _create_class_file(path, class_name, method_source) 

339 return SyncAction(action="CREATE", path=path) 

340 _append_to_class_file(path, class_name, method_source) 

341 return SyncAction(action="UPDATE", path=path) 

342 

343 

344def _find_rule(feature: ParsedFeature, rule_slug: RuleSlug) -> ParsedRule | None: 

345 """Find a rule in a feature by its slug. 

346 

347 Args: 

348 feature: The parsed feature. 

349 rule_slug: The rule slug to find. 

350 

351 Returns: 

352 The matching ParsedRule, or None. 

353 """ 

354 for rule in feature.rules: 

355 if rule.rule_slug == rule_slug: 

356 return rule 

357 return None 

358 

359 

360def _docstring_pattern(function_name: str) -> re.Pattern[str]: 

361 """Build a compiled docstring-replacement regex for a named function. 

362 

363 Args: 

364 function_name: The test function name. 

365 

366 Returns: 

367 Compiled regex pattern matching def line + docstring. 

368 """ 

369 return re.compile( 

370 rf'(def {re.escape(function_name)}\([^)]*\) -> None:\n """).*?(""")', 

371 re.DOTALL, 

372 ) 

373 

374 

375def _update_docstring_in_content( 

376 content: str, 

377 function_name: str, 

378 new_docstring_body: str, 

379) -> str: 

380 """Replace the docstring of a named function in file content. 

381 

382 Args: 

383 content: Full file content. 

384 function_name: The function name to find. 

385 new_docstring_body: New docstring body (without triple-quotes). 

386 

387 Returns: 

388 Updated file content. 

389 """ 

390 pattern = _docstring_pattern(function_name) 

391 replace_with = new_docstring_body 

392 

393 def replacer(m: re.Match[str]) -> str: 

394 return f"{m.group(1)}\n{replace_with}\n {m.group(2)}" 

395 

396 return pattern.sub(replacer, content, count=1) 

397 

398 

399def _rename_function_in_content(content: str, old_name: str, new_name: str) -> str: 

400 """Rename a test function in file content. 

401 

402 Args: 

403 content: Full file content. 

404 old_name: Current function name. 

405 new_name: New function name. 

406 

407 Returns: 

408 Updated file content. 

409 """ 

410 pattern = re.compile( 

411 rf"^def {re.escape(old_name)}\(([^)]*)\) -> None:", 

412 re.MULTILINE, 

413 ) 

414 return pattern.sub( 

415 lambda m: f"def {new_name}({m.group(1)}) -> None:", 

416 content, 

417 count=1, 

418 ) 

419 

420 

421def update_docstring( 

422 path: Path, 

423 function_name: str, 

424 new_docstring_body: str, 

425 feature_slug: FeatureSlug, 

426 example_id: ExampleId, 

427) -> SyncAction | None: 

428 """Update the docstring and/or rename a function in a test file. 

429 

430 Args: 

431 path: Path to the test file. 

432 function_name: Current function name. 

433 new_docstring_body: New docstring content. 

434 feature_slug: Current feature slug (for renaming). 

435 example_id: The example ID (for renaming). 

436 

437 Returns: 

438 SyncAction if the file was changed, else None. 

439 """ 

440 original = path.read_text(encoding="utf-8") 

441 content = original 

442 new_name = build_function_name(feature_slug, example_id) 

443 if function_name != new_name: 

444 content = _rename_function_in_content(content, function_name, new_name) 

445 content = _update_docstring_in_content(content, new_name, new_docstring_body) 

446 if content == original: 

447 return None 

448 path.write_text(content, encoding="utf-8") 

449 return SyncAction(action="UPDATE", path=path) 

450 

451 

452def _find_function_match(content: str, function_name: str) -> re.Match[str] | None: 

453 """Find the def line for a named function in content. 

454 

455 Args: 

456 content: Full file content. 

457 function_name: The function name to find. 

458 

459 Returns: 

460 Match object or None. 

461 """ 

462 return re.search( 

463 rf"^def {re.escape(function_name)}\([^)]*\) -> None:", 

464 content, 

465 re.MULTILINE, 

466 ) 

467 

468 

469def _insert_marker_before(content: str, match: re.Match[str], marker_line: str) -> str: 

470 """Insert a marker line before the match position in content. 

471 

472 Args: 

473 content: Full file content. 

474 match: Match for the def line. 

475 marker_line: The marker line to insert. 

476 

477 Returns: 

478 Updated file content. 

479 """ 

480 return content[: match.start()] + marker_line + content[match.start() :] 

481 

482 

483def mark_orphan(path: Path, function_name: str) -> SyncAction | None: 

484 """Add an orphan skip marker before a function if not already present. 

485 

486 Args: 

487 path: Path to the test file. 

488 function_name: The function to mark as orphan. 

489 

490 Returns: 

491 SyncAction if file was changed, else None. 

492 """ 

493 content = path.read_text(encoding="utf-8") 

494 match = _find_function_match(content, function_name) 

495 if not match: 

496 return None 

497 if content[: match.start()].endswith(_ORPHAN_MARKER_LINE): 

498 return None 

499 updated = _insert_marker_before(content, match, _ORPHAN_MARKER_LINE) 

500 path.write_text(updated, encoding="utf-8") 

501 return SyncAction(action="ORPHAN", path=path) 

502 

503 

504def remove_orphan_marker(path: Path, function_name: str) -> SyncAction | None: 

505 """Remove orphan skip marker before a function if present. 

506 

507 Args: 

508 path: Path to the test file. 

509 function_name: The function to un-orphan. 

510 

511 Returns: 

512 SyncAction if file was changed, else None. 

513 """ 

514 content = path.read_text(encoding="utf-8") 

515 escaped_marker = re.escape(_ORPHAN_MARKER_LINE.rstrip("\n")) 

516 pattern = re.compile( 

517 rf"^{escaped_marker}\n(def {re.escape(function_name)}\([^)]*\) -> None:)", 

518 re.MULTILINE, 

519 ) 

520 updated = pattern.sub(r"\1", content, count=1) 

521 if updated == content: 

522 return None 

523 path.write_text(updated, encoding="utf-8") 

524 return SyncAction(action="ORPHAN", path=path) 

525 

526 

527def _build_non_conforming_marker(correct_file: Path, correct_class: str | None) -> str: 

528 """Build the non-conforming skip marker line. 

529 

530 Args: 

531 correct_file: Where the stub should be. 

532 correct_class: The correct class name, if applicable. 

533 

534 Returns: 

535 Marker line string. 

536 """ 

537 detail = f"should be in {correct_file}" 

538 if correct_class: 

539 detail += f" class {correct_class}" 

540 return f'@pytest.mark.skip(reason="non-conforming: {detail}")\n' 

541 

542 

543def mark_non_conforming( 

544 path: Path, 

545 function_name: str, 

546 correct_file: Path, 

547 correct_class: str | None, 

548) -> SyncAction | None: 

549 """Mark a non-conforming test function with a skip marker. 

550 

551 A non-conforming stub is one in the wrong file, wrong class, or with a 

552 wrong function name. 

553 

554 Args: 

555 path: Path to the test file. 

556 function_name: The function name to mark. 

557 correct_file: Where the stub should be. 

558 correct_class: The correct class name, if applicable. 

559 

560 Returns: 

561 SyncAction if file was changed, else None. 

562 """ 

563 marker_line = _build_non_conforming_marker(correct_file, correct_class) 

564 content = path.read_text(encoding="utf-8") 

565 match = _find_function_match(content, function_name) 

566 if not match: 

567 return None 

568 before_def = content[: match.start()] 

569 if f"non-conforming: should be in {correct_file}" in before_def: 

570 return None 

571 updated = _insert_marker_before(content, match, marker_line) 

572 path.write_text(updated, encoding="utf-8") 

573 return SyncAction(action="NON_CONFORMING", path=path) 

574 

575 

576def _rewrite_decorators( 

577 path: Path, 

578 content: str, 

579 match: re.Match[str], 

580 new_decorators: str, 

581) -> SyncAction: 

582 """Rewrite the decorator block for a match and save. 

583 

584 Args: 

585 path: Path to the test file. 

586 content: Full file content. 

587 match: Regex match for the decorator block. 

588 new_decorators: Updated decorator block. 

589 

590 Returns: 

591 SyncAction with DEPRECATED action. 

592 """ 

593 indent = match.group(1) 

594 def_start = match.start() + len(indent) + len(match.group(2)) 

595 new_content = content[: match.start()] + new_decorators + content[def_start:] 

596 path.write_text(new_content, encoding="utf-8") 

597 return SyncAction(action="DEPRECATED", path=path) 

598 

599 

600def _apply_deprecated_toggle( 

601 path: Path, 

602 content: str, 

603 match: re.Match[str], 

604 should_be_deprecated: bool, 

605) -> SyncAction | None: 

606 """Apply deprecated marker add/remove for a single decorator match. 

607 

608 Args: 

609 path: Path to the test file. 

610 content: Full file content. 

611 match: Regex match for the decorator block. 

612 should_be_deprecated: Whether to add or remove the marker. 

613 

614 Returns: 

615 SyncAction if changed, else None. 

616 """ 

617 indent = match.group(1) 

618 full_decorators = indent + match.group(2) 

619 marker_line = f"{indent}@pytest.mark.deprecated\n" 

620 has_marker = marker_line in full_decorators 

621 if should_be_deprecated and not has_marker: 

622 return _rewrite_decorators(path, content, match, marker_line + full_decorators) 

623 if not should_be_deprecated and has_marker: 

624 stripped = full_decorators.replace(marker_line, "") 

625 return _rewrite_decorators(path, content, match, stripped) 

626 return None 

627 

628 

629def toggle_deprecated_marker( 

630 path: Path, 

631 function_name: str, 

632 *, 

633 should_be_deprecated: bool, 

634) -> SyncAction | None: 

635 """Add or remove @pytest.mark.deprecated before a function. 

636 

637 Args: 

638 path: Path to the test file. 

639 function_name: The test function name. 

640 should_be_deprecated: If True, add the marker; if False, remove it. 

641 

642 Returns: 

643 SyncAction if file was changed, else None. 

644 """ 

645 if not path.exists(): 

646 return None 

647 content = path.read_text(encoding="utf-8") 

648 for match in _DECORATOR_RE.finditer(content): 

649 if not function_name.endswith(f"_{match.group(3)}"): 

650 continue 

651 result = _apply_deprecated_toggle(path, content, match, should_be_deprecated) 

652 if result is not None: 

653 return result 

654 return None