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
This commit is contained in:
John McCardle 2026-07-13 23:47:04 -04:00
commit 464af6a549
9 changed files with 464 additions and 120 deletions

File diff suppressed because one or more lines are too long

View file

@ -127,108 +127,175 @@ static PyObject* mcrfpy_sync_storage(PyObject* self, PyObject* args)
Py_RETURN_NONE;
}
// #151: Module-level __getattr__ for dynamic properties (current_scene, scenes)
static PyObject* mcrfpy_module_getattr(PyObject* self, PyObject* args)
// #151/#356: the module's dynamic attributes, as real descriptors on the module type.
//
// These were originally a PEP-562 `__getattr__` plus a string-matching `tp_setattro`.
// That worked for getattr/setattr but left them invisible to `dir(mcrfpy)`, and every
// doc/stub/manifest generator discovers module symbols via dir() -- so the single most
// common idiom in the engine (`mcrfpy.current_scene = ...`) appeared in no generated
// documentation at all. Descriptors are introspectable and carry their own docstrings.
static PyObject* mcrfpy_get_current_scene(PyObject* self, void* closure)
{
const char* name;
if (!PyArg_ParseTuple(args, "s", &name)) {
return McRFPy_API::api_get_current_scene();
}
static int mcrfpy_set_current_scene(PyObject* self, PyObject* value, void* closure)
{
if (!value) {
PyErr_SetString(PyExc_AttributeError, "cannot delete 'current_scene'");
return -1;
}
return McRFPy_API::api_set_current_scene(value);
}
static PyObject* mcrfpy_get_scenes(PyObject* self, void* closure)
{
return McRFPy_API::api_get_scenes();
}
static PyObject* mcrfpy_get_timers(PyObject* self, void* closure)
{
return McRFPy_API::api_get_timers();
}
static PyObject* mcrfpy_get_animations(PyObject* self, void* closure)
{
return McRFPy_API::api_get_animations();
}
static PyObject* mcrfpy_get_default_transition(PyObject* self, void* closure)
{
return PyTransition::to_python(PyTransition::default_transition);
}
static int mcrfpy_set_default_transition(PyObject* self, PyObject* value, void* closure)
{
if (!value) {
PyErr_SetString(PyExc_AttributeError, "cannot delete 'default_transition'");
return -1;
}
TransitionType trans;
if (!PyTransition::from_arg(value, &trans, nullptr)) {
return -1;
}
PyTransition::default_transition = trans;
return 0;
}
static PyObject* mcrfpy_get_default_transition_duration(PyObject* self, void* closure)
{
return PyFloat_FromDouble(PyTransition::default_duration);
}
static int mcrfpy_set_default_transition_duration(PyObject* self, PyObject* value, void* closure)
{
if (!value) {
PyErr_SetString(PyExc_AttributeError, "cannot delete 'default_transition_duration'");
return -1;
}
double duration;
if (PyFloat_Check(value)) {
duration = PyFloat_AsDouble(value);
} else if (PyLong_Check(value)) {
duration = PyLong_AsDouble(value);
} else {
PyErr_SetString(PyExc_TypeError, "default_transition_duration must be a number");
return -1;
}
if (duration < 0.0) {
PyErr_SetString(PyExc_ValueError, "default_transition_duration must be non-negative");
return -1;
}
PyTransition::default_duration = static_cast<float>(duration);
return 0;
}
static PyObject* mcrfpy_get_save_dir(PyObject* self, void* closure)
{
#ifdef __EMSCRIPTEN__
return PyUnicode_FromString("/save");
#else
return PyUnicode_FromString("save");
#endif
}
static PyGetSetDef mcrfpy_module_getset[] = {
{"current_scene", mcrfpy_get_current_scene, mcrfpy_set_current_scene,
MCRF_PROPERTY(current_scene,
"The active scene (Scene). Assign a Scene object, or a scene name as a string, "
"to switch scenes."
), NULL},
{"scenes", mcrfpy_get_scenes, NULL,
MCRF_PROPERTY(scenes,
"Tuple of all registered Scene objects (tuple, read-only)."
), NULL},
{"timers", mcrfpy_get_timers, NULL,
MCRF_PROPERTY(timers,
"Tuple of all active Timer objects (tuple, read-only)."
), NULL},
{"animations", mcrfpy_get_animations, NULL,
MCRF_PROPERTY(animations,
"Tuple of all currently running Animation objects (tuple, read-only)."
), NULL},
{"default_transition", mcrfpy_get_default_transition, mcrfpy_set_default_transition,
MCRF_PROPERTY(default_transition,
"Default transition (Transition) applied when switching scenes without an "
"explicit transition."
), NULL},
{"default_transition_duration", mcrfpy_get_default_transition_duration,
mcrfpy_set_default_transition_duration,
MCRF_PROPERTY(default_transition_duration,
"Default scene-transition duration in seconds (float). Must be non-negative."
), NULL},
{"save_dir", mcrfpy_get_save_dir, NULL,
MCRF_PROPERTY(save_dir,
"Directory used for persistent save data (str, read-only). '/save' under Emscripten."
), NULL},
{NULL} // Sentinel
};
// #356: PyModule_Type.__dir__ returns only the module __dict__ keys, so descriptors
// living on the module *type* would still be invisible to dir(). Override it to union
// the two -- this is what actually makes the generators (and tab-completion) see them.
static PyObject* mcrfpy_module_dir(PyObject* self, PyObject* Py_UNUSED(ignored))
{
PyObject* dict = PyModule_GetDict(self); // borrowed
if (!dict) return NULL;
PyObject* names = PyDict_Keys(dict);
if (!names) return NULL;
for (PyGetSetDef* gs = mcrfpy_module_getset; gs->name != NULL; gs++) {
PyObject* name = PyUnicode_FromString(gs->name);
if (!name || PyList_Append(names, name) < 0) {
Py_XDECREF(name);
Py_DECREF(names);
return NULL;
}
Py_DECREF(name);
}
if (PyList_Sort(names) < 0) {
Py_DECREF(names);
return NULL;
}
if (strcmp(name, "current_scene") == 0) {
return McRFPy_API::api_get_current_scene();
}
if (strcmp(name, "scenes") == 0) {
return McRFPy_API::api_get_scenes();
}
if (strcmp(name, "timers") == 0) {
return McRFPy_API::api_get_timers();
}
if (strcmp(name, "animations") == 0) {
return McRFPy_API::api_get_animations();
}
if (strcmp(name, "default_transition") == 0) {
return PyTransition::to_python(PyTransition::default_transition);
}
if (strcmp(name, "default_transition_duration") == 0) {
return PyFloat_FromDouble(PyTransition::default_duration);
}
if (strcmp(name, "save_dir") == 0) {
#ifdef __EMSCRIPTEN__
return PyUnicode_FromString("/save");
#else
return PyUnicode_FromString("save");
#endif
}
// Attribute not found - raise AttributeError
PyErr_Format(PyExc_AttributeError, "module 'mcrfpy' has no attribute '%s'", name);
return NULL;
return names;
}
// #151: Custom module type with __setattr__ support for current_scene
static int mcrfpy_module_setattro(PyObject* self, PyObject* name, PyObject* value)
{
const char* name_str = PyUnicode_AsUTF8(name);
if (!name_str) return -1;
static PyMethodDef mcrfpy_module_type_methods[] = {
{"__dir__", mcrfpy_module_dir, METH_NOARGS,
MCRF_METHOD(mcrfpy, __dir__,
MCRF_SIG("()", "list"),
MCRF_DESC("Names of the module's attributes, including the dynamic properties "
"(current_scene, scenes, timers, ...) that live as descriptors on the "
"module type rather than in the module dict."),
MCRF_RETURNS("list: sorted attribute names")
)},
{NULL} // Sentinel
};
if (strcmp(name_str, "current_scene") == 0) {
return McRFPy_API::api_set_current_scene(value);
}
if (strcmp(name_str, "scenes") == 0) {
PyErr_SetString(PyExc_AttributeError, "'scenes' is read-only");
return -1;
}
if (strcmp(name_str, "timers") == 0) {
PyErr_SetString(PyExc_AttributeError, "'timers' is read-only");
return -1;
}
if (strcmp(name_str, "animations") == 0) {
PyErr_SetString(PyExc_AttributeError, "'animations' is read-only");
return -1;
}
if (strcmp(name_str, "default_transition") == 0) {
TransitionType trans;
if (!PyTransition::from_arg(value, &trans, nullptr)) {
return -1;
}
PyTransition::default_transition = trans;
return 0;
}
if (strcmp(name_str, "default_transition_duration") == 0) {
double duration;
if (PyFloat_Check(value)) {
duration = PyFloat_AsDouble(value);
} else if (PyLong_Check(value)) {
duration = PyLong_AsDouble(value);
} else {
PyErr_SetString(PyExc_TypeError, "default_transition_duration must be a number");
return -1;
}
if (duration < 0.0) {
PyErr_SetString(PyExc_ValueError, "default_transition_duration must be non-negative");
return -1;
}
PyTransition::default_duration = static_cast<float>(duration);
return 0;
}
// For other attributes, use default module setattr
return PyObject_GenericSetAttr(self, name, value);
}
// Custom module type that inherits from PyModule_Type but has our __setattr__
// Custom module type: carries the dynamic properties as descriptors (#151, #356)
static PyTypeObject McRFPyModuleType = {
.ob_base = {.ob_base = {.ob_refcnt = 1, .ob_type = NULL}, .ob_size = 0},
.tp_name = "mcrfpy.module",
@ -246,11 +313,15 @@ static PyTypeObject McRFPyModuleType = {
.tp_hash = NULL,
.tp_call = NULL,
.tp_str = NULL,
// tp_getattro/tp_setattro inherited from PyModule_Type: generic attribute access
// finds the tp_getset data descriptors below before touching the module dict.
.tp_getattro = NULL,
.tp_setattro = mcrfpy_module_setattro,
.tp_setattro = NULL,
.tp_as_buffer = NULL,
.tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE,
.tp_doc = "McRogueFace module with property support",
.tp_methods = mcrfpy_module_type_methods,
.tp_getset = mcrfpy_module_getset,
};
static PyMethodDef mcrfpyMethods[] = {
@ -349,12 +420,8 @@ static PyMethodDef mcrfpyMethods[] = {
MCRF_NOTE("Messages appear in the 'logs' array of each frame in the output JSON.")
)},
// #151: Module-level attribute access for current_scene and scenes
{"__getattr__", mcrfpy_module_getattr, METH_VARARGS,
MCRF_METHOD(mcrfpy, __getattr__,
MCRF_SIG("(name: str)", "object"),
MCRF_DESC("Module-level attribute access for dynamic properties (current_scene, scenes).")
)},
// #356: the module's dynamic attributes (current_scene, scenes, timers, ...) are
// real descriptors on McRFPyModuleType now, not a PEP-562 __getattr__ shim.
// #219: Thread synchronization
{"lock", PyLock::lock, METH_NOARGS,

View file

@ -0,0 +1,107 @@
"""
Regression test for issue #356.
mcrfpy's dynamic module attributes (current_scene, scenes, timers, ...) used to be a
PEP-562 module __getattr__. getattr/hasattr worked, but they were absent from
dir(mcrfpy) -- and every doc/stub/manifest generator discovers module symbols via
dir(), so the single most common idiom in the engine appeared in no generated docs.
They are now real getset descriptors on type(mcrfpy), plus a __dir__ override
(CPython's module.__dir__ reports only __dict__ keys, so descriptors alone are not
enough -- that is the trap this test exists to catch).
"""
import mcrfpy
import sys
import types
DYNAMIC = [
"current_scene",
"scenes",
"timers",
"animations",
"default_transition",
"default_transition_duration",
"save_dir",
]
READONLY = ["scenes", "timers", "animations", "save_dir"]
failures = []
def check(label, condition):
if condition:
print(f" PASS: {label}")
else:
print(f" FAIL: {label}")
failures.append(label)
print("1. All dynamic attributes are in dir(mcrfpy)")
names = dir(mcrfpy)
for attr in DYNAMIC:
check(f"'{attr}' in dir(mcrfpy)", attr in names)
print("2. dir() still contains normal module symbols")
check("classes still listed", "Frame" in names and "Scene" in names)
check("functions still listed", "find" in names and "step" in names)
print("3. They are real descriptors carrying docstrings")
modtype = type(mcrfpy)
for attr in DYNAMIC:
descr = getattr(modtype, attr, None)
check(f"'{attr}' is a getset descriptor",
isinstance(descr, types.GetSetDescriptorType))
check(f"'{attr}' has a docstring", bool(descr and descr.__doc__))
print("4. Access still works (behavior preserved)")
scene = mcrfpy.Scene("issue356")
mcrfpy.current_scene = scene
check("current_scene round-trips", mcrfpy.current_scene is not None)
check("scenes is a tuple", isinstance(mcrfpy.scenes, tuple))
check("timers is a tuple", isinstance(mcrfpy.timers, tuple))
check("animations is a tuple", isinstance(mcrfpy.animations, tuple))
check("save_dir is a str", isinstance(mcrfpy.save_dir, str))
check("default_transition_duration is a float",
isinstance(mcrfpy.default_transition_duration, float))
mcrfpy.default_transition_duration = 0.25
check("default_transition_duration is writable",
abs(mcrfpy.default_transition_duration - 0.25) < 1e-6)
print("5. Read-only attributes still reject assignment")
for attr in READONLY:
try:
setattr(mcrfpy, attr, ())
check(f"assigning '{attr}' raises AttributeError", False)
except AttributeError:
check(f"assigning '{attr}' raises AttributeError", True)
print("6. Validation preserved on the writable ones")
try:
mcrfpy.default_transition_duration = -1.0
check("negative duration raises ValueError", False)
except ValueError:
check("negative duration raises ValueError", True)
try:
mcrfpy.default_transition_duration = "not a number"
check("non-numeric duration raises TypeError", False)
except TypeError:
check("non-numeric duration raises TypeError", True)
print("7. Unknown attributes still raise AttributeError")
try:
mcrfpy.definitely_not_an_attribute
check("unknown attribute raises AttributeError", False)
except AttributeError:
check("unknown attribute raises AttributeError", True)
print()
if failures:
print(f"FAIL - {len(failures)} check(s) failed:")
for f in failures:
print(f" - {f}")
sys.exit(1)
print("PASS")
sys.exit(0)

View file

@ -24,6 +24,15 @@ const keyboard: Keyboard
const mouse: Mouse
const window: Window
=== MODULE DYNAMIC ATTRIBUTES (7) ===
attr animations (ro) :: Tuple of all currently running Animation objects (tuple, read-only).
attr current_scene (rw) :: The active scene (Scene). Assign a Scene object, or a scene name as a string, to switch scenes.
attr default_transition (rw) :: Default transition (Transition) applied when switching scenes without an explicit transition.
attr default_transition_duration (rw) :: Default scene-transition duration in seconds (float). Must be non-negative.
attr save_dir (ro) :: Directory used for persistent save data (str, read-only). '/save' under Emscripten.
attr scenes (ro) :: Tuple of all registered Scene objects (tuple, read-only).
attr timers (ro) :: Tuple of all active Timer objects (tuple, read-only).
=== SUBMODULES (1) ===
submodule automation

View file

@ -270,7 +270,16 @@ def build_snapshot():
out.append("# Regenerate intentionally: MCRF_UPDATE_API_SNAPSHOT=1")
out.append("# Singleton/constant VALUES are intentionally NOT captured.")
names = [n for n in dir(mcrfpy) if not n.startswith("_")]
# #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)
@ -298,6 +307,16 @@ def build_snapshot():
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):

View file

@ -210,6 +210,17 @@ def compute_delta(a, b, object_pages):
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,
@ -220,13 +231,18 @@ def compute_delta(a, b, object_pages):
"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["functions_signature_changed"] or d["functions_doc_changed"]
or d["module_attributes_added"] or d["module_attributes_removed"]
or d["module_attributes_changed"])
# ---------------------------------------------------------------------------
@ -308,6 +324,22 @@ def render_md(d):
% (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)

View file

@ -215,11 +215,35 @@ def collect_classes():
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("_"):
if name.startswith("_") or name in dynamic:
continue
obj = getattr(mcrfpy, name)
if isinstance(obj, type):
@ -266,11 +290,12 @@ def assign_lifecycle(cur, prev, new_since, version, commit, has_signature):
return lifecycle
def build_tree(classes, functions, prev, first_run, base_version, version, commit):
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():
@ -303,7 +328,14 @@ def build_tree(classes, functions, prev, first_run, base_version, version, commi
fentry, prev_fn, new_since, version, commit, has_signature=True)
functions_out[fname] = fentry
return objects_out, functions_out
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):
@ -328,9 +360,10 @@ def main():
classes = collect_classes()
functions = collect_functions()
module_attrs = collect_module_attributes()
objects_out, functions_out = build_tree(
classes, functions, prev, first_run, base_version, version, commit)
objects_out, functions_out, attrs_out = build_tree(
classes, functions, module_attrs, prev, first_run, base_version, version, commit)
full = {
"schema": SCHEMA_VERSION,
@ -340,6 +373,7 @@ def main():
"seeded": first_run,
"objects": objects_out,
"functions": functions_out,
"module_attributes": attrs_out,
}
manifest = strip_docs(full)
@ -366,7 +400,8 @@ def main():
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" % (n_obj, n_mem, n_fn))
print("objects=%d members=%d functions=%d module_attributes=%d"
% (n_obj, n_mem, n_fn, len(attrs_out)))
if __name__ == "__main__":

