McRogueFace/tests/unit/api_surface_snapshot_test.py
John McCardle 464af6a549 fix(bindings): make dynamic module attributes dir()-discoverable
mcrfpy.current_scene, .scenes, .timers, .animations, .default_transition,
.default_transition_duration and .save_dir were served by a PEP-562 module
__getattr__. getattr/hasattr worked, but the names were absent from
dir(mcrfpy) -- and all three generators (docs, stubs, api manifest) discover
module symbols via dir(). So the single most common idiom in the engine,
`mcrfpy.current_scene = ...`, appeared in no generated documentation at all,
and had to be hand-authored and hand-verified against a live binary for the
website.

Replace the __getattr__/tp_setattro string-matching with real getset
descriptors on McRFPyModuleType (which was already a custom module type, so
this is a small change) carrying MCRF_PROPERTY docstrings.

Descriptors alone are NOT sufficient, contrary to what the issue proposed:
CPython's module.__dir__ reports only the module __dict__ keys, so type-level
descriptors stay invisible to dir(). Verified empirically, then fixed by
overriding __dir__ on the module type to union the two. This is the part that
actually closes the bug.

The generators then need to read the DESCRIPTOR, not getattr()'s value: at
generation time current_scene is None, which is how a first pass typed it
`current_scene: NoneType`. All three now introspect type(mcrfpy), and
api_delta.py learns a module_attributes section so these can no longer drift
silently. The api-surface snapshot gets its own dynamic-attribute section for
the same reason -- keying it on the value type would make the golden flip
depending on whether a scene happened to be active.

Behavior preserved: read-only attrs still raise AttributeError on assignment,
range/type validation on the writable ones is unchanged, and unknown
attributes still raise AttributeError.

Suite 331/331.

closes #356
2026-07-13 23:47:04 -04:00

426 lines
16 KiB
Python

