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
« prev ^ index » next coverage.py v7.8.0, created at 2026-04-21 04:49 +0000
1"""Test stub writer for pytest-beehave."""
3from __future__ import annotations
5import re
6from dataclasses import dataclass
7from pathlib import Path
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
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)
27@dataclass(frozen=True, slots=True)
28class SyncAction:
29 """Description of a stub sync action taken.
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 """
37 action: str
38 path: Path
39 detail: str = ""
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}"
48@dataclass(frozen=True, slots=True)
49class StubSpec:
50 """Specification for a single test stub to write.
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 """
60 feature_slug: FeatureSlug
61 rule_slug: RuleSlug | None
62 example: ParsedExample
63 feature: ParsedFeature
64 stub_format: StubFormat = "functions"
67def build_function_name(feature_slug: FeatureSlug, example_id: ExampleId) -> str:
68 """Build the test function name from slug and ID.
70 Args:
71 feature_slug: The feature slug.
72 example_id: The example ID.
74 Returns:
75 String like 'test_my_feature_aabbccdd'.
76 """
77 return f"test_{feature_slug}_{example_id}"
80def build_class_name(rule_slug: RuleSlug) -> str:
81 """Build the test class name from a rule slug.
83 Args:
84 rule_slug: The rule slug (underscore-separated).
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)
93def _render_step(step: ParsedStep) -> str:
94 """Render a single step for a docstring.
96 Args:
97 step: The step to render.
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
112def _render_background_section(steps: tuple[ParsedStep, ...]) -> list[str]:
113 """Render a single background section as docstring lines.
115 Args:
116 steps: The background steps.
118 Returns:
119 List of rendered lines.
120 """
121 lines = [" Background:"]
122 lines.extend(_render_step(step) for step in steps)
123 return lines
126def build_docstring(
127 feature: ParsedFeature,
128 rule: ParsedRule | None,
129 example: ParsedExample,
130) -> str:
131 """Build the docstring body for a test stub.
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.
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)
150def _stub_decorator(is_deprecated: bool) -> str:
151 """Return the decorator line for a new stub.
153 Args:
154 is_deprecated: If True, return deprecated marker; else skip marker.
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'
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.
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.
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 )
192def _build_file_header(story_slug: str) -> str:
193 """Build the header for a new test stub file.
195 Args:
196 story_slug: The story file stem (underscore-separated).
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'
205def _indent_stub(source: str, indent: str = " ") -> str:
206 """Indent all lines of a stub by the given prefix.
208 Args:
209 source: The stub source code.
210 indent: Indentation prefix.
212 Returns:
213 Indented source.
214 """
215 return "\n".join(
216 indent + line if line.strip() else line for line in source.splitlines()
217 )
220def _append_stub_to_file(path: Path, function_source: str) -> None:
221 """Append a stub function to an existing file.
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")
232def _write_top_level_stub(path: Path, function_source: str) -> SyncAction:
233 """Write a top-level (no Rule) stub to file.
235 Args:
236 path: Path to the test file.
237 function_source: Full function source code.
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)
256def write_stub_to_file(path: Path, spec: StubSpec) -> SyncAction:
257 """Write a test stub for a single example to a test file.
259 Creates the file if it doesn't exist, appends if it does.
261 Args:
262 path: Path to the test file.
263 spec: The stub specification.
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)
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.
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 )
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.
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")
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.
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).
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)
344def _find_rule(feature: ParsedFeature, rule_slug: RuleSlug) -> ParsedRule | None:
345 """Find a rule in a feature by its slug.
347 Args:
348 feature: The parsed feature.
349 rule_slug: The rule slug to find.
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
360def _docstring_pattern(function_name: str) -> re.Pattern[str]:
361 """Build a compiled docstring-replacement regex for a named function.
363 Args:
364 function_name: The test function name.
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 )
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.
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).
387 Returns:
388 Updated file content.
389 """
390 pattern = _docstring_pattern(function_name)
391 replace_with = new_docstring_body
393 def replacer(m: re.Match[str]) -> str:
394 return f"{m.group(1)}\n{replace_with}\n {m.group(2)}"
396 return pattern.sub(replacer, content, count=1)
399def _rename_function_in_content(content: str, old_name: str, new_name: str) -> str:
400 """Rename a test function in file content.
402 Args:
403 content: Full file content.
404 old_name: Current function name.
405 new_name: New function name.
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 )
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.
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).
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)
452def _find_function_match(content: str, function_name: str) -> re.Match[str] | None:
453 """Find the def line for a named function in content.
455 Args:
456 content: Full file content.
457 function_name: The function name to find.
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 )
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.
472 Args:
473 content: Full file content.
474 match: Match for the def line.
475 marker_line: The marker line to insert.
477 Returns:
478 Updated file content.
479 """
480 return content[: match.start()] + marker_line + content[match.start() :]
483def mark_orphan(path: Path, function_name: str) -> SyncAction | None:
484 """Add an orphan skip marker before a function if not already present.
486 Args:
487 path: Path to the test file.
488 function_name: The function to mark as orphan.
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)
504def remove_orphan_marker(path: Path, function_name: str) -> SyncAction | None:
505 """Remove orphan skip marker before a function if present.
507 Args:
508 path: Path to the test file.
509 function_name: The function to un-orphan.
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)
527def _build_non_conforming_marker(correct_file: Path, correct_class: str | None) -> str:
528 """Build the non-conforming skip marker line.
530 Args:
531 correct_file: Where the stub should be.
532 correct_class: The correct class name, if applicable.
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'
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.
551 A non-conforming stub is one in the wrong file, wrong class, or with a
552 wrong function name.
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.
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)
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.
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.
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)
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.
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.
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
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.
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.
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