pytest_beehave.stub_writer

Test stub writer for pytest-beehave.

  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
@dataclass(frozen=True, slots=True)
class SyncAction:
28@dataclass(frozen=True, slots=True)
29class SyncAction:
30    """Description of a stub sync action taken.
31
32    Attributes:
33        action: The action type (CREATE, UPDATE, ORPHAN, DEPRECATED).
34        path: Path to the affected test file.
35        detail: Optional extra detail string.
36    """
37
38    action: str
39    path: Path
40    detail: str = ""
41
42    def __str__(self) -> str:
43        """Return a human-readable summary of the action."""
44        if self.detail:
45            return f"{self.action} {self.path} ({self.detail})"
46        return f"{self.action} {self.path}"

Description of a stub sync action taken.

Attributes: action: The action type (CREATE, UPDATE, ORPHAN, DEPRECATED). path: Path to the affected test file. detail: Optional extra detail string.

SyncAction(action: str, path: pathlib._local.Path, detail: str = '')
action: str
path: pathlib._local.Path
detail: str
@dataclass(frozen=True, slots=True)
class StubSpec:
49@dataclass(frozen=True, slots=True)
50class StubSpec:
51    """Specification for a single test stub to write.
52
53    Attributes:
54        feature_slug: The feature slug (underscored).
55        rule_slug: The rule slug (underscore-separated), or None for top-level stubs.
56        example: The parsed example.
57        feature: The full parsed feature (for docstring context).
58        stub_format: The output format for the stub ("functions" or "classes").
59    """
60
61    feature_slug: FeatureSlug
62    rule_slug: RuleSlug | None
63    example: ParsedExample
64    feature: ParsedFeature
65    stub_format: StubFormat = "functions"

Specification for a single test stub to write.

Attributes: feature_slug: The feature slug (underscored). rule_slug: The rule slug (underscore-separated), or None for top-level stubs. example: The parsed example. feature: The full parsed feature (for docstring context). stub_format: The output format for the stub ("functions" or "classes").

StubSpec( feature_slug: pytest_beehave.models.FeatureSlug, rule_slug: pytest_beehave.models.RuleSlug | None, example: pytest_beehave.feature_parser.ParsedExample, feature: pytest_beehave.feature_parser.ParsedFeature, stub_format: StubFormat = 'functions')
rule_slug: pytest_beehave.models.RuleSlug | None
def build_function_name( feature_slug: pytest_beehave.models.FeatureSlug, example_id: pytest_beehave.models.ExampleId) -> str:
68def build_function_name(feature_slug: FeatureSlug, example_id: ExampleId) -> str:
69    """Build the test function name from slug and ID.
70
71    Args:
72        feature_slug: The feature slug.
73        example_id: The example ID.
74
75    Returns:
76        String like 'test_my_feature_aabbccdd'.
77    """
78    return f"test_{feature_slug}_{example_id}"

Build the test function name from slug and ID.

Args: feature_slug: The feature slug. example_id: The example ID.

Returns: String like 'test_my_feature_aabbccdd'.

def build_class_name(rule_slug: pytest_beehave.models.RuleSlug) -> str:
81def build_class_name(rule_slug: RuleSlug) -> str:
82    """Build the test class name from a rule slug.
83
84    Args:
85        rule_slug: The rule slug (underscore-separated).
86
87    Returns:
88        String like 'TestMyRule'.
89    """
90    parts = str(rule_slug).split("_")
91    return "Test" + "".join(p.capitalize() for p in parts if p)

Build the test class name from a rule slug.

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

Returns: String like 'TestMyRule'.