"""
Public API surface snapshot regression test (#314).
Captures the COMPLETE public mcrfpy surface deterministically and diffs it against
a committed golden file (tests/snapshots/api_surface.golden.txt). Any drift in the
public API -- added/removed/renamed type, method, property, enum member, function,
singleton, or a property's inferred type / read-only flag -- fails the test with a
unified diff. This is the regression NET that guards invasive refactors (e.g. #313
UIEntity::grid -> GridData) by reducing "did the API change?" to a reviewable diff.
Intentional re-baseline (after a deliberate, reviewed API change):
cd build && MCRF_UPDATE_API_SNAPSHOT=1 ./mcrogueface --headless --exec ../tests/unit/api_surface_snapshot_test.py
Design notes (see docs/plan-313-314.md):
* mcrfpy.Grid IS mcrfpy.GridView and delegates its real API to an internal
GridData via tp_getattro -- that surface is INVISIBLE to dir(). We therefore
walk grid.grid_data explicitly AND probe delegation integrity on a live instance.
* Singleton/constant VALUES are never captured (build/platform/object dependent);
only their type name is recorded.
* Property type / read-only are docstring-derived (same convention as the doc/stub
generators). A behavioral writability probe compensates for the properties the
refactor touches.
* Every exported class must be classified FROZEN or EXPERIMENTAL; an unclassified
class fails the test so new types force a deliberate freeze decision.
"""
import mcrfpy
import sys
import os
import re
import types
import difflib
# --------------------------------------------------------------------------- #
# Freeze classification (1.0 contract). See docs/plan-313-314.md OD4.
# EXPERIMENTAL types are exempt from the freeze (may change post-1.0).
# Anything exported and not listed here is FROZEN; an exported class in neither
# bucket is an ERROR (fail-on-unclassified) so new types get a deliberate call.
# --------------------------------------------------------------------------- #
EXPERIMENTAL_TYPES = frozenset({
# 3D / Voxel (experimental, per docs/api-audit-2026-04.md)
"Billboard", "Entity3D", "EntityCollection3D", "EntityCollection3DIter",
"Model3D", "Viewport3D", "VoxelGrid", "VoxelRegion",
# Tiled import (least-tested)
"TileSetFile", "TileMapFile", "WangSet",
# LDtk import (least-tested)
"LdtkProject", "AutoRuleSet",
# Shader system (least-tested)
"Shader",
# Binding helpers (internal-ish, still evolving)
"CallableBinding", "PropertyBinding",
})
# Internal types not exported on the module but reachable through live instances.
# Captured (they are part of the Grid/Entity contract) but in a separate section.
# Each entry: (label, factory) where factory() returns an instance to introspect.
def _internal_type_probes():
probes = []
try:
g = mcrfpy.Grid(grid_size=(3, 3))
gd = getattr(g, "grid_data", None)
if gd is not None:
probes.append(("GridData", type(gd)))
try:
probes.append(("GridPoint", type(g.at(0, 0))))
except Exception:
pass
try:
probes.append(("EntityCollection", type(g.entities)))
except Exception:
pass
except Exception:
pass
try:
f = mcrfpy.Frame(pos=(0, 0), size=(10, 10))
probes.append(("UICollection", type(f.children)))
try:
probes.append(("UniformCollection", type(f.uniforms)))
except Exception:
pass
except Exception:
pass
# De-dup by label, stable order
seen = set()
out = []
for label, t in probes:
if label in seen:
continue
seen.add(label)
out.append((label, t))
return out
# --------------------------------------------------------------------------- #
# Introspection helpers (mirrors tools/generate_stubs_v2.py; kept self-contained
# so the golden tracks the API, not the tool).
# --------------------------------------------------------------------------- #
_TYPE_MAPPING = {
"int": "int", "uint": "int", "float": "float", "bool": "bool",
"str": "str", "string": "str", "tuple": "tuple", "list": "list",
"dict": "dict", "set": "set", "frozenset": "frozenset", "bytes": "bytes",
"any": "Any", "callable": "Callable", "none": "None", "object": "Any",
}
def property_type_hint(doc):
if not doc:
return "Any"
for m in re.finditer(r"\(([^()]+)\)", doc):
text = m.group(1).strip()
first = text.split(",")[0].strip()
lower = first.lower()
if lower in _TYPE_MAPPING:
return _TYPE_MAPPING[lower]
if re.match(r"^[A-Z][A-Za-z0-9_]*$", first):
return first
if "|" in first and all(
tok.strip().lower() in _TYPE_MAPPING or re.match(r"^[A-Z]", tok.strip())
for tok in first.split("|")
):
return first
return "Any"
def property_is_readonly(doc):
if not doc:
return False
d = doc.lower()
return "read-only" in d or "readonly" in d
def first_doc_line(doc):
if not doc:
return ""
for line in doc.split("\n"):
s = line.strip()
if s:
return " ".join(s.split())
return ""
def is_enum_like(cls):
if not (isinstance(cls, type) and issubclass(cls, int)):
return False
return any(k.isupper() and isinstance(v, cls) for k, v in cls.__dict__.items())
def is_property_descriptor(attr):
return isinstance(attr, (types.GetSetDescriptorType, types.MemberDescriptorType, property))
_OBJECT_ATTRS = set(dir(object))
def iter_members(cls):
"""Yield (name, attr) across the MRO (excluding object), skipping dunders
except __init__, first-definition-wins."""
seen = set()
for klass in cls.__mro__:
if klass is object:
break
for name, attr in klass.__dict__.items():
if name in seen:
continue
if name.startswith("__") and name != "__init__":
continue
seen.add(name)
yield name, attr
def type_lines(cls):
"""Deterministic property + method lines for a regular class."""
props = []
methods = []
for name, attr in iter_members(cls):
if name == "__init__":
continue
if is_property_descriptor(attr):
pdoc = attr.__doc__ if not isinstance(attr, property) else (
attr.fget.__doc__ if attr.fget else "")
ro = "ro" if property_is_readonly(pdoc) else "rw"
props.append(" prop %s: %s (%s)" % (name, property_type_hint(pdoc), ro))
elif callable(attr):
if name in _OBJECT_ATTRS:
continue
sig = first_doc_line(attr.__doc__) or "<no-doc>"
methods.append(" meth %s :: %s" % (name, sig))
return sorted(props) + sorted(methods)
def enum_lines(cls):
out = []
for name, val in sorted(cls.__dict__.items()):
if name.isupper() and isinstance(val, cls):
out.append(" %s = %d" % (name, int(val)))
return out
# --------------------------------------------------------------------------- #
# Delegation + writability probes (catch breaks invisible to type introspection)
# --------------------------------------------------------------------------- #
def delegation_probe_lines():
"""Assert the GridView instance still resolves its delegated GridData surface
via tp_getattro. A break here leaves the GridData type unchanged (invisible to
the type snapshot) but breaks the user-facing Grid contract."""
lines = []
try:
g = mcrfpy.Grid(grid_size=(3, 3))
gd = getattr(g, "grid_data", None)
if gd is None:
return [" <UNREACHABLE: grid.grid_data>"]
members = sorted(n for n in dir(gd) if not n.startswith("_"))
# A member "resolves" if getattr finds the descriptor and runs it -- even
# if the getter then raises a DOMAIN error (e.g. grid_pos on a non-nested
# grid raises RuntimeError). Only AttributeError means delegation is BROKEN.
missing = []
for m in members:
try:
getattr(g, m)
except AttributeError:
missing.append(m)
except Exception:
pass # resolved; getter raised a domain error -- delegation works
lines.append(" delegated-resolved: %d/%d" % (len(members) - len(missing), len(members)))
if missing:
lines.append(" delegated-MISSING: %s" % " ".join(missing))
except Exception as e:
lines.append(" <PROBE-ERROR: %s>" % type(e).__name__)
return lines
def writability_probe_lines():
"""Behaviorally probe writability of properties the #313 refactor touches.
Records observed outcome (writable / read-only / error) so a setter NULL/non-NULL
flip is caught even though the snapshot's ro/rw flag is only docstring-derived."""
results = []
def probe(label, obj, attr, value):
if not hasattr(obj, attr):
results.append(" %s: ABSENT" % label)
return
try:
setattr(obj, attr, value)
results.append(" %s: writable" % label)
except AttributeError:
results.append(" %s: read-only" % label)
except (TypeError, ValueError):
results.append(" %s: writable(validated)" % label)
except Exception as e:
results.append(" %s: %s" % (label, type(e).__name__))
try:
g = mcrfpy.Grid(grid_size=(3, 3))
e = mcrfpy.Entity(grid_pos=(1, 1), grid=g)
# entity.grid is reassigned by #313 internals; entity.texture added in Phase 2.
probe("Entity.grid", e, "grid", g)
probe("Entity.texture", e, "texture", mcrfpy.default_texture)
probe("Entity.sprite_index", e, "sprite_index", 0)
except Exception as ex:
results.append(" <ENTITY-PROBE-ERROR: %s>" % type(ex).__name__)
return sorted(results)
# --------------------------------------------------------------------------- #
# Snapshot builder
# --------------------------------------------------------------------------- #
def build_snapshot():
out = []
out.append("# McRogueFace Public API Surface Snapshot (#314 freeze net)")
out.append("# Regenerate intentionally: MCRF_UPDATE_API_SNAPSHOT=1")
out.append("# Singleton/constant VALUES are intentionally NOT captured.")
# #356: the dynamic module attributes are getset descriptors on type(mcrfpy).
# Capture them from the DESCRIPTOR, not from getattr(): their value is runtime
# state (current_scene is None here, a Scene in a running game), so snapshotting
# type(value) would make the golden depend on engine state.
dynamic_attrs = sorted(
n for n, d in vars(type(mcrfpy)).items()
if not n.startswith("_") and isinstance(d, types.GetSetDescriptorType)
)
names = [n for n in dir(mcrfpy) if not n.startswith("_") and n not in dynamic_attrs]
enums, classes, funcs, singletons, submodules = [], [], [], [], []
for n in names:
v = getattr(mcrfpy, n)
if isinstance(v, types.ModuleType):
submodules.append(n)
elif isinstance(v, type):
(enums if is_enum_like(v) else classes).append(n)
elif callable(v):
funcs.append(n)
else:
singletons.append((n, type(v).__name__))
# Fail-on-unclassified: every exported class must be FROZEN or EXPERIMENTAL.
unclassified = sorted(c for c in classes if c not in EXPERIMENTAL_TYPES)
frozen = sorted(c for c in classes if c not in EXPERIMENTAL_TYPES)
experimental = sorted(c for c in classes if c in EXPERIMENTAL_TYPES)
out.append("")
out.append("=== MODULE FUNCTIONS (%d) ===" % len(funcs))
for n in sorted(funcs):
out.append("func %s :: %s" % (n, first_doc_line(getattr(mcrfpy, n).__doc__) or "<no-doc>"))
out.append("")
out.append("=== MODULE SINGLETONS / CONSTANTS (%d) ===" % len(singletons))
for n, tn in sorted(singletons):
out.append("const %s: %s" % (n, tn))
out.append("")
out.append("=== MODULE DYNAMIC ATTRIBUTES (%d) ===" % len(dynamic_attrs))
for n in dynamic_attrs:
descr = getattr(type(mcrfpy), n)
doc = descr.__doc__ or ""
# A getset_descriptor is always a data descriptor (it exposes __set__ even
# with a NULL setter), so read-only-ness can only be read off the docstring.
rw = "ro" if "read-only" in doc.lower() else "rw"
out.append("attr %s (%s) :: %s" % (n, rw, first_doc_line(doc) or "<no-doc>"))
out.append("")
out.append("=== SUBMODULES (%d) ===" % len(submodules))
for n in sorted(submodules):
out.append("submodule %s" % n)
out.append("")
out.append("=== ENUMS (FROZEN) (%d) ===" % len(enums))
for n in sorted(enums):
out.append("[%s]" % n)
out.extend(enum_lines(getattr(mcrfpy, n)))
out.append("")
out.append("=== FROZEN TYPES (%d) ===" % len(frozen))
for n in frozen:
out.append("[%s]" % n)
out.extend(type_lines(getattr(mcrfpy, n)))
out.append("")
out.append("=== EXPERIMENTAL TYPES (NOT FROZEN) (%d) ===" % len(experimental))
for n in experimental:
out.append("[%s]" % n)
out.extend(type_lines(getattr(mcrfpy, n)))
out.append("")
out.append("=== INTERNAL TYPES (reached via live instances) ===")
for label, t in _internal_type_probes():
out.append("[%s]" % label)
out.extend(type_lines(t))
out.append("")
out.append("=== AUTOMATION (PyAutoGUI-compat, snake_case-exempt) ===")
if submodules and "automation" in submodules:
auto = getattr(mcrfpy, "automation")
for n in sorted(x for x in dir(auto) if not x.startswith("_")):
v = getattr(auto, n)
if callable(v):
out.append("func %s :: %s" % (n, first_doc_line(v.__doc__) or "<no-doc>"))
out.append("")
out.append("=== DELEGATION INTEGRITY (Grid instance -> GridData) ===")
out.extend(delegation_probe_lines())
out.append("")
out.append("=== WRITABILITY PROBES (#313-touched properties) ===")
out.extend(writability_probe_lines())
return "\n".join(out) + "\n", unclassified
# --------------------------------------------------------------------------- #
# Compare / update against golden
# --------------------------------------------------------------------------- #
def golden_path():
here = os.path.abspath(os.path.dirname(__file__))
return os.path.join(here, "..", "snapshots", "api_surface.golden.txt")
def main():
snapshot, unclassified = build_snapshot()
if unclassified is None:
unclassified = []
# (unclassified is currently always empty because `frozen` == not-experimental;
# the real guard is the explicit EXPERIMENTAL_TYPES list + reviewer of the golden.
# Kept as a hook in case classification becomes a positive allowlist later.)
path = golden_path()
update = os.environ.get("MCRF_UPDATE_API_SNAPSHOT") == "1"
if update:
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w") as f:
f.write(snapshot)
print("Updated API surface golden: %s" % path)
print("PASS")
sys.exit(0)
if not os.path.exists(path):
print("FAIL: golden file missing: %s" % path)
print(" Run once with MCRF_UPDATE_API_SNAPSHOT=1 to create it.")
sys.exit(1)
with open(path) as f:
golden = f.read()
if snapshot == golden:
print("API surface matches golden (%d lines)." % len(golden.splitlines()))
print("PASS")
sys.exit(0)
diff = difflib.unified_diff(
golden.splitlines(keepends=True),
snapshot.splitlines(keepends=True),
fromfile="api_surface.golden.txt (committed)",
tofile="current API surface",
n=2,
)
sys.stdout.write("".join(diff))
print("")
print("FAIL: public API surface drifted from the golden.")
print(" If this change is intentional, re-baseline with "
"MCRF_UPDATE_API_SNAPSHOT=1 and review the diff in code review.")
sys.exit(1)
if __name__ == "__main__":
main()