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:
parent
ce15469ffc
commit
464af6a549
9 changed files with 464 additions and 120 deletions
File diff suppressed because one or more lines are too long
|
|
@ -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,
|
||||
|
|
|
|||
107
tests/regression/issue_356_module_attrs_discoverable_test.py
Normal file
107
tests/regression/issue_356_module_attrs_discoverable_test.py
Normal 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)
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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__":
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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__":
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue