Skip to content

Tests ast

tests_ast

Read-only AST inspection: tests, classes, imports, helpers, markers.

All helpers operate on ast.Module / ast.stmt nodes from the stdlib ast module — no libcst, no mutation, no I/O beyond Path.read_text when an entry point takes a path. This file gathers every read-side AST primitive used by the fix pipeline so that cst_rewrite / stages_* can stay focused on side-effects.

Future: the higher-level pieces (top_level_test_classes, top_level_helpers, collect_imported_names) may move to axm-ast once that package exposes raw ast.Module access. The fine-grained walkers (class_is_pathological, marker_fixtures_in_unit) are too specific to pytest semantics to belong in a general lib.

class_is_pathological(cls)

Return a reason if the class cannot be safely flattened, else None.

Pathological = uses self.<attr> inside methods, has __init__, inherits from anything other than object (or empty bases).

Source code in packages/axm-audit/src/axm_audit/core/fix/tests_ast.py
Python
def class_is_pathological(cls: ast.ClassDef) -> str | None:
    """Return a reason if the class cannot be safely flattened, else None.

    Pathological = uses `self.<attr>` inside methods, has `__init__`,
    inherits from anything other than `object` (or empty bases).
    """
    if reason := _bad_base_reason(cls):
        return reason
    if _has_init(cls):
        return "has __init__"
    return _self_attr_reason(cls)

collect_imported_names(tree)

Return {imported_name: (import_stmt, enclosing_block_or_None)}.

Walks the whole module — not just top-level — so that if TYPE_CHECKING blocks are scanned too. enclosing_block is the if TYPE_CHECKING: statement (or similar) wrapping the import, or None for top-level.

For from x import y and from x import y as z, the mapping uses the local binding name (y or z).

Source code in packages/axm-audit/src/axm_audit/core/fix/tests_ast.py
Python
def collect_imported_names(
    tree: ast.Module,
) -> dict[str, tuple[ast.stmt, ast.stmt | None]]:
    """Return {imported_name: (import_stmt, enclosing_block_or_None)}.

    Walks the whole module — not just top-level — so that ``if TYPE_CHECKING``
    blocks are scanned too.  ``enclosing_block`` is the ``if TYPE_CHECKING:``
    statement (or similar) wrapping the import, or None for top-level.

    For ``from x import y`` and ``from x import y as z``, the mapping uses
    the local binding name (``y`` or ``z``).
    """
    out: dict[str, tuple[ast.stmt, ast.stmt | None]] = {}
    work: deque[tuple[list[ast.stmt], ast.stmt | None]] = deque([(tree.body, None)])
    while work:
        stmts, enclosing = work.popleft()
        for stmt in stmts:
            if isinstance(stmt, ast.Import | ast.ImportFrom):
                for local in _import_local_names(stmt):
                    out[local] = (stmt, enclosing)
                continue
            for block in _child_stmt_blocks(stmt):
                work.append((block, stmt))
    return out

collect_referenced_names(tree)

Names actually referenced from live top-level symbols.

Restricted to Name(Load) reachable from: * decorators on top-level FunctionDef / ClassDef * class bases and keywords * argument annotations + default expressions of top-level functions * function bodies of top-level FunctionDef / ClassDef methods (excluding nested string literals, which ast.walk would otherwise pick up if someone embedded a textwrap.dedent block) * top-level Assign / AnnAssign right-hand sides

Walking the whole module — as the previous implementation did — picks up identifiers inside dead branches, string literals parsed by callers via ast.parse(some_dedent_block), etc. and triggers F401 backfills for names that aren't really used.

Source code in packages/axm-audit/src/axm_audit/core/fix/tests_ast.py
Python
def collect_referenced_names(tree: ast.Module) -> set[str]:
    """Names actually referenced from live top-level symbols.

    Restricted to ``Name(Load)`` reachable from:
      * decorators on top-level FunctionDef / ClassDef
      * class bases and keywords
      * argument annotations + default expressions of top-level functions
      * function bodies of top-level FunctionDef / ClassDef methods
        (excluding nested string literals, which ast.walk would otherwise
        pick up if someone embedded a textwrap.dedent block)
      * top-level Assign / AnnAssign right-hand sides

    Walking the *whole module* — as the previous implementation did —
    picks up identifiers inside dead branches, string literals parsed by
    callers via ``ast.parse(some_dedent_block)``, etc. and triggers F401
    backfills for names that aren't really used.
    """
    out: set[str] = set()
    for stmt in tree.body:
        for node in _iter_stmt_ref_nodes(stmt):
            for sub in ast.walk(node):
                if isinstance(sub, ast.Name) and isinstance(sub.ctx, ast.Load):
                    out.add(sub.id)
    return out