127def build_docstring(
128    feature: ParsedFeature,
129    rule: ParsedRule | None,
130    example: ParsedExample,
131) -> str:
132    """Build the docstring body for a test stub.
133
134    Args:
135        feature: The parsed feature (not used directly, kept for interface).
136        rule: The parsed rule containing this example, or None.
137        example: The parsed example.
138
139    Returns:
140        Docstring content (without surrounding triple-quotes).
141    """
142    lines: list[str] = []
143    for bg_steps in example.background_sections:
144        lines.extend(_render_background_section(bg_steps))
145    lines.extend(_render_step(step) for step in example.steps)
146    if example.outline_examples is not None:
147        lines.append(f"    {example.outline_examples}")
148    return "\n".join(lines)

Build the docstring body for a test stub.

Args: feature: The parsed feature (not used directly, kept for interface). rule: The parsed rule containing this example, or None. example: The parsed example.

Returns: Docstring content (without surrounding triple-quotes).

def write_stub_to_file( path: pathlib._local.Path, spec: StubSpec) -> SyncAction:
257def write_stub_to_file(path: Path, spec: StubSpec) -> SyncAction:
258    """Write a test stub for a single example to a test file.
259
260    Creates the file if it doesn't exist, appends if it does.
261
262    Args:
263        path: Path to the test file.
264        spec: The stub specification.
265
266    Returns:
267        A SyncAction describing what was done.
268    """
269    example = spec.example
270    function_name = build_function_name(spec.feature_slug, example.example_id)
271    rule = _find_rule(spec.feature, spec.rule_slug) if spec.rule_slug else None
272    docstring_body = build_docstring(spec.feature, rule, example)
273    is_class_method = spec.rule_slug is not None and spec.stub_format == "classes"
274    function_source = _stub_function_source(
275        function_name, docstring_body, example.is_deprecated, is_method=is_class_method
276    )
277    if is_class_method:
278        return _write_class_based_stub(path, spec, function_name, function_source)
279    return _write_top_level_stub(path, function_source)

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

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

Args: path: Path to the test file. spec: The stub specification.

Returns: A SyncAction describing what was done.

def update_docstring( path: pathlib._local.Path, function_name: str, new_docstring_body: str, feature_slug: pytest_beehave.models.FeatureSlug, example_id: pytest_beehave.models.ExampleId) -> SyncAction | None:
422def update_docstring(
423    path: Path,
424    function_name: str,
425    new_docstring_body: str,
426    feature_slug: FeatureSlug,
427    example_id: ExampleId,
428) -> SyncAction | None:
429    """Update the docstring and/or rename a function in a test file.
430
431    Args:
432        path: Path to the test file.
433        function_name: Current function name.
434        new_docstring_body: New docstring content.
435        feature_slug: Current feature slug (for renaming).
436        example_id: The example ID (for renaming).
437
438    Returns:
439        SyncAction if the file was changed, else None.
440    """
441    original = path.read_text(encoding="utf-8")
442    content = original
443    new_name = build_function_name(feature_slug, example_id)
444    if function_name != new_name:
445        content = _rename_function_in_content(content, function_name, new_name)
446    content = _update_docstring_in_content(content, new_name, new_docstring_body)
447    if content == original:
448        return None
449    path.write_text(content, encoding="utf-8")
450    return SyncAction(action="UPDATE", path=path)

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

Args: path: Path to the test file. function_name: Current function name. new_docstring_body: New docstring content. feature_slug: Current feature slug (for renaming). example_id: The example ID (for renaming).

Returns: SyncAction if the file was changed, else None.

def mark_orphan( path: pathlib._local.Path, function_name: str) -> SyncAction | None:
484def mark_orphan(path: Path, function_name: str) -> SyncAction | None:
485    """Add an orphan skip marker before a function if not already present.
486
487    Args:
488        path: Path to the test file.
489        function_name: The function to mark as orphan.
490
491    Returns:
492        SyncAction if file was changed, else None.
493    """
494    content = path.read_text(encoding="utf-8")
495    match = _find_function_match(content, function_name)
496    if not match:
497        return None
498    if content[: match.start()].endswith(_ORPHAN_MARKER_LINE):
499        return None
500    updated = _insert_marker_before(content, match, _ORPHAN_MARKER_LINE)
501    path.write_text(updated, encoding="utf-8")
502    return SyncAction(action="ORPHAN", path=path)

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

