McRogueFace/tools/generate_stubs_v2.py
John McCardle 30abb0b7ec fix(engine): headless step() is a full simulation frame; --exec can no longer hang
step() advanced animations and timers only. It never called updatePythonScenes(),
which lives solely in doFrame() -- so headless Scene.update(dt) overrides never
fired. That was the filed bug, but it was one of twelve omissions: the C++ scene
update never ran, scene transitions never progressed or COMPLETED, and
current_frame never advanced. Headless behaved measurably differently from a real
frame, and any game with per-frame logic in update() was untestable.

step() is now a full simulation frame: scene update, timers, Python scene update,
animations, transitions (including completion), frame-time metrics, currentFrame++.
Render and input stay out, deliberately -- rendering is orthogonal to the clock and
costs zero simulation time (see renderScene(); a screenshot draws arbitrary state
without time passing). Timers remain on simulation_time: explicit, deterministic
time is the entire point of driving a headless test with step().

This exposed the deeper bug. testTimers() reads simulation_time when headless, and
simulation_time is advanced ONLY by step() -- the doFrame run loop never touched it.
So the headless run loop spun forever on a frozen clock: any headless script that
registered a timer and did not call step() hung until killed. And a script that died
during setup registered no timers, hit the auto-exit-when-no-timers path, exited 0,
and was scored as a PASSING test.

So headless --exec now exits when its scripts finish, nonzero if a script fell off
the end without calling sys.exit(). step() is the clock; the engine cannot advance
time on its own, and pretending otherwise is what produced the hang. A genuinely
long-lived headless process (server, REPL host) opts in with the new --run-forever.

run_tests.py additionally refuses to pass any test whose output contains a
Traceback. Between the two, 82 tests that were being scored green while asserting
nothing are now correctly red; they are repaired in the following commits.

closes #350
2026-07-14 06:37:52 -04:00

540 lines
17 KiB
Python

