orchestrata_tools.test_analyzer.call_extraction
Reference › orchestrata_tools › test_analyzer › call_extraction
This module extracts what one function body does, for the test-analysis pipeline. Everything in it is stateless: given one ast node and the module's import context, it produces a CallProfile. The profile replaced an anonymous tuple of calls, used imports and variable types that was threaded through several BodyMatcher signatures. Its only importer is body_matcher, and tests/test_orchestrata_tools_call_extraction.py covers it.
CallProfile is a frozen dataclass of resolved call names, imported classes used, and inferred var_types; merged_with unions two profiles, with the other profile's variable types winning on conflict. CallExtractor has extract for a node given imported names and classes, plus helpers is_pytest_fixture, fixture_params, decorator_names, get_call_name (dotted name of a call), get_decorator_name, get_annotation_name and collect_orchestrata_imports, which maps aliases of orchestrata_ultimate imports to their original names.
At a glance
| Source | src/orchestrata_tools/test_analyzer/call_extraction.py |
| Lines of code | 212 |
| Classes | 2 |
| Functions | 4 |
| Imports (internal) | none |
| Imported by | orchestrata_tools.test_analyzer.body_matcher |
| Tests that import it | tests/test_orchestrata_tools_call_extraction.py |
Class diagram
Dependency diagram
API
Constants
| Name | Type | Value |
|---|---|---|
_EMPTY_TYPES |
Mapping[str, str] |
MappingProxyType({}) |
Classes
CallProfile (dataclass)
Decorators: dataclass(frozen=True) · Line 97
What one function's body does.
| Attribute | Type | Default |
|---|---|---|
calls |
frozenset[str] |
frozenset() |
used_imports |
frozenset[str] |
frozenset() |
var_types |
Mapping[str, str] |
field(default=_EMPTY_TYPES) |
| Method | Signature | Summary |
|---|---|---|
merged_with |
merged_with(other: CallProfile) -> CallProfile |
Union of both profiles; other's var_types win on conflict (same precedence as the original dict.update merge order). |
CallExtractor
Line 119
Stateless extraction of a :class:CallProfile from one node.
| Method | Signature | Summary |
|---|---|---|
is_pytest_fixture |
is_pytest_fixture(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool |
True if the function carries a @pytest.fixture decorator. |
fixture_params |
fixture_params(node: ast.FunctionDef | ast.AsyncFunctionDef) -> list[str] |
Parameter names that are likely pytest fixtures. |
decorator_names |
decorator_names(node: ast.FunctionDef | ast.AsyncFunctionDef) -> set[str] |
Resolved names of the node's own decorators (call or bare). |
extract |
extract(node: ast.AST, imported_names: dict[str, str], imported_classes: set[str], local_classes: set[str] | None = None) -> CallProfile |
Extract calls + used imported classes + inferred var types. |
Functions
| Function | Signature | Summary |
|---|---|---|
get_call_name |
get_call_name(node: ast.expr) -> str | None |
Dotted name of a call expression (a.b.c() -> "a.b.c"). |
get_decorator_name |
get_decorator_name(node: ast.expr) -> str |
String name of a decorator (@pytest.fixture() -> "pytest.fixture"). |
get_annotation_name |
get_annotation_name(node: ast.expr) -> str | None |
String form of a type annotation (subscripts collapse to X[...]). |
collect_orchestrata_imports |
collect_orchestrata_imports(tree: ast.AST) -> tuple[dict[str, str], set[str]] |
(alias -> original name, original names) for orchestrata_ultimate imports. |
Source
"""Pure per-function call extraction for the test-analysis pipeline.
Everything here is stateless: given one ``ast`` node and the module's
import context, produce a :class:`CallProfile` — the resolved call names,
the imported classes the body uses, and the inferred variable types. The
profile replaces the anonymous ``(calls, used_imports, var_types)`` tuple
that used to thread through five ``BodyMatcher`` signatures.
"""
from __future__ import annotations
import ast
from collections import deque
from collections.abc import Mapping
from dataclasses import dataclass, field
from types import MappingProxyType
def get_call_name(node: ast.expr) -> str | None:
"""Dotted name of a call expression (``a.b.c()`` -> ``"a.b.c"``)."""
if isinstance(node, ast.Name):
return node.id
elif isinstance(node, ast.Attribute):
parent = get_call_name(node.value)
if parent:
return f"{parent}.{node.attr}"
elif isinstance(node, ast.Subscript):
return get_call_name(node.value)
return None
def get_decorator_name(node: ast.expr) -> str:
"""String name of a decorator (``@pytest.fixture()`` -> ``"pytest.fixture"``)."""
if isinstance(node, ast.Name):
return node.id
elif isinstance(node, ast.Attribute):
parent = get_decorator_name(node.value)
if parent:
return f"{parent}.{node.attr}"
elif isinstance(node, ast.Call):
return get_decorator_name(node.func)
return ""
def get_annotation_name(node: ast.expr) -> str | None:
"""String form of a type annotation (subscripts collapse to ``X[...]``)."""
if isinstance(node, ast.Name):
return node.id
elif isinstance(node, ast.Attribute):
parent = get_annotation_name(node.value)
if parent:
return f"{parent}.{node.attr}"
elif isinstance(node, ast.Subscript):
parent = get_annotation_name(node.value)
if parent:
return f"{parent}[...]"
return None
def collect_orchestrata_imports(tree: ast.AST) -> tuple[dict[str, str], set[str]]:
"""(alias -> original name, original names) for orchestrata_ultimate imports.
Import statements can only appear in statement position, so the walk
descends into statement nodes only (skipping expression subtrees, the
bulk of the tree). Emission order equals ``ast.walk``'s BFS restricted
to statements, preserving the original dict-overwrite order.
"""
imported_names: dict[str, str] = {}
imported_classes: set[str] = set()
queue: deque[ast.AST] = deque([tree])
while queue:
node = queue.popleft()
for child in ast.iter_child_nodes(node):
if isinstance(child, (ast.stmt, ast.ExceptHandler, ast.match_case)):
queue.append(child)
if isinstance(node, ast.ImportFrom):
module_name = node.module or ""
if module_name.startswith("orchestrata_ultimate"):
for alias in node.names:
name = alias.asname or alias.name
imported_names[name] = alias.name
imported_classes.add(alias.name)
elif isinstance(node, ast.Import):
for alias in node.names:
parts = alias.name.split(".")
if parts[0] == "orchestrata_ultimate":
name = alias.asname or parts[-1]
imported_names[name] = parts[-1]
imported_classes.add(parts[-1])
return imported_names, imported_classes
_EMPTY_TYPES: Mapping[str, str] = MappingProxyType({})
@dataclass(frozen=True)
class CallProfile:
"""What one function's body does.
Immutable on purpose: fixture profiles are shared across every test
that uses the fixture, so in-place mutation was an aliasing bug
waiting to happen. ``var_types`` maps variable name -> class name.
"""
calls: frozenset[str] = frozenset()
used_imports: frozenset[str] = frozenset()
var_types: Mapping[str, str] = field(default=_EMPTY_TYPES)
def merged_with(self, other: CallProfile) -> CallProfile:
"""Union of both profiles; *other*'s var_types win on conflict
(same precedence as the original ``dict.update`` merge order)."""
return CallProfile(
calls=self.calls | other.calls,
used_imports=self.used_imports | other.used_imports,
var_types=MappingProxyType({**self.var_types, **other.var_types}),
)
class CallExtractor:
"""Stateless extraction of a :class:`CallProfile` from one node."""
_PRIMITIVES = {"str", "int", "float", "bool", "list", "dict", "set", "tuple"}
def is_pytest_fixture(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
"""True if the function carries a ``@pytest.fixture`` decorator."""
for decorator in node.decorator_list:
name = get_decorator_name(decorator)
if name == "pytest.fixture" or name == "fixture":
return True
return False
def fixture_params(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> list[str]:
"""Parameter names that are likely pytest fixtures.
Skips ``self``/``cls`` and primitive-annotated parameters.
"""
params = []
for arg in node.args.args + node.args.posonlyargs + node.args.kwonlyargs:
name = arg.arg
if name in ("self", "cls"):
continue
if arg.annotation:
ann_name = get_annotation_name(arg.annotation)
if ann_name and ann_name.split("[")[0].strip() in self._PRIMITIVES:
continue
params.append(name)
return params
def decorator_names(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> set[str]:
"""Resolved names of the node's own decorators (call or bare)."""
names: set[str] = set()
for d in node.decorator_list:
n = get_call_name(d.func if isinstance(d, ast.Call) else d)
if n:
names.add(n)
return names
def extract(
self,
node: ast.AST,
imported_names: dict[str, str],
imported_classes: set[str],
local_classes: set[str] | None = None,
) -> CallProfile:
"""Extract calls + used imported classes + inferred var types.
``obj = ImportedClass(); obj.method()`` resolves to
``ImportedClass.method``.
"""
local_classes = local_classes or set()
all_classes = imported_classes | local_classes
calls: set[str] = set()
used_imports: set[str] = set()
var_types: dict[str, str] = {}
for child in ast.walk(node):
# Track assignments: servicer = CXPServicer()
if isinstance(child, ast.Assign):
for target in child.targets:
if isinstance(target, ast.Name) and isinstance(
child.value, ast.Call
):
func_name = get_call_name(child.value.func)
if func_name and func_name in imported_names:
var_types[target.id] = imported_names[func_name]
used_imports.add(imported_names[func_name])
elif func_name and func_name in all_classes:
var_types[target.id] = func_name
used_imports.add(func_name)
# Track calls
if isinstance(child, ast.Call):
name = get_call_name(child.func)
if name:
parts = name.split(".")
# Resolve variable to class:
# servicer.send_packet -> CXPServicer.send_packet
if len(parts) >= 2 and parts[0] in var_types:
resolved = ".".join([var_types[parts[0]]] + parts[1:])
calls.add(resolved)
used_imports.add(var_types[parts[0]])
else:
calls.add(name)
if parts[0] in imported_names:
used_imports.add(imported_names[parts[0]])
elif parts[0] in all_classes:
used_imports.add(parts[0])
return CallProfile(
calls=frozenset(calls),
used_imports=frozenset(used_imports),
var_types=MappingProxyType(var_types),
)