orchestrata_tools.test_generator.enhanced_generator
Reference › orchestrata_tools › test_generator › enhanced_generator
EnhancedTestGenerator produces spec-compliant pytest modules from class metadata. Its docstring ties it to the project testing standards (SPEC-097 and the test rules in AGENTS.md): no mocking or skips, @traces decorators propagated from the source, function-scope fixtures, a test_id fixture for database operations, pytest.mark.asyncio for async tests, and the arrange-act-assert pattern. Importers of the module are the package and its tests (tests/test_orchestrata_tools_test_generator.py, tests/test_enum_test_gen_value_agnostic.py, tests/test_orchestrata_tools_testgen_param_strategy.py).
TestGenConfig holds the settings. generate_module takes classes, the source module name and the test module name and returns a full test module: a docstring, an ordered import block (standard library, third party, local) from _imports, and one test class per source class via _test_class and _test_method. Enum classes are handled separately. _collect_traces, _parse_traces and _extract_traces read @traces decorators from the source file and _traces_decorator re-emits them on the generated tests. Parameter values come from a ParamValueStrategy in testgen.types, by default the table LITERAL_FIXTURES. A template-based alternative is jinja_gen.
At a glance
| Source | src/orchestrata_tools/test_generator/enhanced_generator.py |
| Lines of code | 352 |
| Classes | 2 |
| Functions | 0 |
| Imports (internal) | orchestrata_tools.models.ast_models, orchestrata_tools.test_generator.testgen.types |
| Imported by | orchestrata_tools.test_generator |
| Tests that import it | tests/test_enum_test_gen_value_agnostic.py, tests/test_orchestrata_tools_test_generator.py, tests/test_orchestrata_tools_testgen_param_strategy.py |
Class diagram
Dependency diagram
API
Classes
TestGenConfig (dataclass)
Decorators: dataclass · Line 30
Configuration for test generation.
| Attribute | Type | Default |
|---|---|---|
include_traces |
bool |
True |
include_imports |
bool |
True |
include_fixtures |
bool |
True |
async_support |
bool |
True |
fixture_scope |
str |
'function' |
aaa_pattern |
bool |
True |
module_docstring |
bool |
True |
test_class_prefix |
str |
'Test' |
EnhancedTestGenerator
Line 43
Generate spec-compliant pytest stubs with full @traces support.
| Attribute | Type | Default |
|---|---|---|
TYPE_FIXTURE |
dict[str, str] |
LITERAL_FIXTURES |
| Method | Signature | Summary |
|---|---|---|
__init__ |
__init__(config: TestGenConfig | None = None, values: ParamValueStrategy | None = None) |
|
generate_module |
generate_module(classes: list[ClassInfo], source_module: str, test_module: str) -> str |
Generate a complete test module. |
_docstring |
_docstring(source_module: str) -> str |
|
_imports |
_imports(classes: list[ClassInfo], source_module: str) -> str |
Generate ordered import block: stdlib → third-party → local. |
_has_traces |
_has_traces(classes: list[ClassInfo]) -> bool |
|
_test_class |
_test_class(cls_info: ClassInfo, source_module: str, all_traces: dict, is_enum: bool = False) -> str |
|
_test_method |
_test_method(method: MethodInfo, cls_info: ClassInfo, all_traces: dict) -> str |
|
_fixture_for |
_fixture_for(param: ParamInfo) -> str |
|
_traces_decorator |
_traces_decorator(meta: dict, indent_level: int = 0) -> str |
|
_collect_traces |
_collect_traces(classes: list[ClassInfo]) -> dict[str, dict] |
|
_parse_traces |
_parse_traces(file_path: str) -> dict[str, dict] |
Map class/method names to their @traces metadata. |
_extract_traces |
_extract_traces(decorator: py_ast.expr) -> dict[str, str | list[str]] | None |
Source
"""Enhanced test generator — produces spec-compliant pytest stubs with @traces, async support, fixtures, and AAA pattern.
Follows project standards: SPEC-097 (Testing), AGENTS.md § Test Standards (T1-T14).
- No mocking, stubs, fakes, skips
- @traces decorators propagated from source
- Function-scope fixtures
- test_id fixture for DB ops
- Async tests use @pytest.mark.asyncio
- AAA pattern (Arrange-Act-Assert)
"""
from __future__ import annotations
import ast as py_ast
from dataclasses import dataclass
from typing import TYPE_CHECKING
from orchestrata_tools.test_generator.testgen.types import (
LITERAL_FIXTURES,
ParamValueStrategy,
default_param_strategy,
fixture_args,
)
if TYPE_CHECKING:
from orchestrata_tools.models.ast_models import ClassInfo, MethodInfo, ParamInfo
@dataclass
class TestGenConfig:
"""Configuration for test generation."""
include_traces: bool = True
include_imports: bool = True
include_fixtures: bool = True
async_support: bool = True
fixture_scope: str = "function"
aaa_pattern: bool = True
module_docstring: bool = True
test_class_prefix: str = "Test"
class EnhancedTestGenerator:
"""Generate spec-compliant pytest stubs with full @traces support."""
#: One source of truth: the literal table lives in ``testgen.types``.
TYPE_FIXTURE: dict[str, str] = LITERAL_FIXTURES
def __init__(
self,
config: TestGenConfig | None = None,
values: ParamValueStrategy | None = None,
):
self.config = config or TestGenConfig()
# WI-TESTGEN (task 575): path-like ``str`` params are bound to
# ``tmp_path`` so a generated test can never write into the checkout.
self._values = values or default_param_strategy(
self.TYPE_FIXTURE, "None # TODO: {name} ({ann})"
)
# ── Module-level generation ────────────────────────────────────
def generate_module(
self, classes: list[ClassInfo], source_module: str, test_module: str
) -> str:
"""Generate a complete test module."""
parts: list[str] = []
if self.config.module_docstring:
parts.append(self._docstring(source_module))
if self.config.include_imports:
parts.append(self._imports(classes, source_module))
all_traces = self._collect_traces(classes)
for cls_info in classes:
if cls_info.name.startswith("_"):
continue
is_enum = cls_info.is_enum
parts.append(
self._test_class(cls_info, source_module, all_traces, is_enum=is_enum)
)
return "\n\n".join(parts) + "\n"
def _docstring(self, source_module: str) -> str:
return (
f'"""Tests for {source_module}.\n\n'
"Test Standards (AGENTS.md § Test Standards):\n"
" T1-T9 enforced. No mocking, stubs, fakes, skips, monkeypatch.\n"
" Function-scope fixtures only. test_id for DB ops. Parallel-safe.\n"
'"""\n'
)
def _imports(self, classes: list[ClassInfo], source_module: str) -> str:
"""Generate ordered import block: stdlib → third-party → local."""
lines = []
lines.append("from __future__ import annotations")
lines.append("")
lines.append("import pytest")
lines.append("from datetime import UTC, datetime")
lines.append("from pathlib import Path")
lines.append("from uuid import uuid4")
# Source module imports
cls_names = [c.name for c in classes if not c.name.startswith("_")]
if len(cls_names) == 1:
lines.append(
f"from orchestrata_ultimate.{source_module} import {cls_names[0]}"
)
else:
lines.append(f"from orchestrata_ultimate.{source_module} import (")
lines.extend(f" {name}," for name in cls_names)
lines.append(")")
# @traces import if needed
if self._has_traces(classes):
lines.append("from orchestrata_ultimate.core.base.traces import traces")
return "\n".join(lines) + "\n"
def _has_traces(self, classes: list[ClassInfo]) -> bool:
for cls in classes:
fp = getattr(cls, "file_path", "") if hasattr(cls, "file_path") else ""
if not fp:
continue
try:
with open(fp) as f:
if "@traces" in f.read():
return True
except Exception:
pass
return False
# ── Test class ─────────────────────────────────────────────────
def _test_class(
self,
cls_info: ClassInfo,
source_module: str,
all_traces: dict,
is_enum: bool = False,
) -> str:
lines = []
test_name = f"{self.config.test_class_prefix}{cls_info.name}"
class_traces = all_traces.get(cls_info.name, {})
if class_traces and self.config.include_traces:
lines.append(self._traces_decorator(class_traces, indent_level=0))
if is_enum:
lines.append(f"class {test_name}:")
for var in cls_info.class_vars or []:
vname = getattr(var, "name", "") if hasattr(var, "name") else str(var)
lines.append(f" def test_{vname.lower()}_is_valid(self) -> None:")
lines.append(
f' """Test {cls_info.name}.{vname} is a valid enum value."""'
)
# Value-agnostic: don't assume the member's value is its
# lowercased name (e.g. RED = "#ff0000", ACTIVE = 1, auto()).
lines.append(
f" assert {cls_info.name}.{vname} in {cls_info.name}"
)
lines.append(
f' assert {cls_info.name}.{vname}.name == "{vname}"'
)
lines.append("")
return "\n".join(lines).rstrip()
lines.append(f"class {test_name}:")
lines.append("")
lines.append(" @pytest.fixture")
lines.append(f" def obj(self) -> {cls_info.name}:")
lines.append(f" return {cls_info.name}()")
lines.append("")
for method in cls_info.methods:
if method.name.startswith("_") and method.name != "__init__":
continue
m = self._test_method(method, cls_info, all_traces)
if m:
lines.append(m)
return "\n".join(lines).rstrip()
def _test_method(
self,
method: MethodInfo,
cls_info: ClassInfo,
all_traces: dict,
) -> str:
lines = []
method_traces = all_traces.get(f"{cls_info.name}.{method.name}", {})
if method_traces and self.config.include_traces:
lines.append(self._traces_decorator(method_traces, indent_level=1))
if self.config.async_support and method.is_async:
lines.append(" @pytest.mark.asyncio")
# Cap at 5 params: the Act call below MUST use this same capped
# list, not the full `params` — otherwise a method with >5
# params generates a call referencing names past the cap that
# the Arrange section never assigned (NameError at test-run time).
params = [p for p in method.parameters if p.name not in ("self", "cls")][:5]
values = [self._fixture_for(p) for p in params]
extra = "".join(f", {name}" for name in fixture_args(values))
test_name = f"test_{method.name}"
if method.is_async:
lines.append(
f" async def {test_name}(self, obj: {cls_info.name}{extra}) -> None:"
)
else:
lines.append(
f" def {test_name}(self, obj: {cls_info.name}{extra}) -> None:"
)
lines.append(f' """Test {cls_info.name}.{method.name}()."""')
if self.config.aaa_pattern:
lines.append("")
lines.append(" # Arrange")
lines.extend(
f" {p.name} = {value}" for p, value in zip(params, values)
)
lines.append("")
lines.append(" # Act")
call_args = ", ".join(f"{p.name}={p.name}" for p in params)
async_kw = "await " if method.is_async else ""
if method.return_type and method.return_type != "None":
lines.append(
f" result = {async_kw}obj.{method.name}({call_args})"
)
else:
lines.append(f" {async_kw}obj.{method.name}({call_args})")
lines.append("")
lines.append(" # Assert")
if method.return_type and method.return_type != "None":
lines.append(" assert result is not None")
else:
lines.append(" # Verify side effects or state changes")
else:
lines.append(" pass # TODO: implement")
return "\n".join(lines) + "\n"
def _fixture_for(self, param: ParamInfo) -> str:
default = getattr(param, "default", None)
if default is not None:
return str(default)
return self._values.value_for(param)
def _traces_decorator(self, meta: dict, indent_level: int = 0) -> str:
prefix = " " * indent_level
reqs = meta.get("reqs") or ["REQ-???-???"]
# `reqs` is normally a list (see _extract_traces), but tolerate a
# bare string too rather than iterating its characters.
if isinstance(reqs, str):
reqs = [reqs]
reqs_literal = ", ".join(f'"{r}"' for r in reqs)
return (
f"{prefix}@traces("
f'spec="{meta.get("spec", "???")}", '
f'story="{meta.get("story", "???")}", '
f"reqs=[{reqs_literal}])"
)
# ── @traces extraction ─────────────────────────────────────────
def _collect_traces(self, classes: list[ClassInfo]) -> dict[str, dict]:
result: dict[str, dict] = {}
for cls in classes:
fp = getattr(cls, "file_path", "") if hasattr(cls, "file_path") else ""
if fp:
try:
result.update(self._parse_traces(fp))
except Exception:
pass
return result
def _parse_traces(self, file_path: str) -> dict[str, dict]:
"""Map class/method names to their @traces metadata.
Keyed to match what ``_test_class``/``_test_method`` look up:
a bare class name ("Widget") for class-level @traces, and
"ClassName.method_name" for method-level @traces. A flat
``ast.walk`` (used previously) loses the enclosing-class context,
so it keyed everything by bare node name — colliding methods of
the same name across different classes and never matching the
qualified-name lookups the callers actually perform (@traces was
silently never emitted). This walks module body -> class body
explicitly to preserve that context.
"""
result: dict[str, dict] = {}
try:
with open(file_path) as f:
tree = py_ast.parse(f.read())
except Exception:
return result
for node in tree.body:
if isinstance(node, py_ast.ClassDef):
for dec in node.decorator_list:
meta = self._extract_traces(dec)
if meta:
result[node.name] = meta
for item in node.body:
if isinstance(item, (py_ast.FunctionDef, py_ast.AsyncFunctionDef)):
for dec in item.decorator_list:
meta = self._extract_traces(dec)
if meta:
result[f"{node.name}.{item.name}"] = meta
elif isinstance(node, (py_ast.FunctionDef, py_ast.AsyncFunctionDef)):
for dec in node.decorator_list:
meta = self._extract_traces(dec)
if meta:
result[node.name] = meta
return result
def _extract_traces(
self, decorator: py_ast.expr
) -> dict[str, str | list[str]] | None:
if (
not isinstance(decorator, py_ast.Call)
or not isinstance(decorator.func, py_ast.Name)
or decorator.func.id != "traces"
):
return None
meta: dict[str, str | list[str]] = {}
for kw in decorator.keywords:
if kw.arg == "spec" and isinstance(kw.value, py_ast.Constant):
meta["spec"] = str(kw.value.value)
elif kw.arg == "story" and isinstance(kw.value, py_ast.Constant):
meta["story"] = str(kw.value.value)
elif kw.arg == "reqs" and isinstance(kw.value, py_ast.List):
reqs = [
str(e.value)
for e in kw.value.elts
if isinstance(e, py_ast.Constant)
]
# Keep as a real list (capped at 3) — joining into one
# comma-separated string here used to produce a single
# malformed reqs=["A, B, C"] entry instead of a proper
# multi-element list, which the real @traces() decorator
# (core.base.traces) rejects: it validates each requirement
# ID individually against a strict REQ-NNN-NNN pattern.
meta["reqs"] = reqs[:3]
return meta if meta else None