#!/usr/bin/env python3
"""Generate .pyi type stub files for McRogueFace Python API.
Uses runtime introspection of the compiled mcrfpy module. Signatures are
extracted from the first line of each docstring when present, with a
fallback to (*args, **kwargs) when a class or callable has no signature
declared.
Run via McRogueFace itself so the mcrfpy module is importable:
./build/mcrogueface --headless --exec tools/generate_stubs_v2.py
"""
import os
import re
import sys
import types
import inspect
from pathlib import Path
try:
import mcrfpy
except ImportError:
print("Error: this script must be run under McRogueFace (needs mcrfpy)")
sys.exit(1)
# ---------- signature extraction ----------
_SIG_NAME_RE = re.compile(r"^\s*(\w+)\s*\(")
_RET_RE = re.compile(r"^\s*->\s*(.+?)\s*$")
def _parse_balanced_signature(line):
"""Parse 'name(...) -> ret' with proper paren/bracket depth tracking.
Returns (name, params_text, return_text) or None if not a signature.
Rejects multi-form signatures like 'foo(x) or foo(y)' by requiring the
content after the matched closing paren to be empty or '-> X'.
"""
m = _SIG_NAME_RE.match(line)
if not m:
return None
name = m.group(1)
i = m.end() # position right after the opening '('
depth = 1
params_start = i
while i < len(line) and depth:
c = line[i]
if c in "([{":
depth += 1
elif c in ")]}":
depth -= 1
if depth == 0:
break
i += 1
if depth != 0:
return None
params = line[params_start:i]
tail = line[i + 1:].strip()
if not tail:
return name, params, None
rm = _RET_RE.match(tail)
if not rm:
return None # trailing 'or foo(...)' or similar, bail out
return name, params, rm.group(1).strip()
def first_line(doc):
if not doc:
return ""
return doc.strip().split("\n", 1)[0].strip()
def extract_signature(name, doc):
"""Parse 'name(args) -> ret' from the first docstring line.
Returns (params_str, return_str) or (None, None) if no signature present
or if the signature is multi-form (e.g. 'foo(x) or foo(y)').
"""
line = first_line(doc)
if not line:
return None, None
parsed = _parse_balanced_signature(line)
if parsed is None:
return None, None
sig_name, params, ret = parsed
if sig_name != name:
return None, None
return params.strip(), ret
def first_description_paragraph(doc):
"""Return the first non-empty line after a signature line, if any."""
if not doc:
return ""
lines = doc.strip().split("\n")
start = 1 if lines and _parse_balanced_signature(lines[0].strip()) else 0
for line in lines[start:]:
s = line.strip()
if s:
return s
return ""
# ---------- classification ----------
def is_enum_like(cls):
"""True if this class looks like an IntEnum: int subclass with uppercase members."""
if not issubclass(cls, int):
return False
for name, value in cls.__dict__.items():
if name.isupper() and isinstance(value, cls):
return True
return False
def enum_members(cls):
"""Yield (name, int_value) pairs for enum-like classes."""
for name, value in sorted(cls.__dict__.items()):
if name.isupper() and isinstance(value, cls):
yield name, int(value)
def is_method_like(attr):
return isinstance(attr, (types.BuiltinFunctionType,
types.BuiltinMethodType,
types.MethodType,
types.FunctionType,
types.MethodDescriptorType,
types.WrapperDescriptorType,
types.ClassMethodDescriptorType)) or callable(attr) and not inspect.isclass(attr)
def is_property_descriptor(attr):
return isinstance(attr, (types.GetSetDescriptorType, types.MemberDescriptorType, property))
# ---------- emitters ----------
def indent(text, n=4):
pad = " " * n
return "\n".join(pad + ln if ln else ln for ln in text.split("\n"))
def _sanitize_params(params):
"""Rewrite param forms that are not valid Python syntax.
- Bare `...` (used in docs to mean "and more kwargs") becomes `**kwargs`.
- `**kwargs` already present is kept.
"""
if not params:
return params
# Replace a trailing ", ..." or lone "..." with **kwargs
if params.strip() == "...":
return "**kwargs"
# If "..." appears as a token, replace with **kwargs
tokens = [t.strip() for t in params.split(",")]
fixed = []
saw_kwargs = False
for tok in tokens:
if tok == "...":
if not saw_kwargs:
fixed.append("**kwargs")
saw_kwargs = True
else:
if tok.startswith("**"):
saw_kwargs = True
fixed.append(tok)
return ", ".join(fixed)
def emit_function(name, doc, is_method=False, is_static=False):
"""Emit a def line for a free function or method. Always returns a str
ending with `: ...` plus an optional one-line docstring."""
params, ret = extract_signature(name, doc)
if params is None:
if is_method:
params = "self, *args, **kwargs"
else:
params = "*args, **kwargs"
else:
params = _sanitize_params(params)
if is_method and not is_static:
params = "self" + (", " + params if params else "")
ret = ret or "Any"
decorator = ("@staticmethod\n" if is_static and is_method else "")
summary = first_description_paragraph(doc) or first_line(doc)
# If the signature itself is the only line, there's nothing useful to
# restate. Strip a summary that exactly matches the signature.
sig_line = f"{name}({params.replace('self, ', '').replace('self', '')})"
body = f'"""{escape_docstring(summary)}"""' if summary else "..."
# Never add duplicate bodies
if body == "...":
return f'{decorator}def {name}({params}) -> {ret}: ...'
return f'{decorator}def {name}({params}) -> {ret}:\n {body}\n ...'
def escape_docstring(text):
# Collapse whitespace and escape triple quotes
t = " ".join(text.split())
t = t.replace('"""', "'''")
if len(t) > 160:
t = t[:157] + "..."
return t
# Recognized type words the property parser will accept.
# Lowercase maps directly to a Python typing name. Anything else must
# look like a class name (CapitalCase).
_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):
"""Best-effort type inference from a property docstring.
Accepts the FIRST parenthesized group whose first word is either a
recognized primitive type or a CapitalCase class name. Skips groups
like "(width, height)" or "(trigger, data)" that are argument lists
rather than type declarations.
"""
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]
# Class-like name (starts with capital): accept
if re.match(r"^[A-Z][A-Za-z0-9_]*$", first):
return first
# Union-like text "int | None" etc.
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
return "read-only" in doc.lower() or "readonly" in doc.lower()
def emit_property(name, doc):
t = property_type_hint(doc)
summary = first_description_paragraph(doc) or first_line(doc)
if summary:
return f'{name}: {t} # {escape_docstring(summary)}'
return f'{name}: {t}'
# ---------- class emitter ----------
# Methods inherited from object that we should never emit
_OBJECT_ATTRS = set(dir(object))
def iter_class_members(cls):
"""Yield (name, attr) from cls __dict__ plus inherited members, skipping dunders
except __init__, and skipping plain object inheritance."""
seen = set()
# Walk mro excluding object
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 emit_class(name, cls):
"""Emit a `class Name:` block for a regular class or IntEnum."""
bases = []
if is_enum_like(cls):
bases.append("IntEnum")
header = f'class {name}({", ".join(bases)}):' if bases else f'class {name}:'
doc = cls.__doc__ or ""
summary = first_description_paragraph(doc) or first_line(doc) or f"{name} type."
body_lines = [f'"""{escape_docstring(summary)}"""']
if is_enum_like(cls):
for m_name, m_val in enum_members(cls):
body_lines.append(f"{m_name}: int")
# Enums rarely have other members we need to expose in stubs
return header + "\n" + indent("\n".join(body_lines))
# Regular class: emit __init__, methods, properties
methods = []
properties = []
seen_names = set()
def add_from(klass):
for attr_name, attr in iter_class_members(klass):
if attr_name == "__init__" or attr_name in seen_names:
continue
if is_property_descriptor(attr):
pdoc = attr.__doc__ if not isinstance(attr, property) else (attr.fget.__doc__ if attr.fget else "")
properties.append((attr_name, pdoc or ""))
seen_names.add(attr_name)
elif callable(attr):
if attr_name in _OBJECT_ATTRS:
continue
mdoc = attr.__doc__ or ""
is_static = isinstance(attr, (types.BuiltinFunctionType,
types.BuiltinMethodType)) and not hasattr(attr, "__self__")
methods.append((attr_name, mdoc, is_static))
seen_names.add(attr_name)
add_from(cls)
# Merge delegated methods/properties from any inner data class
delegate = discover_delegate(name, cls)
if delegate is not None:
add_from(delegate)
# __init__: take signature from class doc when possible
init_params, _ = extract_signature(name, doc)
if init_params is None:
init_body = "def __init__(self, *args, **kwargs) -> None: ..."
else:
init_body = f"def __init__(self, {init_params}) -> None: ..." if init_params else \
"def __init__(self) -> None: ..."
body_lines.append(init_body)
for pname, pdoc in sorted(properties):
body_lines.append(emit_property(pname, pdoc))
for mname, mdoc, is_static in sorted(methods):
# Skip dunders we don't have a good signature for
if mname.startswith("__") and mname != "__init__":
continue
body_lines.append(emit_function(mname, mdoc, is_method=True, is_static=is_static))
if len(body_lines) == 1:
body_lines.append("...")
return header + "\n" + indent("\n".join(body_lines))
# ---------- delegation discovery ----------
# Some C types expose methods on an inner "_Data" helper that dir(cls) does
# not see because __getattr__ forwards at the instance level. We probe known
# method names on a live instance to discover the delegate type and merge its
# surface into the stub.
_DELEGATION_PROBES = (
"center_camera", # Grid/GridView -> _GridData
"camera_rotation",
"add_collision_label",
"entities",
"compute_fov",
"add_layer",
"apply_threshold",
)
_DELEGATE_CLASS_FACTORIES = {
# class_name: callable returning a live instance, or None to skip
"Grid": lambda: mcrfpy.Grid(grid_size=(2, 2)),
"GridView": lambda: mcrfpy.GridView(grid=mcrfpy.Grid(grid_size=(2, 2))),
}
def discover_delegate(cls_name, cls):
"""Return a delegate type if `cls` forwards known method names to it, else None."""
factory = _DELEGATE_CLASS_FACTORIES.get(cls_name)
if factory is None:
return None
try:
inst = factory()
except Exception:
return None
for probe in _DELEGATION_PROBES:
try:
m = getattr(inst, probe, None)
except Exception:
continue
if callable(m) and hasattr(m, "__self__"):
s_type = type(m.__self__)
if s_type is not cls:
return s_type
return None
# ---------- submodule (automation) ----------
def emit_submodule(mod_name, mod):
lines = [f"class _{mod_name}_module:"]
body = [f'"""Stub for mcrfpy.{mod_name} submodule."""']
for name in sorted(dir(mod)):
if name.startswith("_"):
continue
attr = getattr(mod, name)
if callable(attr):
body.append(emit_function(name, attr.__doc__ or "", is_method=True, is_static=True))
if len(body) == 1:
body.append("...")
lines.append(indent("\n".join(body)))
lines.append(f"{mod_name}: _{mod_name}_module")
return "\n".join(lines)
# ---------- main entry ----------
HEADER = '''"""Type stubs for McRogueFace Python API.
Auto-generated by tools/generate_stubs_v2.py via runtime introspection.
Do not edit by hand -- regenerate after API changes:
make && ./tools/generate_all_docs.sh
"""
from enum import IntEnum
from typing import Any, Callable, Dict, List, Optional, Tuple, Union, overload
'''
def module_attributes():
"""#356: the module's dynamic attributes (current_scene, scenes, ...).
These are getset descriptors on type(mcrfpy), not entries in the module dict.
Introspecting the *descriptor* gives the declared type and read-only flag from
its docstring; introspecting the module's live *value* does not -- at generation
time current_scene is None, which is how it was previously typed 'NoneType'.
"""
attrs = {}
for name, descr in vars(type(mcrfpy)).items():
if name.startswith("_"):
continue
if isinstance(descr, types.GetSetDescriptorType):
attrs[name] = descr.__doc__ or ""
return attrs
def classify_module():
classes = {}
functions = {}
constants = {}
submodules = {}
dynamic = module_attributes()
for name in sorted(dir(mcrfpy)):
if name.startswith("_") or name in dynamic:
continue
attr = getattr(mcrfpy, name)
if isinstance(attr, types.ModuleType):
submodules[name] = attr
elif inspect.isclass(attr):
classes[name] = attr
elif callable(attr):
functions[name] = attr
else:
constants[name] = attr
return classes, functions, constants, submodules, dynamic
def main():
classes, functions, constants, submodules, dynamic = classify_module()
out_lines = [HEADER]
# Emit classes (enums first so forward-reference order is sensible)
enum_names = [n for n, c in classes.items() if is_enum_like(c)]
other_names = [n for n in classes if n not in enum_names]
out_lines.append("# --- Enums --------------------------------------------------------------")
for n in sorted(enum_names):
out_lines.append(emit_class(n, classes[n]))
out_lines.append("")
out_lines.append("# --- Classes ------------------------------------------------------------")
for n in sorted(other_names):
out_lines.append(emit_class(n, classes[n]))
out_lines.append("")
out_lines.append("# --- Submodules ---------------------------------------------------------")
for n in sorted(submodules):
out_lines.append(emit_submodule(n, submodules[n]))
out_lines.append("")
out_lines.append("# --- Module-level functions ---------------------------------------------")
for n in sorted(functions):
fn = functions[n]
out_lines.append(emit_function(n, fn.__doc__ or ""))
out_lines.append("")
out_lines.append("# --- Module-level constants ---------------------------------------------")
for n in sorted(constants):
v = constants[n]
t = type(v).__name__
out_lines.append(f"{n}: {t}")
out_lines.append("")
out_lines.append("# --- Module-level dynamic attributes (#356) -----------------------------")
for n in sorted(dynamic):
doc = dynamic[n]
t = property_type_hint(doc)
note = " # read-only" if property_is_readonly(doc) else ""
out_lines.append(f"{n}: {t}{note}")
out_text = "\n".join(out_lines).rstrip() + "\n"
stubs_dir = Path("stubs")
stubs_dir.mkdir(exist_ok=True)
(stubs_dir / "mcrfpy.pyi").write_text(out_text)
(stubs_dir / "py.typed").write_text("")
print(f"Wrote stubs/mcrfpy.pyi ({len(out_text)} bytes, "
f"{len(classes)} classes, {len(functions)} functions, "
f"{len(constants)} constants, {len(dynamic)} dynamic attributes, "
f"{len(submodules)} submodules)")
if __name__ == "__main__":
main()
sys.exit(0) # #350: headless --exec must declare its outcome