file_has_pathological_class(source)

True iff source contains a Test* class that class_is_pathological flags AND that has divergent method canonicals.

Used by plan_naming SPLIT and _execute_split to short-circuit when Stage 0 was unable to flatten — avoids planning a SPLIT that will only partially route and leave the file mid-state.

Cheap: only walks classes, no canonicalisation. Pathological with a single canonical is fine: the class is homogeneous and SPLIT will move it as a block.

Source code in packages/axm-audit/src/axm_audit/core/fix/tests_ast.py
Python
def file_has_pathological_class(source: Path) -> bool:
    """True iff *source* contains a Test* class that ``class_is_pathological``
    flags AND that has divergent method canonicals.

    Used by ``plan_naming`` SPLIT and ``_execute_split`` to short-circuit
    when Stage 0 was unable to flatten — avoids planning a SPLIT that
    will only partially route and leave the file mid-state.

    Cheap: only walks classes, no canonicalisation. Pathological with a
    *single* canonical is fine: the class is homogeneous and SPLIT will
    move it as a block.
    """
    try:
        tree = ast.parse(source.read_text())
    except (OSError, SyntaxError):
        return False
    return any(
        class_is_pathological(cls) is not None and _class_has_divergent_methods(cls)
        for cls in top_level_test_classes(tree)
    )

func_body_hash(func)

Stable string hash of a function body (for collision dedup).

Comparison is structural via ast.unparse on the body — ignores docstrings, comments, and minor formatting.

Source code in packages/axm-audit/src/axm_audit/core/fix/tests_ast.py
Python
def func_body_hash(func: ast.FunctionDef) -> str:
    """Stable string hash of a function body (for collision dedup).

    Comparison is structural via ast.unparse on the body — ignores
    docstrings, comments, and minor formatting.
    """
    body = list(func.body)
    if (
        body
        and isinstance(body[0], ast.Expr)
        and isinstance(body[0].value, ast.Constant)
        and isinstance(body[0].value.value, str)
    ):
        body = body[1:]  # drop docstring
    stub = ast.Module(body=body, type_ignores=[])
    return ast.unparse(stub)

marker_fixtures_in_unit(node)

Return fixture names declared via @pytest.mark.usefixtures("X", ...).

Source code in packages/axm-audit/src/axm_audit/core/fix/tests_ast.py
Python
def marker_fixtures_in_unit(node: ast.stmt) -> set[str]:
    """Return fixture names declared via ``@pytest.mark.usefixtures("X", ...)``."""
    out: set[str] = set()
    for n in _scannable_units(node):
        for deco in getattr(n, "decorator_list", []) or []:
            out.update(_usefixtures_args(deco))
    return out

top_level_helpers(tree)

Return {name: (body_hash, node)} for every top-level helper.

A helper is a top-level FunctionDef / ClassDef that is NOT a test (test_* / Test*) plus single-target uppercase NAME = ... constants. Fixtures (@pytest.fixture) are included — they're helpers from the body-conflict perspective.

Source code in packages/axm-audit/src/axm_audit/core/fix/tests_ast.py
Python
def top_level_helpers(
    tree: ast.Module,
) -> dict[str, tuple[str, ast.stmt]]:
    """Return ``{name: (body_hash, node)}`` for every top-level helper.

    A helper is a top-level FunctionDef / ClassDef that is NOT a test
    (``test_*`` / ``Test*``) plus single-target uppercase ``NAME = ...``
    constants. Fixtures (``@pytest.fixture``) are included — they're
    helpers from the body-conflict perspective.
    """
    out: dict[str, tuple[str, ast.stmt]] = {}
    for node in tree.body:
        entry = _helper_entry(node)
        if entry is not None:
            name, body_hash = entry
            out[name] = (body_hash, node)
    return out

top_level_test_classes(tree)

Test classes at module level that contain test_ methods.

Source code in packages/axm-audit/src/axm_audit/core/fix/tests_ast.py
Python
def top_level_test_classes(tree: ast.Module) -> list[ast.ClassDef]:
    """Test* classes at module level that contain test_* methods."""
    out: list[ast.ClassDef] = []
    for node in tree.body:
        if not (isinstance(node, ast.ClassDef) and node.name.startswith("Test")):
            continue
        if any(
            isinstance(c, ast.FunctionDef) and c.name.startswith("test_")
            for c in node.body
        ):
            out.append(node)
    return out