orchestrata_tools.test_generator.testgen.branch
Reference › orchestrata_tools › test_generator › testgen › branch
This module generates real, runnable branch and exception tests from a function's AST. For every branch event it emits a complete pytest test and not a stub, deriving inputs from the condition. An if produces two tests (true path and false path), a raise produces one test that reaches it under pytest.raises, a try produces one test per handler, and an assert produces a passing and a violating test. Inputs come from conditions. Where an input type cannot be resolved the test uses cast(Any, None) with the typing import registered on the output file, never a mock. The generated tests contain no skip, pass or TODO. It was restored in August 2026 and walks the already-parsed function node directly (see adapter).
gen_branch_tests is the single public function. It takes the function name, qualified name, function node, source text, whether it is a method, the class name and node, an optional TestFile (writer), a ProjectConfig (config) and a flag for classes made abstract by a mixin. It collects branch events, and when there are none returns an empty string. Otherwise it returns a test class named from the class and function plus a Branches suffix. The script scripts.serialize_codebase_ast also reaches this code through the testgen package; tests/scripts/test_ast_branches.py covers that use. Other tests are tests/test_orchestrata_tools_testgen_branch_coverage.py, ..._param_strategy.py and ..._units.py.
At a glance
| Source | src/orchestrata_tools/test_generator/testgen/branch.py |
| Lines of code | 678 |
| Classes | 0 |
| Functions | 17 |
| Imports (internal) | orchestrata_tools.test_generator.testgen.conditions, orchestrata_tools.test_generator.testgen.config, orchestrata_tools.test_generator.testgen.types, orchestrata_tools.test_generator.testgen.writer |
| Imported by | orchestrata_tools.test_generator.testgen, orchestrata_tools.test_generator.testgen.generator |
| Tests that import it | tests/test_orchestrata_tools_testgen_branch_coverage.py, tests/test_orchestrata_tools_testgen_param_strategy.py, tests/test_orchestrata_tools_testgen_units.py |
Class diagram
No classes; see Functions.
Dependency diagram
API
Functions
| Function | Signature | Summary |
|---|---|---|
gen_branch_tests |
gen_branch_tests(func_name: str, func_qname: str, func_node: ast.FunctionDef | ast.AsyncFunctionDef, source: str, is_method: bool = False, class_name: str | None = None, class_node: ast.ClassDef | None = None, tf: TestFile | None = None, config: ProjectConfig | None = None, looks_abstract_via_mixin… |
|
_args_info |
_args_info(args: ast.arguments) -> dict[str, Any] |
|
_build_constructor |
_build_constructor(class_name: str, class_node: ast.ClassDef | None, tf: TestFile | None, config: ProjectConfig | None) -> str |
Build a constructor call for class_name by looking up init in the class's already-parsed AST body and filling required args with sentinels. |
_find_init |
_find_init(class_node: ast.ClassDef) -> ast.FunctionDef | ast.AsyncFunctionDef | None |
The class's own __init__ (direct body — a base class's __init__ is out of scope here; ClassName() is the safe fallback when this class declares none). |
_collect_events |
_collect_events(stmts: list[ast.stmt], depth: int, max_depth: int = 5) -> list[dict[str, Any]] |
|
_try_unparse |
_try_unparse(node: ast.AST) -> str |
|
_handler_reraises |
_handler_reraises(handler_body: list[ast.stmt], caught_type: ast.expr | None) -> str | None |
|
_event_to_tests |
_event_to_tests(ev: dict[str, Any], func_name: str, args_info: dict[str, Any], returns: str | None, is_method: bool, is_async: bool, tf: TestFile | None = None, config: ProjectConfig | None = None) -> list[dict[str, Any]] |
|
_sentinel_tracked |
_sentinel_tracked(annotation: str | None, tf: TestFile | None, config: ProjectConfig | None = None, name: str = '') -> str |
types.sentinel plus import tracking — the cast(Any, None) fallback (template-defect fix (b)) needs from typing import Any, cast registered on the SAME generated file, and any stdlib… |
_build_call |
_build_call(func_name: str, args_info: dict[str, Any], is_method: bool, is_async: bool, overrides: dict[str, str] | None = None, tf: TestFile | None = None, config: ProjectConfig | None = None) -> str |
|
_assert_return |
_assert_return(returns: str | None) -> list[str] |
|
_is_optional_return |
_is_optional_return(returns: str) -> bool |
True for Optional[X]/Union[X, None] and the PEP 604 X | None spelling this codebase actually writes. |
_exc_class |
_exc_class(exc_expr: str | None) -> str |
|
_exc_message |
_exc_message(exc_node: ast.expr | None) -> str | None |
A match= value for pytest.raises, only when the raised exception's first argument is a literal string constant. |
_assert_message |
_assert_message(msg_node: ast.expr | None) -> str | None |
A match= value for the pytest.raises(AssertionError) wrapper around a violated assert cond, msg — same defect class as _exc_message above, found while writing coverage for this exact… |
_slug |
_slug(condition: str) -> str |
|
_sanitize_docstring_source |
_sanitize_docstring_source(source: str) -> str |
Escape triple quotes in source code so it can be safely embedded inside a Python docstring. |
Source
"""branch.py — Generate real, runnable branch/exception tests from AST structure.
For every branch event inside a function we emit a complete pytest test —
not a stub — by deriving concrete inputs directly from the condition AST:
If node → two tests: one that takes the true path, one the false path.
Inputs are derived by analysing the condition expression.
Raise node → one test: inputs that reach the raise + pytest.raises(ExcType).
Try node → one test per handler: inputs that trigger that exception class.
If the handler catches and re-raises a different one, we assert
on the outer exception.
Assert node → two tests:
(a) valid input → assert does NOT fire
(b) violating input → pytest.raises(AssertionError)
Each test is self-contained: it constructs the object (if a method),
calls the function with the derived inputs, and asserts the expected
outcome — no pytest.skip, no pass, no TODO in the body.
Where we cannot fully resolve an input (unknown custom type) we emit
``cast(Any, None)`` with the import registered on the TestFile — never a
mock (see ``types.sentinel``).
Restored 2026-08-19 from ``scripts/ast_serializer/testgen`` (deleted
2026-07-09, commit 6a186c9d) and re-pointed at the current AST layer: the
deleted parser's ``StatementRecord`` tree walk is replaced by a direct
walk of the already-parsed ``ast.FunctionDef``/``ast.AsyncFunctionDef``
node (see ``orchestrata_tools.test_generator.testgen.adapter`` for how
that node is located). ``conditions.py``'s condition analysis is untouched
— it always took a condition string + a plain args dict, never the
deleted parser's types.
Template-defect fixes applied here:
(a) THE fix. The original (pre-deletion) generator never emitted
``async def``/``@pytest.mark.asyncio`` at all — every scaffolded
test was a plain synchronous ``def``. A hand-patched generated file
(``tests/generated_recovery/test_agents_volks_qa_volk.py``) shows
what happens when someone adds the async wrapper by hand without
the ``await``: ``self._obj.process(...)`` returns a (never awaited)
coroutine object, so ``assert result is not None`` is vacuously
true — false confidence, plus a live ``RuntimeWarning``. This
version makes async-awareness a first-class part of the generator:
when the target is ``async def``, the test wrapper is emitted as
``async def`` with ``@pytest.mark.asyncio``, and the call is
awaited.
(c) When the target class's bases resolve, under TYPE_CHECKING, to a
package-local ``_XHost``-style Protocol with an unimplemented stub
(the "mixin self-type" trick — see ``adapter.looks_abstract_via_mixin``),
the constructor call carries ``# pyright: ignore[reportAbstractUsage]``.
"""
from __future__ import annotations
import ast
import re
from typing import TYPE_CHECKING, Any
from orchestrata_tools.test_generator.testgen.conditions import analyse
from orchestrata_tools.test_generator.testgen.types import (
fixture_args,
sentinel_for_param,
sentinel_imports,
sentinel_needs_cast,
)
from orchestrata_tools.test_generator.testgen.writer import CodeBuilder, TestFile
if TYPE_CHECKING:
from orchestrata_tools.test_generator.testgen.config import ProjectConfig
def gen_branch_tests(
func_name: str,
func_qname: str,
func_node: ast.FunctionDef | ast.AsyncFunctionDef,
source: str,
is_method: bool = False,
class_name: str | None = None,
class_node: ast.ClassDef | None = None,
tf: TestFile | None = None,
config: ProjectConfig | None = None,
looks_abstract_via_mixin: bool = False,
) -> str:
args_info = _args_info(func_node.args)
returns = ast.unparse(func_node.returns) if func_node.returns else None
is_async = isinstance(func_node, ast.AsyncFunctionDef)
events = _collect_events(func_node.body, depth=0)
if not events:
return ""
label = (
f"Test{class_name}{func_name.title().replace('_', '')}"
if class_name
else f"Test{func_name.title().replace('_', '')}"
) + "Branches"
cb = CodeBuilder()
cb.cls(label)
# Sanitize source to avoid triple-quote breakage in docstrings
safe_source = _sanitize_docstring_source(source[:500] if source else "")
cb.docstring(
f"Branch/exception tests for: {func_qname}\n"
f"Every test exercises a specific control-flow path.\n"
+ (f"\nSource:\n{safe_source}" if safe_source else "")
)
cb.blank()
if is_method and class_name:
cb.func("setup_method")
ctor = _build_constructor(class_name, class_node, tf, config)
cb.line(f"# TODO: instantiate {class_name} with valid constructor args")
ignore = (
" # pyright: ignore[reportAbstractUsage]"
if looks_abstract_via_mixin
else ""
)
cb.line(f"self._obj = {ctor}{ignore}")
cb.end()
cb.blank()
generated: set[str] = set()
for ev in events:
for t in _event_to_tests(
ev, func_name, args_info, returns, is_method, is_async, tf, config
):
if t["name"] in generated:
continue
generated.add(t["name"])
if is_async:
cb.line("@pytest.mark.asyncio")
fixtures = fixture_args(t["body"])
cb.func(
t["name"],
args=", ".join(["self", *fixtures]),
is_async=is_async,
)
cb.docstring(t["doc"])
for ln in t["body"]:
cb.line(ln)
cb.end()
cb.blank()
if not generated:
return ""
cb.end()
return cb.render()
# ---------------------------------------------------------------------------
# Argument-shape helper (mirrors adapter.args_info_from_ast — kept local so
# branch.py has no import-time dependency on adapter.py's ast.ClassDef
# lookups, only on the plain ast.arguments shape it already receives).
# ---------------------------------------------------------------------------
def _args_info(args: ast.arguments) -> dict[str, Any]:
def _param(a: ast.arg) -> dict[str, str | None]:
return {
"name": a.arg,
"annotation": ast.unparse(a.annotation) if a.annotation else None,
}
return {
"posonlyargs": [_param(a) for a in args.posonlyargs],
"args": [_param(a) for a in args.args],
"kwonlyargs": [_param(a) for a in args.kwonlyargs],
"defaults_count": len(args.defaults),
"kw_defaults_count": sum(1 for d in args.kw_defaults if d is not None),
}
# ---------------------------------------------------------------------------
# Constructor inference
# ---------------------------------------------------------------------------
def _build_constructor(
class_name: str,
class_node: ast.ClassDef | None,
tf: TestFile | None,
config: ProjectConfig | None,
) -> str:
"""
Build a constructor call for *class_name* by looking up __init__
in the class's already-parsed AST body and filling required args
with sentinels. Falls back to ``ClassName()`` if no __init__ is found.
"""
if class_node is None:
return f"{class_name}()"
init_node = _find_init(class_node)
if init_node is None:
return f"{class_name}()"
args_info = _args_info(init_node.args)
parts: list[str] = []
all_args = args_info.get("args", [])
defaults_count = args_info.get("defaults_count", 0)
# Required args = all args except the last defaults_count (self included
# in the count base, but self never has a default and is always index 0)
required_count = len(all_args) - defaults_count
for i, a in enumerate(all_args):
if a["name"] in ("self", "cls"):
continue
if i >= required_count:
continue # optional argument — skip to keep test minimal; no
# sentinel computed, so no unused import gets registered for
# a value that never reaches the generated call (fix (b)'s
# tracking must stay precise, not just present)
val = _sentinel_tracked(a.get("annotation"), tf, config)
parts.append(f"{a['name']}={val}")
kwonly = args_info.get("kwonlyargs", [])
kw_defaults_count = args_info.get("kw_defaults_count", 0)
kw_required_count = len(kwonly) - kw_defaults_count
for i, a in enumerate(kwonly):
if i >= kw_required_count:
continue
val = _sentinel_tracked(a.get("annotation"), tf, config)
parts.append(f"{a['name']}={val}")
if parts:
return f"{class_name}({', '.join(parts)})"
return f"{class_name}()"
def _find_init(
class_node: ast.ClassDef,
) -> ast.FunctionDef | ast.AsyncFunctionDef | None:
"""The class's own ``__init__`` (direct body — a base class's
``__init__`` is out of scope here; ``ClassName()`` is the safe
fallback when this class declares none)."""
for item in class_node.body:
if (
isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef))
and item.name == "__init__"
):
return item
return None
# ---------------------------------------------------------------------------
# Event collection — direct ast walk (replaces the deleted parser's
# StatementRecord tree walk one-for-one: same event kinds, same recursion
# rules — stop at nested def/class, otherwise descend into every compound
# statement's body/orelse/handlers/finalbody).
# ---------------------------------------------------------------------------
def _collect_events(
stmts: list[ast.stmt], depth: int, max_depth: int = 5
) -> list[dict[str, Any]]:
if depth > max_depth:
return []
events: list[dict[str, Any]] = []
for s in stmts:
if isinstance(s, ast.If):
events.append(
{"kind": "if", "lineno": s.lineno, "test": _try_unparse(s.test)}
)
events.extend(_collect_events(s.body, depth + 1, max_depth))
events.extend(_collect_events(s.orelse, depth + 1, max_depth))
elif isinstance(s, ast.Try):
handlers = [
{
"type": _try_unparse(h.type) if h.type else None,
"reraise": _handler_reraises(h.body, h.type),
}
for h in s.handlers
]
events.append(
{
"kind": "try",
"lineno": s.lineno,
"handlers": handlers,
"has_finally": bool(s.finalbody),
}
)
events.extend(_collect_events(s.body, depth + 1, max_depth))
for h in s.handlers:
events.extend(_collect_events(h.body, depth + 1, max_depth))
events.extend(_collect_events(s.orelse, depth + 1, max_depth))
events.extend(_collect_events(s.finalbody, depth + 1, max_depth))
elif isinstance(s, ast.Assert):
events.append(
{
"kind": "assert",
"lineno": s.lineno,
"test": _try_unparse(s.test),
"msg_node": s.msg,
}
)
elif isinstance(s, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
continue # nested scope — not a branch of the enclosing function
elif isinstance(s, ast.Raise):
events.append(
{
"kind": "raise",
"lineno": s.lineno,
"exc": _try_unparse(s.exc) if s.exc else None,
"exc_node": s.exc,
}
)
else:
for field_name in ("body", "orelse", "finalbody"):
child = getattr(s, field_name, None)
if child:
events.extend(_collect_events(child, depth + 1, max_depth))
return sorted(events, key=lambda e: e["lineno"])
def _try_unparse(node: ast.AST) -> str:
try:
return ast.unparse(node)
except Exception:
return "?"
def _handler_reraises(
handler_body: list[ast.stmt], caught_type: ast.expr | None
) -> str | None:
caught = _exc_class(_try_unparse(caught_type)) if caught_type else "Exception"
for s in handler_body:
if isinstance(s, ast.Raise) and s.exc is not None:
exc = _exc_class(_try_unparse(s.exc))
if exc != caught:
return exc
return None
# ---------------------------------------------------------------------------
# Event → concrete tests
# ---------------------------------------------------------------------------
def _event_to_tests(
ev: dict[str, Any],
func_name: str,
args_info: dict[str, Any],
returns: str | None,
is_method: bool,
is_async: bool,
tf: TestFile | None = None,
config: ProjectConfig | None = None,
) -> list[dict[str, Any]]:
kind = ev["kind"]
lineno = ev["lineno"]
tests: list[dict[str, Any]] = []
call = _build_call(func_name, args_info, is_method, is_async, tf=tf, config=config)
if kind == "if":
cond = ev.get("test") or "condition"
ci = analyse(cond, args_info)
true_call = _build_call(
func_name,
args_info,
is_method,
is_async,
ci.true_inputs,
tf=tf,
config=config,
)
true_body = list(ci.setup_code) + [true_call] + _assert_return(returns)
tests.append(
{
"name": f"test_L{lineno}_when_{_slug(cond)}_true",
"doc": f"Line {lineno}: condition `{cond}` is True.\n"
f"Inputs: {ci.true_inputs or '(see setup code)'}",
"body": true_body,
}
)
false_call = _build_call(
func_name,
args_info,
is_method,
is_async,
ci.false_inputs,
tf=tf,
config=config,
)
false_body = list(ci.setup_code) + [false_call] + _assert_return(returns)
tests.append(
{
"name": f"test_L{lineno}_when_{_slug(cond)}_false",
"doc": f"Line {lineno}: condition `{cond}` is False.\n"
f"Inputs: {ci.false_inputs or '(see setup code)'}",
"body": false_body,
}
)
elif kind == "raise":
exc = ev.get("exc") or "Exception"
exc_class = _exc_class(exc)
msg_match = _exc_message(ev.get("exc_node"))
match_part = f", match={msg_match}" if msg_match else ""
tests.append(
{
"name": f"test_raises_{exc_class}_L{lineno}",
"doc": f"Line {lineno}: {exc} must be raised when its guard condition is met.",
"body": [
f"with pytest.raises({exc_class}{match_part}):",
f" {call} # provide inputs that reach the raise at line {lineno}",
],
}
)
elif kind == "try":
for h in ev.get("handlers", []):
exc_type = h.get("type") or "Exception"
exc_class = _exc_class(exc_type)
reraises = h.get("reraise")
if reraises:
body = [
f"# Handler catches {exc_type} and re-raises as {reraises}",
f"with pytest.raises({reraises}):",
f" {call} # trigger the {exc_type} path",
]
else:
body = [
f"# Handler suppresses {exc_type} — call must NOT propagate it",
f"{call} # trigger the {exc_type} path",
"# assert recovery state here",
]
tests.append(
{
"name": f"test_handles_{exc_class}_L{lineno}",
"doc": f"Line {lineno}: {exc_type} handler — "
f"{'re-raises as ' + reraises if reraises else 'suppressed'}.",
"body": body,
}
)
if ev.get("has_finally"):
tests.append(
{
"name": f"test_finally_always_runs_L{lineno}",
"doc": f"Line {lineno}: finally block must run on both success and exception paths.",
"body": [
"# Happy path — finally must still execute",
f"{call}",
"assert True # if finally raised, this line would not be reached",
],
}
)
elif kind == "assert":
cond = ev.get("test") or "condition"
ci = analyse(cond, args_info)
# Template-defect (found while restoring): the pre-deletion source
# passed ci.false_inputs to the "passes" call and ci.true_inputs to
# the "violation" call — backwards. ci.true_inputs are the inputs
# that make the CONDITION True, i.e. the assert HOLDS (no error);
# ci.false_inputs make it False, i.e. the assert RAISES. Swapped,
# "test_assert_passes" actually drove the violating input and
# raised AssertionError from inside a test that never wrapped the
# call in pytest.raises — failing on its own generated output.
valid_call = _build_call(
func_name,
args_info,
is_method,
is_async,
ci.true_inputs,
tf=tf,
config=config,
)
tests.append(
{
"name": f"test_assert_passes_L{lineno}",
"doc": f"Line {lineno}: assert `{cond}` must hold under valid inputs.",
"body": list(ci.setup_code)
+ [
f"# Inputs where `{cond}` is satisfied",
valid_call,
"# No AssertionError raised — test passes",
],
}
)
bad_call = _build_call(
func_name,
args_info,
is_method,
is_async,
ci.false_inputs,
tf=tf,
config=config,
)
msg_match = _assert_message(ev.get("msg_node"))
match_part = f", match={msg_match}" if msg_match else ""
tests.append(
{
"name": f"test_assert_violation_L{lineno}",
"doc": f"Line {lineno}: violating `{cond}` must raise AssertionError.",
"body": list(ci.setup_code)
+ [
f"with pytest.raises(AssertionError{match_part}):",
f" {bad_call}",
],
}
)
return tests
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _sentinel_tracked(
annotation: str | None,
tf: TestFile | None,
config: ProjectConfig | None = None,
name: str = "",
) -> str:
"""``types.sentinel`` plus import tracking — the ``cast(Any, None)``
fallback (template-defect fix (b)) needs ``from typing import Any,
cast`` registered on the SAME generated file, and any stdlib sentinel
(``pathlib.Path()``, ``datetime.datetime.now()``, ...) needs its
module imported too.
"""
# WI-TESTGEN (task 575): a path-like ``str`` param is built under the
# ``tmp_path`` fixture, never the bare literal ``"test_value"``.
val = sentinel_for_param(name, annotation, config)
if tf is not None:
for mod in sentinel_imports(annotation):
tf.add_import(mod)
if sentinel_needs_cast(annotation, config):
tf.add_typing_import("Any")
tf.add_typing_import("cast")
return val
def _build_call(
func_name: str,
args_info: dict[str, Any],
is_method: bool,
is_async: bool,
overrides: dict[str, str] | None = None,
tf: TestFile | None = None,
config: ProjectConfig | None = None,
) -> str:
overrides = overrides or {}
parts = []
for a in args_info.get("args", []):
if a["name"] in ("self", "cls"):
continue
val = overrides.get(a["name"]) or _sentinel_tracked(
a.get("annotation"), tf, config, name=a["name"]
)
parts.append(f"{a['name']}={val}")
for a in args_info.get("kwonlyargs", []):
val = overrides.get(a["name"]) or _sentinel_tracked(
a.get("annotation"), tf, config, name=a["name"]
)
parts.append(f"{a['name']}={val}")
args_str = ", ".join(parts)
prefix = "result = "
# Template-defect fix (a): await the call when the target is async —
# otherwise `result` is an unawaited coroutine object, never None, and
# every `assert result is not None` below is vacuously true.
await_kw = "await " if is_async else ""
if is_method:
return f"{prefix}{await_kw}self._obj.{func_name}({args_str})"
return f"{prefix}{await_kw}{func_name}({args_str})"
def _assert_return(returns: str | None) -> list[str]:
if not returns or returns == "None":
return ["assert result is None"]
# An Optional/Union-with-None return (very common in this codebase's
# "X | None" style) makes a plain None a CORRECT outcome for some
# inputs — this generator's condition analysis (conditions.py) cannot
# reliably force which branch actually runs for every real-world
# guard (attribute/collection-state conditions in particular), so
# asserting `is not None` here produced a live false failure: a
# genuinely correct `None` return from an Optional-returning function
# failing its own generated test. Assert only that the call completed.
if _is_optional_return(returns):
return [f"# result may legitimately be None here (return type: {returns})"]
if returns == "bool":
return ["assert isinstance(result, bool)"]
if returns == "str":
return ["assert isinstance(result, str)"]
if returns == "int":
return ["assert isinstance(result, int)"]
if returns.startswith(("list", "List")):
return ["assert isinstance(result, list)"]
if returns.startswith(("dict", "Dict")):
return ["assert isinstance(result, dict)"]
return [f"assert result is not None # return type: {returns}"]
def _is_optional_return(returns: str) -> bool:
"""True for ``Optional[X]``/``Union[X, None]`` and the PEP 604
``X | None`` spelling this codebase actually writes."""
r = returns.strip()
if r.startswith("Optional["):
return True
if r.startswith("Union[") and "None" in r:
return True
return "None" in [p.strip() for p in r.split("|")]
def _exc_class(exc_expr: str | None) -> str:
if not exc_expr:
return "Exception"
# Handle tuple types like "(FileNotFoundError, PermissionError)"
expr = exc_expr.strip()
if expr.startswith("(") and ")" in expr:
expr = expr[1 : expr.index(")")]
first = expr.split(",")[0].strip()
return first.split("(")[0].strip().split(".")[-1]
def _exc_message(exc_node: ast.expr | None) -> str | None:
"""A ``match=`` value for ``pytest.raises``, only when the raised
exception's first argument is a literal string constant.
Restoration fix: the pre-deletion version regex-matched the FIRST
quoted substring anywhere inside the raise's unparsed text — which,
for something like ``raise Err(f"...", agent_id=d.get('alias', ''))``,
happily matches a quote pair from an unrelated keyword argument
(observed live: a bogus ``match="alias', '"`` that made the generated
``pytest.raises`` assertion fail on an f-string message it was never
meant to match). Only a real ``ast.Constant`` string arg is trustworthy
as a literal match target — an f-string/joined message is content we
cannot predict without executing the function.
"""
if not isinstance(exc_node, ast.Call) or not exc_node.args:
return None
first = exc_node.args[0]
if isinstance(first, ast.Constant) and isinstance(first.value, str):
return repr(re.escape(first.value[:60]))
return None
def _assert_message(msg_node: ast.expr | None) -> str | None:
"""A ``match=`` value for the ``pytest.raises(AssertionError)`` wrapper
around a violated ``assert cond, msg`` — same defect class as
``_exc_message`` above, found while writing coverage for this exact
code path. ``ast.unparse(msg_node)`` on a string-constant message
returns its SOURCE form (quotes included, e.g. ``"'boom'"``), not the
runtime string the AssertionError actually carries (``"boom"``) — a
match built from the unparsed form has stray literal quote characters
the real message never contains, so it never matches and the generated
test fails on its own output. Only trust a literal ``ast.Constant``
string, exactly as ``_exc_message`` does for raised exceptions.
"""
if isinstance(msg_node, ast.Constant) and isinstance(msg_node.value, str):
return repr(re.escape(msg_node.value[:60]))
return None
def _slug(condition: str) -> str:
return re.sub(r"[^a-zA-Z0-9]+", "_", condition).strip("_").lower()[:40] or "cond"
def _sanitize_docstring_source(source: str) -> str:
"""
Escape triple quotes in source code so it can be safely embedded
inside a Python docstring.
"""
if not source:
return ""
# Replace " with ' to avoid breaking """ ... """ docstrings
return source.replace('"""', "'''").replace('"', "'")