From 21a29b8846139cfbb3692a96e6f570d3a4eee490 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Thu, 2 Jul 2026 12:39:38 +0200 Subject: [PATCH] Invalidate static extraction on deletion and mutation --- .../test_generate_popular_node_signatures.py | 99 +++++++++++++++++++ tools/generate_popular_node_signatures.py | 65 ++++++++++++ 2 files changed, 164 insertions(+) diff --git a/tests/test_generate_popular_node_signatures.py b/tests/test_generate_popular_node_signatures.py index bf848ff..35c1bb2 100644 --- a/tests/test_generate_popular_node_signatures.py +++ b/tests/test_generate_popular_node_signatures.py @@ -312,6 +312,33 @@ NODE_CLASS_MAPPINGS = { self.assertEqual({}, result["nodes"]) self.assertEqual("no_static_nodes", result["pack"]["status"]) + def test_delete_invalidates_static_env_value(self): + source = ''' +INPUTS = { + "required": { + "image": ("IMAGE",), + }, +} +del INPUTS + + +class DeletedInputEnvNode: + RETURN_TYPES = ("IMAGE",) + + @classmethod + def INPUT_TYPES(cls): + return INPUTS + + +NODE_CLASS_MAPPINGS = { + "DeletedInputEnvNode": DeletedInputEnvNode, +} +''' + result = self._extract_source(source, "deleted-input-env-pack") + + self.assertEqual({}, result["nodes"]) + self.assertEqual("no_static_nodes", result["pack"]["status"]) + def test_dynamic_return_types_reassignment_skips_node(self): source = ''' def build_outputs(): @@ -340,6 +367,54 @@ NODE_CLASS_MAPPINGS = { self.assertEqual({}, result["nodes"]) self.assertEqual("no_static_nodes", result["pack"]["status"]) + def test_delete_return_types_skips_node(self): + source = ''' +class DeletedReturnTypesNode: + RETURN_TYPES = ("IMAGE",) + del RETURN_TYPES + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + }, + } + + +NODE_CLASS_MAPPINGS = { + "DeletedReturnTypesNode": DeletedReturnTypesNode, +} +''' + result = self._extract_source(source, "deleted-return-types-pack") + + self.assertEqual({}, result["nodes"]) + self.assertEqual("no_static_nodes", result["pack"]["status"]) + + def test_mutated_return_types_skips_node(self): + source = ''' +class MutatedReturnTypesNode: + RETURN_TYPES = ["IMAGE"] + RETURN_TYPES.clear() + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + }, + } + + +NODE_CLASS_MAPPINGS = { + "MutatedReturnTypesNode": MutatedReturnTypesNode, +} +''' + result = self._extract_source(source, "mutated-return-types-pack") + + self.assertEqual({}, result["nodes"]) + self.assertEqual("no_static_nodes", result["pack"]["status"]) + def test_final_static_return_types_assignment_wins(self): source = ''' class FinalReturnTypesNode: @@ -392,6 +467,30 @@ NODE_CLASS_MAPPINGS = build_mappings() self.assertEqual({}, result["nodes"]) self.assertEqual("no_static_nodes", result["pack"]["status"]) + def test_mutated_node_class_mapping_skips_node(self): + source = ''' +class MutatedMappingNode: + RETURN_TYPES = ("IMAGE",) + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + }, + } + + +NODE_CLASS_MAPPINGS = { + "MutatedMappingNode": MutatedMappingNode, +} +NODE_CLASS_MAPPINGS.clear() +''' + result = self._extract_source(source, "mutated-mapping-pack") + + self.assertEqual({}, result["nodes"]) + self.assertEqual("no_static_nodes", result["pack"]["status"]) + def test_dynamic_display_mapping_reassignment_falls_back_to_node_type(self): source = ''' def build_displays(): diff --git a/tools/generate_popular_node_signatures.py b/tools/generate_popular_node_signatures.py index c02f1ae..6dbf6df 100644 --- a/tools/generate_popular_node_signatures.py +++ b/tools/generate_popular_node_signatures.py @@ -18,6 +18,21 @@ class UnsupportedStaticExpression(Exception): _MISSING = object() _INVALID = object() +_MUTATING_METHODS = { + "add", + "append", + "clear", + "discard", + "extend", + "insert", + "pop", + "popitem", + "remove", + "reverse", + "setdefault", + "sort", + "update", +} def _literal(node, env): @@ -67,6 +82,26 @@ def _assignment_target_names(stmt): return set() +def _delete_target_names(stmt): + if not isinstance(stmt, ast.Delete): + return set() + names = set() + for target in stmt.targets: + names.update(_target_names(target)) + return names + + +def _mutating_call_target_names(stmt): + if not isinstance(stmt, ast.Expr): + return set() + call = stmt.value + if not isinstance(call, ast.Call) or not isinstance(call.func, ast.Attribute): + return set() + if call.func.attr not in _MUTATING_METHODS: + return set() + return _target_names(call.func.value) + + def _assigned_names_in_control_flow(stmt): names = set() @@ -89,6 +124,12 @@ def _assigned_names_in_control_flow(stmt): def visit_AugAssign(self, node): names.update(_assignment_target_names(node)) + def visit_Delete(self, node): + names.update(_delete_target_names(node)) + + def visit_Expr(self, node): + names.update(_mutating_call_target_names(node)) + def visit_For(self, node): names.update(_assignment_target_names(node)) self.generic_visit(node) @@ -134,6 +175,14 @@ def _collect_module_env(tree): for name in _assignment_target_names(stmt): env.pop(name, None) continue + if isinstance(stmt, ast.Delete): + for name in _delete_target_names(stmt): + env.pop(name, None) + continue + if isinstance(stmt, ast.Expr): + for name in _mutating_call_target_names(stmt): + env.pop(name, None) + continue if isinstance(stmt, (ast.If, ast.For, ast.AsyncFor, ast.While, ast.Try)): for name in _assigned_names_in_control_flow(stmt): env.pop(name, None) @@ -179,6 +228,14 @@ def _class_attr(cls, name, env): if isinstance(stmt.target, ast.Name) and stmt.target.id == name: value = _INVALID continue + if isinstance(stmt, ast.Delete): + if name in _delete_target_names(stmt): + value = _INVALID + continue + if isinstance(stmt, ast.Expr): + if name in _mutating_call_target_names(stmt): + value = _INVALID + continue if isinstance(stmt, (ast.If, ast.For, ast.AsyncFor, ast.While, ast.Try)): if name in _assigned_names_in_control_flow(stmt): value = _INVALID @@ -256,6 +313,14 @@ def _final_module_dict(tree, env, name, value_converter): if _name_is_assigned(stmt, name): value = _INVALID continue + if isinstance(stmt, ast.Delete): + if name in _delete_target_names(stmt): + value = _INVALID + continue + if isinstance(stmt, ast.Expr): + if name in _mutating_call_target_names(stmt): + value = _INVALID + continue if isinstance(stmt, (ast.If, ast.For, ast.AsyncFor, ast.While, ast.Try)): if name in _assigned_names_in_control_flow(stmt): value = _INVALID