orchestrata_tools.test_generator.testgen.adapter
Reference › orchestrata_tools › test_generator › testgen › adapter
This module bridges the current AST layer into the shapes the rest of testgen consumes, and parses nothing itself. The generator was restored in August 2026 after an earlier private parser tree was deleted as a duplicate, and was re-pointed at orchestrata_ast: the flat facts in orchestrata_ast.models and the ast.Module trees that CodebaseAnalyzer.trees keeps from its own parse (see analyzer). Its importer is generator, and the tests are tests/test_orchestrata_tools_testgen_adapter_coverage.py, ..._structural_coverage.py and ..._units.py.
A few facts the flat model lacks, such as dataclass field defaults, enum member values and full function bodies, are read off the already-parsed trees by small helpers: find_class_node and find_function_node locate a node by name and line, args_info_from_ast converts an argument list to a dictionary, looks_abstract_via_mixin checks whether a base class makes a class abstract, and class_structural_extra builds the "extra" dictionary that the generators in structural take.
At a glance
| Source | src/orchestrata_tools/test_generator/testgen/adapter.py |
| Lines of code | 319 |
| Classes | 0 |
| Functions | 10 |
| Imports (internal) | orchestrata_ast.models |
| Imported by | orchestrata_tools.test_generator.testgen.generator |
| Tests that import it | tests/test_orchestrata_tools_testgen_adapter_coverage.py, tests/test_orchestrata_tools_testgen_structural_coverage.py, tests/test_orchestrata_tools_testgen_units.py |
Class diagram
No classes; see Functions.
Dependency diagram
API
Functions
| Function | Signature | Summary |
|---|---|---|
find_class_node |
find_class_node(class_name: str, line: int, tree: ast.Module) -> ast.ClassDef | None |
The ast.ClassDef matching class_name declared at line. |
find_function_node |
find_function_node(func_name: str, line: int, tree: ast.Module) -> ast.FunctionDef | ast.AsyncFunctionDef | None |
The ast.FunctionDef/ast.AsyncFunctionDef matching func_name declared at line. |
args_info_from_ast |
args_info_from_ast(args: ast.arguments) -> dict[str, Any] |
ast.arguments → the {"args": [...], "kwonlyargs": [...], "posonlyargs": [...], "defaults_count": N, "kw_defaults_count": N} shape conditions.analyse/branch._build_constructor expect. |
looks_abstract_via_mixin |
looks_abstract_via_mixin(cls_info: ClassInfo, codebase: CodebaseAST) -> bool |
True when any of cls_info's base classes uses the _MixinBase = _XHost if TYPE_CHECKING else object pattern in its own source — i.e. pyright will flag instantiating cls_info as abstract even… |
_callee_name |
_callee_name(node: ast.expr) -> str |
|
_safe_unparse |
_safe_unparse(node: ast.expr) -> str |
|
_dataclass_fields_from_node |
_dataclass_fields_from_node(class_node: ast.ClassDef) -> list[dict[str, Any]] |
Per-field metadata for a @dataclass body, read directly off the already-parsed ast.ClassDef — ClassInfo.class_vars only carries name/annotation/default-as-string, not enough to tell a… |
_dataclass_params |
_dataclass_params(cls_info: ClassInfo) -> dict[str, str] |
|
_enum_members_from_node |
_enum_members_from_node(class_node: ast.ClassDef) -> list[dict[str, Any]] |
Enum members, read directly off the ast.ClassDef body — ClassInfo.class_vars only captures ANNOTATED assignments (ast.AnnAssign), and enum members are conventionally plain ``NAME =… |
class_structural_extra |
class_structural_extra(cls_info: ClassInfo, class_node: ast.ClassDef) -> dict[str, Any] |
Build the extra dict structural.py's gen_*_tests functions expect, from the current AST layer's ClassInfo plus a narrow direct read of the class's own already-parsed ast.ClassDef… |
Source
"""adapter.py — Bridges the CURRENT AST layer into the shapes the rest of
``testgen`` consumes.
Before 2026-07-09 this generator ran on ``ast_serializer``'s own
``StatementRecord``/``ModuleRecord``/``ProjectRecord`` tree — a second,
competing Python-source-to-tree layer (``scripts/ast_serializer/{parser,
models}.py``), deleted that day as a correctly-identified duplicate of
``orchestrata_ast`` + ``scripts/serialize_codebase_ast.py``. Restoring
``testgen`` 2026-08-19 means re-pointing it at that survivor instead of
resurrecting its own parser.
Two ingredients, both already produced upstream — this module parses
NOTHING itself:
* ``orchestrata_ast.models.CodebaseAST`` (``ClassInfo``/``FunctionInfo``/
``ModuleInfo``) — the flat, already-computed structural facts
(is_dataclass/is_enum/is_protocol/is_abstract, decorators, bases,
methods, params, docstrings).
* ``orchestrata_ast.analyzer.CodebaseAnalyzer.trees`` — the ``ast.Module``
for each file, already parsed once by the analyzer while it built the
above. A handful of facts the flat model doesn't carry (dataclass
field defaults, enum member values/``auto()``-ness, full function
bodies for branch analysis) are read directly off these ALREADY-PARSED
trees with small, targeted ``ast.walk``/``iter_child_nodes`` helpers —
the same idiom ``scripts/serialize_codebase_ast.py`` itself uses
throughout (``_params_of``, ``_class_node``, ``_branches_of``, ...).
That is "today's AST snapshot", not a second parser.
"""
from __future__ import annotations
import ast
import re
from pathlib import Path
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from orchestrata_ast.models import ClassInfo, CodebaseAST
# ---------------------------------------------------------------------------
# Locating nodes in an already-parsed tree
# ---------------------------------------------------------------------------
def find_class_node(
class_name: str, line: int, tree: ast.Module
) -> ast.ClassDef | None:
"""The ``ast.ClassDef`` matching *class_name* declared at *line*."""
for node in ast.walk(tree):
if (
isinstance(node, ast.ClassDef)
and node.name == class_name
and node.lineno == line
):
return node
return None
def find_function_node(
func_name: str, line: int, tree: ast.Module
) -> ast.FunctionDef | ast.AsyncFunctionDef | None:
"""The ``ast.FunctionDef``/``ast.AsyncFunctionDef`` matching *func_name*
declared at *line*."""
for node in ast.walk(tree):
if (
isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
and node.name == func_name
and node.lineno == line
):
return node
return None
# ---------------------------------------------------------------------------
# Function signatures → the args dict conditions.py/branch.py expect
# ---------------------------------------------------------------------------
def args_info_from_ast(args: ast.arguments) -> dict[str, Any]:
"""``ast.arguments`` → the ``{"args": [...], "kwonlyargs": [...],
"posonlyargs": [...], "defaults_count": N, "kw_defaults_count": N}``
shape ``conditions.analyse``/``branch._build_constructor`` expect.
``args`` intentionally includes ``self``/``cls`` at index 0 (when
present) — callers filter it by name, matching how
``ast.arguments.defaults`` counts trailing positional args including
it.
"""
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),
}
# ---------------------------------------------------------------------------
# Template-defect fix (c): abstract-looking mixin detection
# ---------------------------------------------------------------------------
#: The "mixin self-type" trick this project uses to give pyright visibility
#: across mixin compositions (see e.g. agents/volks/qa/_host.py): a
#: package-local ``_XHost`` Protocol, inherited only under TYPE_CHECKING via
#: ``_MixinBase = _XHost if TYPE_CHECKING else object``. pyright's
#: abstractness check follows the TYPE_CHECKING branch and flags the
#: composed host class as abstract even though it instantiates fine at
#: runtime (mixins inherit plain ``object`` outside TYPE_CHECKING).
_MIXIN_HOST_RE = re.compile(r"_MixinBase\s*=\s*_\w*Host\b")
def looks_abstract_via_mixin(cls_info: ClassInfo, codebase: CodebaseAST) -> bool:
"""True when any of *cls_info*'s base classes uses the
``_MixinBase = _XHost if TYPE_CHECKING else object`` pattern in its own
source — i.e. pyright will flag instantiating *cls_info* as abstract
even though it is not, at runtime.
"""
base_names = set(cls_info.bases)
if not base_names:
return False
checked_files: set[str] = set()
for mod in codebase.modules:
for c in mod.classes:
if c.name not in base_names or c.file_path in checked_files:
continue
checked_files.add(c.file_path)
try:
src = Path(c.file_path).read_text(encoding="utf-8")
except OSError:
continue
if _MIXIN_HOST_RE.search(src):
return True
return False
# ---------------------------------------------------------------------------
# Structural "extra" dict — dataclass / enum / abstract / protocol
# ---------------------------------------------------------------------------
def _callee_name(node: ast.expr) -> str:
if isinstance(node, ast.Name):
return node.id
if isinstance(node, ast.Attribute):
return node.attr
return ""
def _safe_unparse(node: ast.expr) -> str:
try:
return ast.unparse(node)
except Exception:
return "..."
def _dataclass_fields_from_node(class_node: ast.ClassDef) -> list[dict[str, Any]]:
"""Per-field metadata for a ``@dataclass`` body, read directly off the
already-parsed ``ast.ClassDef`` — ``ClassInfo.class_vars`` only carries
name/annotation/default-as-string, not enough to tell a bare default
apart from a ``field(default_factory=..., metadata=...)`` call.
"""
fields: list[dict[str, Any]] = []
for item in class_node.body:
if not (isinstance(item, ast.AnnAssign) and isinstance(item.target, ast.Name)):
continue
name = item.target.id
if name.startswith("_"):
continue
annotation = _safe_unparse(item.annotation)
value = item.value
default_repr: str | None = None
default_factory_repr: str | None = None
metadata_repr: str | None = None
is_field_call = False
required = value is None
if isinstance(value, ast.Call) and _callee_name(value.func) == "field":
is_field_call = True
for kw in value.keywords:
if kw.arg == "default":
default_repr = _safe_unparse(kw.value)
elif kw.arg == "default_factory":
default_factory_repr = _safe_unparse(kw.value)
elif kw.arg == "metadata":
metadata_repr = _safe_unparse(kw.value)
required = default_repr is None and default_factory_repr is None
elif value is not None:
default_repr = _safe_unparse(value)
required = False
fields.append(
{
"name": name,
"type": annotation,
"required": required,
"default": default_repr,
"default_factory": default_factory_repr,
"is_field_call": is_field_call,
"metadata": metadata_repr,
}
)
return fields
def _dataclass_params(cls_info: ClassInfo) -> dict[str, str]:
dec = next((d for d in cls_info.decorators if "dataclass" in d), "")
params: dict[str, str] = {}
for key in ("frozen", "slots", "eq"):
m = re.search(rf"{key}\s*=\s*(True|False)", dec)
if m:
params[key] = m.group(1)
return params
def _enum_members_from_node(class_node: ast.ClassDef) -> list[dict[str, Any]]:
"""Enum members, read directly off the ``ast.ClassDef`` body —
``ClassInfo.class_vars`` only captures ANNOTATED assignments
(``ast.AnnAssign``), and enum members are conventionally plain
``NAME = value``/``NAME = auto()`` (``ast.Assign``).
"""
members: list[dict[str, Any]] = []
for item in class_node.body:
target: ast.expr | None = None
value: ast.expr | None = None
if isinstance(item, ast.Assign) and len(item.targets) == 1:
target = item.targets[0]
value = item.value
elif isinstance(item, ast.AnnAssign) and item.value is not None:
target = item.target
value = item.value
if not (isinstance(target, ast.Name) and value is not None):
continue
name = target.id
if name.startswith("_"):
continue
is_auto = isinstance(value, ast.Call) and _callee_name(value.func) == "auto"
value_repr = "None" if is_auto else _safe_unparse(value)
members.append({"name": name, "is_auto": is_auto, "value_repr": value_repr})
return members
def class_structural_extra(
cls_info: ClassInfo, class_node: ast.ClassDef
) -> dict[str, Any]:
"""Build the ``extra`` dict ``structural.py``'s ``gen_*_tests`` functions
expect, from the current AST layer's ``ClassInfo`` plus a narrow direct
read of the class's own already-parsed ``ast.ClassDef`` body.
"""
extra: dict[str, Any] = {
"name": cls_info.name,
"is_dataclass": cls_info.is_dataclass,
"is_enum": cls_info.is_enum,
"is_protocol": cls_info.is_protocol,
"is_abstract": cls_info.is_abstract,
}
if cls_info.is_dataclass:
extra["dataclass_fields"] = _dataclass_fields_from_node(class_node)
extra["dataclass_params"] = _dataclass_params(cls_info)
extra["has_post_init"] = any(
m.name == "__post_init__" for m in cls_info.methods
)
if cls_info.is_enum:
extra["enum_members"] = _enum_members_from_node(class_node)
base = next(
(b for b in cls_info.bases if b in ("IntEnum", "StrEnum")),
"Enum" if any("Enum" in b for b in cls_info.bases) else "Enum",
)
extra["enum_base"] = base
abstract_methods = [m for m in cls_info.methods if m.is_abstract]
extra["abstract_methods"] = [
{
"name": m.name,
"signature": f"-> {m.return_type}" if m.return_type else "",
"is_property": m.is_property,
}
for m in abstract_methods
]
extra["abc_base"] = next(
(b for b in cls_info.bases if "ABC" in b or "Abstract" in b), "ABC"
)
if cls_info.is_protocol:
members: list[dict[str, Any]] = []
for cv in cls_info.class_vars:
if cv.name.startswith("_"):
continue
members.append(
{"kind": "attribute", "name": cv.name, "type": cv.type_annotation}
)
for m in cls_info.methods:
if m.name.startswith("_") and not m.is_property:
continue
members.append(
{
"kind": "async_method" if m.is_async else "method",
"name": m.name,
"returns": m.return_type,
"is_property": m.is_property,
"is_classmethod": m.is_classmethod,
"is_staticmethod": m.is_staticmethod,
}
)
extra["protocol_members"] = members
extra["is_runtime_checkable"] = any(
"runtime_checkable" in d for d in cls_info.decorators
)
return extra