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

429 lines
15 KiB
Python
Executable file

#!/usr/bin/env python3
"""
api_delta.py -- diff two McRogueFace API manifests.
Plain python3, standard library only. Loads api/manifest.json from two git
refs (or the working tree via ".") and reports what changed in the API surface.
Usage:
api_delta.py REF1 REF2 [--format md|json|gitea] [--site-dir PATH]
REF1 / REF2:
A git ref (branch, tag, commit) whose api/manifest.json is read via
`git show REF:api/manifest.json`, or "." to read the working-tree file
at api/manifest.json.
--format:
md (default) human-readable Markdown tables
json machine-readable delta structure
gitea a checklist-style issue body grouped by object ("- [ ]" items)
--site-dir PATH:
Scan a Jekyll site for pages whose YAML frontmatter carries an
`mcrf.objects` list; affected pages are attached to each object entry.
"""
import os
import re
import sys
import json
import argparse
import subprocess
# ---------------------------------------------------------------------------
# Manifest loading
# ---------------------------------------------------------------------------
def git_output(args, cwd):
proc = subprocess.run(
["git"] + args, cwd=cwd,
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
return proc.returncode, proc.stdout.decode("utf-8", "replace"), \
proc.stderr.decode("utf-8", "replace")
def repo_root():
rc, out, _ = git_output(["rev-parse", "--show-toplevel"], os.getcwd())
if rc == 0 and out.strip():
return out.strip()
return os.getcwd()
def load_manifest(ref, repo):
"""Load api/manifest.json for a ref, or from the working tree when ref == '.'."""
if ref == ".":
path = os.path.join(repo, "api", "manifest.json")
with open(path, "r") as f:
return json.load(f)
rc, out, err = git_output(["show", "%s:api/manifest.json" % ref], repo)
if rc != 0:
raise SystemExit("error: cannot read api/manifest.json at ref '%s': %s"
% (ref, err.strip()))
return json.loads(out)
# ---------------------------------------------------------------------------
# Jekyll frontmatter scan (minimal, no deps)
# ---------------------------------------------------------------------------
def frontmatter_lines(raw):
lines = raw.splitlines()
if not lines or lines[0].strip() != "---":
return None
for i in range(1, len(lines)):
if lines[i].strip() == "---":
return lines[1:i]
return None
def extract_objects_list(hay):
names = []
# inline form: objects: [A, B, "C"]
m = re.search(r"objects:\s*\[([^\]]*)\]", hay)
if m:
for part in m.group(1).split(","):
p = part.strip().strip("'\"")
if p:
names.append(p)
return names
# block form: objects:\n - A\n - B
m = re.search(r"(?m)^\s*objects:\s*$", hay)
if m:
for line in hay[m.end():].splitlines():
if line.strip() == "":
continue
lm = re.match(r"^\s*-\s*(.+?)\s*$", line)
if lm:
names.append(lm.group(1).strip().strip("'\""))
continue
# a non-list, non-blank line ends the list
break
return names
def objects_from_frontmatter(raw):
fm = frontmatter_lines(raw)
if fm is None:
return []
text = "\n".join(fm)
m = re.search(r"(?m)^mcrf:[ \t]*(.*)$", text)
if not m:
return []
inline_after = m.group(1).strip()
block = []
for line in text[m.end():].splitlines():
if line.strip() == "":
block.append(line)
continue
if re.match(r"^\s", line):
block.append(line)
else:
break
hay = inline_after + "\n" + "\n".join(block)
return extract_objects_list(hay)
def scan_site(site_dir):
"""Return {object_name: [relative page paths]}."""
mapping = {}
exts = (".md", ".markdown", ".html", ".htm")
for root, _dirs, files in os.walk(site_dir):
for fname in files:
if not fname.lower().endswith(exts):
continue
path = os.path.join(root, fname)
try:
with open(path, "r", errors="replace") as f:
raw = f.read(8192)
except Exception:
continue
objs = objects_from_frontmatter(raw)
if not objs:
continue
rel = os.path.relpath(path, site_dir)
for name in objs:
mapping.setdefault(name, set()).add(rel)
return {k: sorted(v) for k, v in mapping.items()}
# ---------------------------------------------------------------------------
# Delta computation
# ---------------------------------------------------------------------------
def member_changed(a, b):
"""Classify a member change: 'signature' or 'doc' or None."""
if a.get("kind") != b.get("kind"):
return "signature"
if a.get("signature", "") != b.get("signature", ""):
return "signature"
if a.get("doc_sha") != b.get("doc_sha"):
return "doc"
return None
def compute_delta(a, b, object_pages):
a_objs = a.get("objects", {})
b_objs = b.get("objects", {})
a_fns = a.get("functions", {})
b_fns = b.get("functions", {})
def pages_for(name):
return object_pages.get(name, []) if object_pages else []
objects_added = sorted(set(b_objs) - set(a_objs))
objects_removed = sorted(set(a_objs) - set(b_objs))
objects_changed = {}
for name in sorted(set(a_objs) & set(b_objs)):
a_mem = a_objs[name].get("members", {})
b_mem = b_objs[name].get("members", {})
added = sorted(set(b_mem) - set(a_mem))
removed = sorted(set(a_mem) - set(b_mem))
sig_changed = []
doc_changed = []
for m in sorted(set(a_mem) & set(b_mem)):
kind = member_changed(a_mem[m], b_mem[m])
if kind == "signature":
sig_changed.append(m)
elif kind == "doc":
doc_changed.append(m)
obj_doc_changed = a_objs[name].get("doc_sha") != b_objs[name].get("doc_sha")
if added or removed or sig_changed or doc_changed or obj_doc_changed:
objects_changed[name] = {
"members_added": added,
"members_removed": removed,
"members_signature_changed": sig_changed,
"members_doc_changed": doc_changed,
"object_doc_changed": obj_doc_changed,
"pages": pages_for(name),
}
functions_added = sorted(set(b_fns) - set(a_fns))
functions_removed = sorted(set(a_fns) - set(b_fns))
functions_sig_changed = []
functions_doc_changed = []
for m in sorted(set(a_fns) & set(b_fns)):
kind = member_changed(a_fns[m], b_fns[m])
if kind == "signature":
functions_sig_changed.append(m)
elif kind == "doc":
functions_doc_changed.append(m)
# #356: dynamic module attributes (current_scene, scenes, ...) are their own
# manifest section; without this they drift silently.
a_attrs = a.get("module_attributes", {})
b_attrs = b.get("module_attributes", {})
attrs_added = sorted(set(b_attrs) - set(a_attrs))
attrs_removed = sorted(set(a_attrs) - set(b_attrs))
attrs_changed = []
for m in sorted(set(a_attrs) & set(b_attrs)):
if member_changed(a_attrs[m], b_attrs[m]):
attrs_changed.append(m)
return {
"ref1": None,
"ref2": None,
"objects_added": [{"name": n, "pages": pages_for(n)} for n in objects_added],
"objects_removed": [{"name": n, "pages": pages_for(n)} for n in objects_removed],
"objects_changed": objects_changed,
"functions_added": functions_added,
"functions_removed": functions_removed,
"functions_signature_changed": functions_sig_changed,
"functions_doc_changed": functions_doc_changed,
"module_attributes_added": attrs_added,
"module_attributes_removed": attrs_removed,
"module_attributes_changed": attrs_changed,
}
def delta_is_empty(d):
return not (d["objects_added"] or d["objects_removed"] or d["objects_changed"]
or d["functions_added"] or d["functions_removed"]
or d["functions_signature_changed"] or d["functions_doc_changed"]
or d["module_attributes_added"] or d["module_attributes_removed"]
or d["module_attributes_changed"])
# ---------------------------------------------------------------------------
# Rendering
# ---------------------------------------------------------------------------
def render_json(d):
return json.dumps(d, sort_keys=True, indent=2)
def _pages_suffix(pages):
return (" (pages: %s)" % ", ".join(pages)) if pages else ""
def render_md(d):
out = []
out.append("# API delta: %s -> %s" % (d["ref1"], d["ref2"]))
out.append("")
if delta_is_empty(d):
out.append("No API changes.")
out.append("")
return "\n".join(out)
if d["objects_added"]:
out.append("## Objects added")
out.append("")
out.append("| object | pages |")
out.append("| --- | --- |")
for o in d["objects_added"]:
out.append("| `%s` | %s |" % (o["name"], ", ".join(o["pages"]) or "-"))
out.append("")
if d["objects_removed"]:
out.append("## Objects removed")
out.append("")
out.append("| object | pages |")
out.append("| --- | --- |")
for o in d["objects_removed"]:
out.append("| `%s` | %s |" % (o["name"], ", ".join(o["pages"]) or "-"))
out.append("")
if d["objects_changed"]:
out.append("## Objects changed")
out.append("")
for name in sorted(d["objects_changed"]):
info = d["objects_changed"][name]
out.append("### %s%s" % (name, _pages_suffix(info["pages"])))
out.append("")
if info.get("object_doc_changed"):
out.append("- class docstring changed")
out.append("| change | members |")
out.append("| --- | --- |")
rows = [
("added", info["members_added"]),
("removed", info["members_removed"]),
("signature changed", info["members_signature_changed"]),
("doc changed", info["members_doc_changed"]),
]
for label, members in rows:
if members:
out.append("| %s | %s |"
% (label, ", ".join("`%s`" % m for m in members)))
out.append("")
fn_rows = [
("added", d["functions_added"]),
("removed", d["functions_removed"]),
("signature changed", d["functions_signature_changed"]),
("doc changed", d["functions_doc_changed"]),
]
if any(members for _, members in fn_rows):
out.append("## Functions")
out.append("")
out.append("| change | functions |")
out.append("| --- | --- |")
for label, members in fn_rows:
if members:
out.append("| %s | %s |"
% (label, ", ".join("`%s`" % m for m in members)))
out.append("")
attr_rows = [
("added", d["module_attributes_added"]),
("removed", d["module_attributes_removed"]),
("changed", d["module_attributes_changed"]),
]
if any(members for _, members in attr_rows):
out.append("## Module attributes")
out.append("")
out.append("| change | attributes |")
out.append("| --- | --- |")
for label, members in attr_rows:
if members:
out.append("| %s | %s |"
% (label, ", ".join("`%s`" % m for m in members)))
out.append("")
return "\n".join(out)
def render_gitea(d):
out = []
out.append("## API changes: %s..%s" % (d["ref1"], d["ref2"]))
out.append("")
if delta_is_empty(d):
out.append("No API changes.")
out.append("")
return "\n".join(out)
for o in d["objects_added"]:
out.append("### %s (new object)%s" % (o["name"], _pages_suffix(o["pages"])))
out.append("- [ ] document new object `%s`" % o["name"])
out.append("")
for o in d["objects_removed"]:
out.append("### %s (removed object)%s" % (o["name"], _pages_suffix(o["pages"])))
out.append("- [ ] remove references to `%s`" % o["name"])
out.append("")
for name in sorted(d["objects_changed"]):
info = d["objects_changed"][name]
out.append("### %s%s" % (name, _pages_suffix(info["pages"])))
if info.get("object_doc_changed"):
out.append("- [ ] review class docstring change")
for m in info["members_added"]:
out.append("- [ ] member added: `%s`" % m)
for m in info["members_removed"]:
out.append("- [ ] member removed: `%s`" % m)
for m in info["members_signature_changed"]:
out.append("- [ ] signature changed: `%s`" % m)
for m in info["members_doc_changed"]:
out.append("- [ ] doc changed: `%s`" % m)
out.append("")
fn_items = (
[("added", m) for m in d["functions_added"]]
+ [("removed", m) for m in d["functions_removed"]]
+ [("signature changed", m) for m in d["functions_signature_changed"]]
+ [("doc changed", m) for m in d["functions_doc_changed"]]
)
if fn_items:
out.append("### Functions")
for label, m in fn_items:
out.append("- [ ] %s: `%s`" % (label, m))
out.append("")
return "\n".join(out)
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main(argv=None):
parser = argparse.ArgumentParser(
description="Diff two McRogueFace API manifests.")
parser.add_argument("ref1", help="first git ref, or '.' for the working tree")
parser.add_argument("ref2", help="second git ref, or '.' for the working tree")
parser.add_argument("--format", choices=["md", "json", "gitea"], default="md")
parser.add_argument("--site-dir", default=None,
help="Jekyll site dir to scan for mcrf.objects frontmatter")
args = parser.parse_args(argv)
repo = repo_root()
a = load_manifest(args.ref1, repo)
b = load_manifest(args.ref2, repo)
object_pages = scan_site(args.site_dir) if args.site_dir else None
delta = compute_delta(a, b, object_pages)
delta["ref1"] = args.ref1
delta["ref2"] = args.ref2
if args.format == "json":
print(render_json(delta))
elif args.format == "gitea":
print(render_gitea(delta))
else:
print(render_md(delta))
return 0
if __name__ == "__main__":
sys.exit(main())