McRogueFace/tools/generate_api_manifest.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

409 lines
13 KiB
Python

#!/usr/bin/env python3
"""
API manifest generator for McRogueFace.
Runs INSIDE the engine (mcrfpy embedded interpreter) and introspects the
compiled `mcrfpy` module to emit two artifacts:
api/manifest.json -- compact, committed, deterministic baseline
docs/generated/api_full.json -- same tree plus full docstring text
Invoke:
cd build && ./mcrogueface --headless --exec ../tools/generate_api_manifest.py
The manifest tracks per-object / per-member lifecycle (since / modified) by
diffing against the previous committed manifest (git show HEAD:api/manifest.json).
Renderers must tolerate optional future lifecycle keys "deprecated" and "removed".
Pure ASCII source (embedded interpreter constraint).
"""
import os
import sys
import json
import types
import hashlib
import subprocess
try:
import mcrfpy
except ImportError:
print("Error: this script must be run with McRogueFace as the interpreter")
print("Usage: ./build/mcrogueface --headless --exec ../tools/generate_api_manifest.py")
sys.exit(1)
SCHEMA_VERSION = 1
# ---------------------------------------------------------------------------
# Git / version helpers
# ---------------------------------------------------------------------------
def run_git(args, repo):
"""Return (returncode, stdout_text). Never raises."""
try:
proc = subprocess.run(
["git"] + args,
cwd=repo,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
return proc.returncode, proc.stdout.decode("utf-8", "replace")
except Exception:
return 1, ""
def find_repo_root():
rc, out = run_git(["rev-parse", "--show-toplevel"], os.getcwd())
if rc == 0 and out.strip():
return out.strip()
# Fall back to the tools/ directory's parent.
here = os.path.dirname(os.path.abspath(__file__))
return os.path.dirname(here)
def parse_base_version(repo):
"""Parse MCRFPY_VERSION from src/McRogueFaceVersion.h."""
header = os.path.join(repo, "src", "McRogueFaceVersion.h")
with open(header, "r") as f:
text = f.read()
marker = "#define MCRFPY_VERSION"
for line in text.splitlines():
line = line.strip()
if line.startswith(marker):
q1 = line.find('"')
q2 = line.find('"', q1 + 1)
if q1 != -1 and q2 != -1:
return line[q1 + 1:q2]
raise RuntimeError("Could not parse MCRFPY_VERSION from " + header)
def compute_version_info(repo):
base = parse_base_version(repo)
rc, _ = run_git(["describe", "--tags", "--exact-match"], repo)
exact_tag = (rc == 0)
version = base if exact_tag else (base + "-dev")
rc, out = run_git(["rev-parse", "--short", "HEAD"], repo)
commit = out.strip() if rc == 0 else ""
rc, out = run_git(["status", "--porcelain", "--", "src/"], repo)
dirty = bool(out.strip()) if rc == 0 else False
return base, version, commit, dirty
def load_previous_manifest(repo):
"""Return the previous committed manifest dict, or None on first run."""
rc, out = run_git(["show", "HEAD:api/manifest.json"], repo)
if rc != 0 or not out.strip():
return None
try:
return json.loads(out)
except Exception:
return None
# ---------------------------------------------------------------------------
# Introspection helpers
# ---------------------------------------------------------------------------
def doc_hash(doc):
"""First 16 hex chars of sha256 of the docstring (hash empty string if absent)."""
return hashlib.sha256((doc or "").encode("utf-8")).hexdigest()[:16]
def looks_like_signature(line):
"""A line looks like a signature if it is an optional identifier followed by
a parenthesised argument list: e.g. 'step(dt: float) -> float' or '(a, b)'."""
s = line.strip()
i = 0
n = len(s)
# optional leading identifier
while i < n and (s[i].isalnum() or s[i] == "_"):
i += 1
# optional spaces
while i < n and s[i] == " ":
i += 1
if i >= n or s[i] != "(":
return False
return ")" in s[i:]
def extract_signature(doc, text_signature):
"""First docstring line when it looks like a signature, else __text_signature__,
else empty string."""
if doc:
first = doc.strip().split("\n", 1)[0].strip()
if looks_like_signature(first):
return first
if text_signature:
return text_signature
return ""
def raw_descriptor(cls, name):
for base in getattr(cls, "__mro__", (cls,)):
if name in base.__dict__:
return base.__dict__[name]
return None
def classify_member(cls, name, attr):
"""Return one of method|classmethod|staticmethod|property, or None if the
attribute is not a documented API member (e.g. a plain enum value)."""
raw = raw_descriptor(cls, name)
raw_type = type(raw).__name__
if isinstance(raw, staticmethod):
return "staticmethod"
if raw_type == "classmethod_descriptor":
return "classmethod"
if isinstance(raw, property):
return "property"
if isinstance(raw, (types.GetSetDescriptorType, types.MemberDescriptorType)):
return "property"
if raw_type in ("method_descriptor", "wrapper_descriptor",
"builtin_function_or_method", "function") and callable(attr):
return "method"
return None
def member_entry(cls, name):
"""Build a full member entry (with 'doc'), or None if not an API member."""
try:
attr = getattr(cls, name)
except Exception:
return None
kind = classify_member(cls, name, attr)
if kind is None:
return None
doc = getattr(attr, "__doc__", None) or ""
text_sig = getattr(attr, "__text_signature__", None)
sig = extract_signature(doc, text_sig)
return {
"kind": kind,
"signature": sig,
"doc": doc,
"doc_sha": doc_hash(doc),
}
def collect_classes():
"""Return {class_name: full_entry_without_lifecycle}."""
classes = {}
for name in sorted(dir(mcrfpy)):
if name.startswith("_"):
continue
obj = getattr(mcrfpy, name)
if not isinstance(obj, type):
continue
doc = obj.__doc__ or ""
members = {}
for mname in sorted(dir(obj)):
if mname.startswith("__"):
continue
entry = member_entry(obj, mname)
if entry is not None:
members[mname] = entry
classes[name] = {
"kind": "class",
"doc": doc,
"doc_sha": doc_hash(doc),
"members": members,
}
return classes
def collect_module_attributes():
"""#356: dynamic module attributes, as getset descriptors on type(mcrfpy).
dir()+getattr() cannot describe these -- getattr yields the live value (None for
current_scene at generation time), not the declared type -- so read the descriptor.
"""
import types as _types
attrs = {}
for name, descr in sorted(vars(type(mcrfpy)).items()):
if name.startswith("_"):
continue
if not isinstance(descr, _types.GetSetDescriptorType):
continue
doc = descr.__doc__ or ""
attrs[name] = {
"kind": "attribute",
"readonly": "read-only" in doc.lower(),
"doc": doc,
"doc_sha": doc_hash(doc),
}
return attrs
def collect_functions():
"""Return {func_name: full_entry_without_lifecycle}."""
functions = {}
dynamic = collect_module_attributes()
for name in sorted(dir(mcrfpy)):
if name.startswith("_") or name in dynamic:
continue
obj = getattr(mcrfpy, name)
if isinstance(obj, type):
continue
if not callable(obj):
continue
doc = getattr(obj, "__doc__", None) or ""
text_sig = getattr(obj, "__text_signature__", None)
sig = extract_signature(doc, text_sig)
functions[name] = {
"kind": "function",
"signature": sig,
"doc": doc,
"doc_sha": doc_hash(doc),
}
return functions
# ---------------------------------------------------------------------------
# Lifecycle assignment
# ---------------------------------------------------------------------------
def assign_lifecycle(cur, prev, new_since, version, commit, has_signature):
"""Attach a 'lifecycle' dict to cur based on the previous entry.
- new (prev is None) -> {"since": new_since}
- doc_sha or signature changed -> copy prev lifecycle, set modified={version,commit}
- unchanged -> copy prev lifecycle verbatim
"""
if prev is None:
return {"since": new_since}
prev_lc = prev.get("lifecycle", {})
lifecycle = json.loads(json.dumps(prev_lc)) # deep copy, verbatim
if "since" not in lifecycle:
lifecycle["since"] = new_since
changed = prev.get("doc_sha") != cur["doc_sha"]
if has_signature:
changed = changed or (prev.get("signature", "") != cur.get("signature", ""))
if changed:
lifecycle["modified"] = {"version": version, "commit": commit}
return lifecycle
def build_tree(classes, functions, module_attrs, prev, first_run, base_version, version, commit):
"""Produce the full tree (entries retain 'doc'); lifecycle is computed here."""
new_since = base_version if first_run else version
prev_objects = (prev or {}).get("objects", {})
prev_functions = (prev or {}).get("functions", {})
prev_attrs = (prev or {}).get("module_attributes", {})
objects_out = {}
for cname, centry in classes.items():
prev_obj = prev_objects.get(cname)
obj_lifecycle = assign_lifecycle(
centry, prev_obj, new_since, version, commit, has_signature=False)
prev_members = (prev_obj or {}).get("members", {})
members_out = {}
for mname, mentry in centry["members"].items():
prev_member = prev_members.get(mname)
mentry = dict(mentry)
mentry["lifecycle"] = assign_lifecycle(
mentry, prev_member, new_since, version, commit, has_signature=True)
members_out[mname] = mentry
objects_out[cname] = {
"kind": "class",
"doc": centry["doc"],
"doc_sha": centry["doc_sha"],
"lifecycle": obj_lifecycle,
"members": members_out,
}
functions_out = {}
for fname, fentry in functions.items():
prev_fn = prev_functions.get(fname)
fentry = dict(fentry)
fentry["lifecycle"] = assign_lifecycle(
fentry, prev_fn, new_since, version, commit, has_signature=True)
functions_out[fname] = fentry
attrs_out = {}
for aname, aentry in module_attrs.items():
aentry = dict(aentry)
aentry["lifecycle"] = assign_lifecycle(
aentry, prev_attrs.get(aname), new_since, version, commit, has_signature=False)
attrs_out[aname] = aentry
return objects_out, functions_out, attrs_out
def strip_docs(node):
"""Recursively remove 'doc' keys, returning a new structure for the compact manifest."""
if isinstance(node, dict):
return {k: strip_docs(v) for k, v in node.items() if k != "doc"}
if isinstance(node, list):
return [strip_docs(v) for v in node]
return node
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
repo = find_repo_root()
base_version, version, commit, dirty = compute_version_info(repo)
prev = load_previous_manifest(repo)
first_run = prev is None
classes = collect_classes()
functions = collect_functions()
module_attrs = collect_module_attributes()
objects_out, functions_out, attrs_out = build_tree(
classes, functions, module_attrs, prev, first_run, base_version, version, commit)
full = {
"schema": SCHEMA_VERSION,
"version": version,
"commit": commit,
"dirty": dirty,
"seeded": first_run,
"objects": objects_out,
"functions": functions_out,
"module_attributes": attrs_out,
}
manifest = strip_docs(full)
api_dir = os.path.join(repo, "api")
gen_dir = os.path.join(repo, "docs", "generated")
os.makedirs(api_dir, exist_ok=True)
os.makedirs(gen_dir, exist_ok=True)
manifest_path = os.path.join(api_dir, "manifest.json")
full_path = os.path.join(gen_dir, "api_full.json")
with open(manifest_path, "w") as f:
f.write(json.dumps(manifest, sort_keys=True, separators=(",", ":")))
f.write("\n")
with open(full_path, "w") as f:
f.write(json.dumps(full, sort_keys=True, indent=2))
f.write("\n")
n_obj = len(objects_out)
n_fn = len(functions_out)
n_mem = sum(len(o["members"]) for o in objects_out.values())
print("manifest: %s" % manifest_path)
print("full: %s" % full_path)
print("version=%s commit=%s dirty=%s seeded=%s" % (version, commit, dirty, first_run))
print("objects=%d members=%d functions=%d module_attributes=%d"
% (n_obj, n_mem, n_fn, len(attrs_out)))
if __name__ == "__main__":
main()
sys.exit(0)