SideFX Houdini customization package
git clone https://git.lucas.co/hou-control.git
hc: restore the delay argument dropped from the pending selection action
Clicking a node in the network editor did nothing at all.
1451bf8 rewrote _PendingSelectionSyncAction to drop an editor_key it no longer
needed, and in doing so deleted the __init__ that was the only thing supplying
`delay`. nodegraphbase.PendingDelayedAction takes (editor, delay) and gives
delay no default, so every construction raised TypeError -- out of
createEventHandler, on every mousedown, mouseup and mousedoubleclick. Houdini
discards the event when the hook raises, so the click never reached it. The
selection simply never changed: clicking one node left the previous one
selected, which is what "nothing happens" looked like from the outside.
tools/check.py grows a guard for the whole class of mistake. nodegraphbase is
only importable inside Houdini's UI, so it reads $HFS's nodegraphbase.py and
this repo's nodegraphhooks.py with ast instead: for each class here that
extends one of Houdini's, it works out how many positional arguments the
constructor requires -- from the subclass __init__ if it defines one, from the
base otherwise -- and checks every call site passes at least that many.
Verified by reintroducing the bug: the suite reports one clean failure naming
the line and the arity, rather than a traceback that takes the rest with it.
Nothing else caught this. The module imports fine; the failure is at call time
on a path only a real mouse reaches. That is the same blind spot the split
handle bug sat in, where synthesised events drove code no real input could get
to. Reaching Houdini's own base classes statically is the part of it that can
be tested without a mouse.
Co-Authored-By: Claude Opus 5 <[email protected]>
python3.13libs/nodegraphhooks.py | 6 +++-
tools/check.py | 76 ++++++++++++++++++++++++++++++++++++++++
2 files changed, 81 insertions(+), 1 deletion(-)
diff --git a/python3.13libs/nodegraphhooks.py b/python3.13libs/nodegraphhooks.py
index 61607f5..dc006d5 100755
--- a/python3.13libs/nodegraphhooks.py
+++ b/python3.13libs/nodegraphhooks.py
@@ -34,7 +34,11 @@ def _queueSelectionSync(editor, pending_actions):
for action in pending_actions:
if isinstance(action, _PendingSelectionSyncAction) and action.editor == editor:
return
- pending_actions.append(_PendingSelectionSyncAction(editor))
+ # PendingDelayedAction takes (editor, delay) and gives delay no default, so
+ # it has to be passed. Dropping it raised TypeError out of
+ # createEventHandler on every mousedown and mouseup, and Houdini discards
+ # the event when the hook raises -- clicking a node did nothing at all.
+ pending_actions.append(_PendingSelectionSyncAction(editor, 0.0))
def createEventHandler(uievent, pending_actions):
diff --git a/tools/check.py b/tools/check.py
index a9ef5df..16e9124 100644
--- a/tools/check.py
+++ b/tools/check.py
@@ -397,6 +397,81 @@ def check_split_handles():
+def check_nodegraph_hooks():
+ """nodegraphhooks subclasses Houdini classes it cannot import here.
+
+ nodegraphbase is only importable inside Houdini's UI, so this reads both
+ sources with ast instead. It exists because dropping an argument was
+ invisible every other way: _PendingSelectionSyncAction lost the __init__
+ that supplied `delay`, its base requires it with no default, and the
+ resulting TypeError came out of createEventHandler on every mousedown.
+ Houdini discards the event when the hook raises, so clicking a node did
+ nothing -- with no traceback anywhere the test suite could see.
+ """
+ print("nodegraph hooks")
+ import ast
+
+ hfs = os.environ.get("HFS", "/opt/hfs")
+ base_src = Path(hfs) / "houdini" / "python3.13libs" / "nodegraphbase.py"
+ hooks_src = ROOT / "python3.13libs" / "nodegraphhooks.py"
+
+ if not base_src.is_file():
+ print(f" skip (no nodegraphbase.py at {base_src})")
+ return
+
+ def classes(tree):
+ return {n.name: n for n in ast.walk(tree) if isinstance(n, ast.ClassDef)}
+
+ def required_args(cls_node):
+ """Positional params of __init__ with no default, excluding self."""
+ for item in cls_node.body:
+ if isinstance(item, ast.FunctionDef) and item.name == "__init__":
+ args = item.args.args[1:]
+ return len(args) - len(item.args.defaults), True
+ return None, False # no __init__ of its own
+
+ base_classes = classes(ast.parse(base_src.read_text()))
+ hooks_tree = ast.parse(hooks_src.read_text())
+ hook_classes = classes(hooks_tree)
+
+ def base_of(node):
+ for b in node.bases:
+ if isinstance(b, ast.Attribute):
+ return b.attr
+ if isinstance(b, ast.Name):
+ return b.id
+ return None
+
+ def verify(name, node, base_name):
+ needed, own = required_args(node)
+ if not own:
+ needed, _ = required_args(base_classes[base_name])
+ assert needed is not None, f"cannot determine {base_name}.__init__ arity"
+
+ calls = [n for n in ast.walk(hooks_tree)
+ if isinstance(n, ast.Call) and isinstance(n.func, ast.Name)
+ and n.func.id == name]
+ assert calls, f"{name} is never instantiated"
+ for call in calls:
+ given = len(call.args) + len(call.keywords)
+ assert given >= needed, (
+ f"{name}(...) at line {call.lineno} passes {given} argument(s) "
+ f"but {base_name}.__init__ requires {needed}"
+ )
+ return f"{len(calls)} call site(s), {needed} required arg(s) from {base_name}"
+
+ subclasses = [(n, node, base_of(node)) for n, node in hook_classes.items()
+ if base_of(node) in base_classes]
+ if not subclasses:
+ print(" skip (no nodegraphbase subclasses found)")
+ return
+ for name, node, base_name in subclasses:
+ # Through check(), so a mismatch is a reported failure rather than a
+ # traceback that takes the rest of the suite with it.
+ check(f"{name} constructor arity",
+ lambda n=name, nd=node, b=base_name: verify(n, nd, b))
+
+
def check_geometry():
"""The visible-geometry walk, after HCNode was removed from under it."""
print("geometry")
@@ -573,6 +648,7 @@ def main():
check_state()
check_chrome()
check_split_handles()
+ check_nodegraph_hooks()
check_geometry()
check_node_ops()
print(f"\n{passed} passed, {failed} failed")