View file

@ -251,12 +251,34 @@ def get_constants():
}
return constants
def get_module_attributes():
"""#356: dynamic module attributes (current_scene, scenes, timers, ...).
They are getset descriptors on type(mcrfpy) rather than module-dict entries, so
the dir()+getattr() sweep above cannot describe them: getattr returns the live
*value* (None, at generation time), not the declared type. Read the descriptor.
"""
import types as _types
attrs = {}
for name, descr in vars(type(mcrfpy)).items():
if name.startswith('_'):
continue
if isinstance(descr, _types.GetSetDescriptorType):
doc = descr.__doc__ or ""
attrs[name] = {
"name": name,
"doc": doc,
"readonly": "read-only" in doc.lower(),
}
return attrs
def generate_html_docs():
"""Generate HTML documentation."""
functions = get_all_functions()
classes = get_all_classes()
constants = get_constants()
module_attrs = get_module_attributes()
html_content = f"""<!DOCTYPE html>
<html lang="en">
<head>
@ -385,10 +407,26 @@ def generate_html_docs():
html_content += """ </ul>
</li>
<li><a href="#module-attributes">Module Attributes</a></li>
<li><a href="#constants">Constants</a></li>
</ul>
</div>
<h2 id="module-attributes">Module Attributes</h2>
<p>Dynamic attributes of the <code>mcrfpy</code> module itself.</p>
"""
for attr_name in sorted(module_attrs.keys()):
attr_info = module_attrs[attr_name]
ro = ' <em>(read-only)</em>' if attr_info["readonly"] else ''
html_content += f"""
<div class="method-section">
<h3><code class="function-signature">mcrfpy.{attr_name}</code>{ro}</h3>
<p>{html.escape(attr_info["doc"])}</p>
</div>
"""
html_content += """
<h2 id="functions">Functions</h2>
"""
@ -511,7 +549,8 @@ def generate_markdown_docs():
functions = get_all_functions()
classes = get_all_classes()
constants = get_constants()
module_attrs = get_module_attributes()
md_content = f"""# McRogueFace API Reference
*Generated on {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}*
@ -530,8 +569,17 @@ def generate_markdown_docs():
for class_name in sorted(classes.keys()):
md_content += f" - [{class_name}](#{class_name.lower()})\n"
md_content += "- [Module Attributes](#module-attributes)\n"
md_content += "- [Constants](#constants)\n\n"
# Module attributes (#356)
md_content += "## Module Attributes\n\n"
md_content += "Dynamic attributes of the `mcrfpy` module itself.\n\n"
for attr_name in sorted(module_attrs.keys()):
attr_info = module_attrs[attr_name]
ro = " *(read-only)*" if attr_info["readonly"] else ""
md_content += f"### `mcrfpy.{attr_name}`{ro}\n\n{attr_info['doc']}\n\n"
# Functions
md_content += "## Functions\n\n"

View file

@ -440,13 +440,31 @@ 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("_"):
if name.startswith("_") or name in dynamic:
continue
attr = getattr(mcrfpy, name)
if isinstance(attr, types.ModuleType):
@ -457,11 +475,11 @@ def classify_module():
functions[name] = attr
else:
constants[name] = attr
return classes, functions, constants, submodules
return classes, functions, constants, submodules, dynamic
def main():
classes, functions, constants, submodules = classify_module()
classes, functions, constants, submodules, dynamic = classify_module()
out_lines = [HEADER]
@ -496,6 +514,14 @@ def main():
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")
@ -505,7 +531,8 @@ def main():
print(f"Wrote stubs/mcrfpy.pyi ({len(out_text)} bytes, "
f"{len(classes)} classes, {len(functions)} functions, "
f"{len(constants)} constants, {len(submodules)} submodules)")
f"{len(constants)} constants, {len(dynamic)} dynamic attributes, "
f"{len(submodules)} submodules)")
if __name__ == "__main__":