molecule-core/workspace/tests/_signature_snapshot.py
Hongming Wang 70176e6c8f test(runtime_wedge): module-functions signature snapshot drift gate
BaseAdapter docstring tells adapter authors:

> ``runtime_wedge.mark_wedged()`` / ``clear_wedge()`` — flip the
> workspace to ``degraded`` + auto-recover when your SDK hits a
> non-recoverable error class. Import directly from ``runtime_wedge``;
> the heartbeat forwards the state to the platform automatically.

That's a contract — adapter templates depend on the four module-level
functions (``is_wedged``, ``wedge_reason``, ``mark_wedged``,
``clear_wedge``) being importable by those exact names with those
exact signatures. Renaming any silently breaks every adapter that
calls them: the import resolves the module fine, the
``AttributeError`` only surfaces when the adapter actually hits its
first SDK error — long after the rename merges.

Same drift class as #2378 / #2380 / #2381 (BaseAdapter, skill_loader)
applied to the module-level function surface.

Changes:

  - tests/_signature_snapshot.py gains build_module_functions_record.
    Walks a module's public top-level functions, optionally filtered
    to a specific name list (used here — runtime_wedge has internal
    helpers like reset_for_test that intentionally aren't part of
    the contract). Skips re-exports via __module__ check so a
    `from foo import bar` doesn't pollute the snapshot.
  - tests/test_runtime_wedge_signature.py snapshots the four
    contract functions. Plus a defense-in-depth required-functions
    test that catches removal even when source + snapshot are
    updated together.

Verified: deliberately renaming `mark_wedged` → `mark_wedged_RENAMED`
trips the gate with full snapshot diff in the failure message.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 07:01:10 -07:00

192 lines
7.1 KiB
Python