Args: path: Path to the test file. function_name: The function to mark as orphan.

Returns: SyncAction if file was changed, else None.

def remove_orphan_marker( path: pathlib._local.Path, function_name: str) -> SyncAction | None:
505def remove_orphan_marker(path: Path, function_name: str) -> SyncAction | None:
506    """Remove orphan skip marker before a function if present.
507
508    Args:
509        path: Path to the test file.
510        function_name: The function to un-orphan.
511
512    Returns:
513        SyncAction if file was changed, else None.
514    """
515    content = path.read_text(encoding="utf-8")
516    escaped_marker = re.escape(_ORPHAN_MARKER_LINE.rstrip("\n"))
517    pattern = re.compile(
518        rf"^{escaped_marker}\n(def {re.escape(function_name)}\([^)]*\) -> None:)",
519        re.MULTILINE,
520    )
521    updated = pattern.sub(r"\1", content, count=1)
522    if updated == content:
523        return None
524    path.write_text(updated, encoding="utf-8")
525    return SyncAction(action="ORPHAN", path=path)

Remove orphan skip marker before a function if present.

Args: path: Path to the test file. function_name: The function to un-orphan.

Returns: SyncAction if file was changed, else None.

def mark_non_conforming( path: pathlib._local.Path, function_name: str, correct_file: pathlib._local.Path, correct_class: str | None) -> SyncAction | None:
544def mark_non_conforming(
545    path: Path,
546    function_name: str,
547    correct_file: Path,
548    correct_class: str | None,
549) -> SyncAction | None:
550    """Mark a non-conforming test function with a skip marker.
551
552    A non-conforming stub is one in the wrong file, wrong class, or with a
553    wrong function name.
554
555    Args:
556        path: Path to the test file.
557        function_name: The function name to mark.
558        correct_file: Where the stub should be.
559        correct_class: The correct class name, if applicable.
560
561    Returns:
562        SyncAction if file was changed, else None.
563    """
564    marker_line = _build_non_conforming_marker(correct_file, correct_class)
565    content = path.read_text(encoding="utf-8")
566    match = _find_function_match(content, function_name)
567    if not match:
568        return None
569    before_def = content[: match.start()]
570    if f"non-conforming: should be in {correct_file}" in before_def:
571        return None
572    updated = _insert_marker_before(content, match, marker_line)
573    path.write_text(updated, encoding="utf-8")
574    return SyncAction(action="NON_CONFORMING", path=path)

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

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

Args: path: Path to the test file. function_name: The function name to mark. correct_file: Where the stub should be. correct_class: The correct class name, if applicable.

Returns: SyncAction if file was changed, else None.

def toggle_deprecated_marker( path: pathlib._local.Path, function_name: str, *, should_be_deprecated: bool) -> SyncAction | None:
630def toggle_deprecated_marker(
631    path: Path,
632    function_name: str,
633    *,
634    should_be_deprecated: bool,
635) -> SyncAction | None:
636    """Add or remove @pytest.mark.deprecated before a function.
637
638    Args:
639        path: Path to the test file.
640        function_name: The test function name.
641        should_be_deprecated: If True, add the marker; if False, remove it.
642
643    Returns:
644        SyncAction if file was changed, else None.
645    """
646    if not path.exists():
647        return None
648    content = path.read_text(encoding="utf-8")
649    for match in _DECORATOR_RE.finditer(content):
650        if not function_name.endswith(f"_{match.group(3)}"):
651            continue
652        result = _apply_deprecated_toggle(path, content, match, should_be_deprecated)
653        if result is not None:
654            return result
655    return None

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

Args: path: Path to the test file. function_name: The test function name. should_be_deprecated: If True, add the marker; if False, remove it.

Returns: SyncAction if file was changed, else None.