""" 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 "" 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 [" "] 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(" " % 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(" " % 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 "")) 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 "")) 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 "")) 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()