"""Shared inspect-based signature-snapshot helpers (#2364 item 2).
Originally lived inline in tests/test_adapter_base_signature.py.
Extracted here so each public-surface module gets its own
test_*_signature.py + snapshot file without copy-pasting the
introspection logic.
Pattern (one snapshot file per module):
from tests._signature_snapshot import (
build_class_signature_record,
build_dataclass_record,
compare_against_snapshot,
)
SNAPSHOT_PATH = Path(__file__).parent / "snapshots" / "<module>_signature.json"
def _build_full_snapshot() -> dict:
from <module> import PublicClass, PublicDataclass
return {
"module": "<module>",
"classes": [build_class_signature_record(PublicClass)],
"dataclasses": [build_dataclass_record(PublicDataclass)],
}
def test_<module>_signature_matches_snapshot():
compare_against_snapshot(_build_full_snapshot(), SNAPSHOT_PATH)
The snapshot is a stable JSON file — sort_keys + indent=2 — so
diffs are reviewable in PR. Any drift trips the test with both
expected and actual JSON in the failure message.
"""
import inspect
import json
from pathlib import Path
import pytest
def _annotation_repr(annotation: object) -> str:
"""Stable string form of a type annotation. ``inspect`` returns the
runtime objects which don't compare cleanly — repr is the boring
correct answer for snapshotting."""
if annotation is inspect.Parameter.empty:
return ""
if isinstance(annotation, type):
return annotation.__name__
return str(annotation)
def _parameter_record(p: inspect.Parameter) -> dict:
return {
"name": p.name,
"kind": p.kind.name,
"annotation": _annotation_repr(p.annotation),
"has_default": p.default is not inspect.Parameter.empty,
}
def _signature_record(name: str, fn: object) -> dict:
sig = inspect.signature(fn)
return {
"name": name,
"is_async": inspect.iscoroutinefunction(fn),
"is_abstract": getattr(fn, "__isabstractmethod__", False),
"parameters": [_parameter_record(p) for p in sig.parameters.values()],
"return_annotation": _annotation_repr(sig.return_annotation),
}
def build_class_signature_record(cls: type) -> dict:
"""Snapshot a class's public method surface. Public = name doesn't
start with underscore. Static/class/abstract methods are unwrapped
so the underlying function signature is captured.
Returns: ``{class: <name>, methods: [<sorted method records>]}``
"""
methods: list[dict] = []
for attr_name in sorted(vars(cls)):
if attr_name.startswith("_"):
continue
attr = vars(cls)[attr_name]
if isinstance(attr, staticmethod):
fn = attr.__func__
elif isinstance(attr, classmethod):
fn = attr.__func__
elif callable(attr):
fn = attr
else:
continue
methods.append(_signature_record(attr_name, fn))
return {"class": cls.__name__, "methods": methods}
def build_module_functions_record(module: object, function_names: list[str] | None = None) -> dict:
"""Snapshot a module's public top-level functions. By default, walks
every public callable defined IN the module (excludes re-exports
via __module__ check). Pass ``function_names`` explicitly to pin a
specific set when the module exports more than the contract surface
(e.g. internal helpers that intentionally aren't part of the gate).
Returns: ``{module: <name>, functions: [<sorted records>]}``
"""
import types
fns: list[dict] = []
target_module = module.__name__
if function_names is not None:
for fn_name in sorted(function_names):
fn = getattr(module, fn_name, None)
if fn is None or not isinstance(fn, types.FunctionType):
# Caller asked for a name that isn't a function in the
# module — surface it as part of the snapshot so the
# error path stays in the failure-message-with-diff
# path rather than blowing up here.
fns.append({"name": fn_name, "missing": True})
continue
fns.append(_signature_record(fn_name, fn))
else:
for attr_name in sorted(vars(module)):
if attr_name.startswith("_"):
continue
attr = getattr(module, attr_name)
if not isinstance(attr, types.FunctionType):
continue
# Skip re-exports — only record functions defined IN this
# module so a `from foo import bar` doesn't pollute the
# snapshot.
if getattr(attr, "__module__", None) != target_module:
continue
fns.append(_signature_record(attr_name, attr))
return {"module": target_module, "functions": fns}
def build_dataclass_record(cls: type) -> dict:
"""Snapshot a dataclass's field shape. Captures field name + type
annotation + has_default per field, plus the @dataclass(frozen=...)
flag. Default values themselves are NOT recorded (would require
brittle value-shape stringifying for non-trivial defaults).
Returns: ``{name, frozen, fields: [<field records>]}``
"""
import dataclasses as _dc
fields = []
for f in _dc.fields(cls):
fields.append({
"name": f.name,
"annotation": _annotation_repr(f.type) if not isinstance(f.type, str) else f.type,
"has_default": f.default is not _dc.MISSING or f.default_factory is not _dc.MISSING,
})
return {
"name": cls.__name__,
"frozen": getattr(cls, "__dataclass_params__").frozen,
"fields": fields,
}
def compare_against_snapshot(actual: dict, snapshot_path: Path) -> None:
"""Compare a built snapshot against a checked-in JSON file.
On first run (snapshot missing): writes the file and skips. Re-run
to verify it now passes — the snapshot file appears in the diff
of the PR introducing it.
On drift: fails the test with both expected and actual JSON in
the failure message so the reviewer sees the change without
re-running anything.
"""
if not snapshot_path.exists():
snapshot_path.parent.mkdir(parents=True, exist_ok=True)
snapshot_path.write_text(json.dumps(actual, indent=2, sort_keys=True) + "\n")
pytest.skip(
f"snapshot did not exist; wrote {snapshot_path.name}"
"re-run the test to verify it now passes"
)
expected = json.loads(snapshot_path.read_text())
if actual != expected:
actual_str = json.dumps(actual, indent=2, sort_keys=True)
expected_str = json.dumps(expected, indent=2, sort_keys=True)
pytest.fail(
f"Signature drifted from {snapshot_path.name}.\n\n"
"Update intentionally by deleting the snapshot file and re-running, "
"OR by editing it to match. The PR diff makes the change visible "
"to reviewers and to template repos that depend on this surface.\n\n"
f"=== EXPECTED ({snapshot_path.name}) ===\n{expected_str}\n\n"
f"=== ACTUAL (current source) ===\n{actual_str}\n"
)