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
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.
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").
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'.
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).
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.
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.
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.
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.
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.
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.