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
426 lines
16 KiB
Python
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()
|