From 2ad3cd3a09fae3b861d6f784af445d65b8893e85 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Thu, 2 Jul 2026 16:55:52 +0200 Subject: [PATCH] Invalidate dynamic namespace mutations --- .../test_generate_popular_node_signatures.py | 72 +++++++++++++++++++ tools/generate_popular_node_signatures.py | 51 ++++++++++++- 2 files changed, 121 insertions(+), 2 deletions(-) diff --git a/tests/test_generate_popular_node_signatures.py b/tests/test_generate_popular_node_signatures.py index d098d96..72f13d3 100644 --- a/tests/test_generate_popular_node_signatures.py +++ b/tests/test_generate_popular_node_signatures.py @@ -2125,6 +2125,54 @@ RET.clear() self.assertEqual({}, result["nodes"]) self.assertEqual("no_static_nodes", result["pack"]["status"]) + def test_getattr_return_types_mutation_after_mapping_skips_node(self): + source = ''' +class GetattrReturnTypesNode: + RETURN_TYPES = ["IMAGE"] + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + }, + } + + +NODE_CLASS_MAPPINGS = { + "GetattrReturnTypesNode": GetattrReturnTypesNode, +} +getattr(GetattrReturnTypesNode, "RETURN_TYPES").clear() +''' + result = self._extract_source(source, "getattr-return-types-pack") + + self.assertEqual({}, result["nodes"]) + self.assertEqual("no_static_nodes", result["pack"]["status"]) + + def test_globals_class_return_types_mutation_after_mapping_skips_node(self): + source = ''' +class GlobalsClassReturnTypesNode: + RETURN_TYPES = ["IMAGE"] + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + }, + } + + +NODE_CLASS_MAPPINGS = { + "GlobalsClassReturnTypesNode": GlobalsClassReturnTypesNode, +} +globals()["GlobalsClassReturnTypesNode"].RETURN_TYPES.clear() +''' + result = self._extract_source(source, "globals-class-return-types-pack") + + self.assertEqual({}, result["nodes"]) + self.assertEqual("no_static_nodes", result["pack"]["status"]) + def test_module_class_tuple_alias_patch_after_mapping_skips_node(self): source = ''' class TupleAliasPatchedNode: @@ -2225,6 +2273,30 @@ NODE_CLASS_MAPPINGS.clear() self.assertEqual({}, result["nodes"]) self.assertEqual("no_static_nodes", result["pack"]["status"]) + def test_globals_mutation_invalidates_static_node_mapping(self): + source = ''' +class GlobalMutatedMappingNode: + RETURN_TYPES = ("IMAGE",) + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + }, + } + + +NODE_CLASS_MAPPINGS = { + "GlobalMutatedMappingNode": GlobalMutatedMappingNode, +} +globals()["NODE_CLASS_MAPPINGS"].clear() +''' + result = self._extract_source(source, "global-mutated-mapping-pack") + + self.assertEqual({}, result["nodes"]) + self.assertEqual("no_static_nodes", result["pack"]["status"]) + def test_unpacked_alias_mutation_invalidates_static_node_mapping(self): source = ''' class UnpackedAliasMutatedMappingNode: diff --git a/tools/generate_popular_node_signatures.py b/tools/generate_popular_node_signatures.py index 80fb47e..5ebca0f 100644 --- a/tools/generate_popular_node_signatures.py +++ b/tools/generate_popular_node_signatures.py @@ -83,6 +83,20 @@ def _is_mutable_static_value(value): return isinstance(value, (dict, list, set)) +def _namespace_subscript_name(node): + if not isinstance(node, ast.Subscript): + return None + if not isinstance(node.value, ast.Call) or not isinstance(node.value.func, ast.Name): + return None + if node.value.func.id not in {"globals", "locals", "vars"}: + return None + if node.value.args or node.value.keywords: + return None + if isinstance(node.slice, ast.Constant) and isinstance(node.slice.value, str): + return node.slice.value + return None + + def _target_names(target): if isinstance(target, ast.Name): return {target.id} @@ -93,23 +107,56 @@ def _target_names(target): return names if isinstance(target, ast.Starred): return _target_names(target.value) - if isinstance(target, (ast.Attribute, ast.Subscript)): + if isinstance(target, ast.Attribute): + return _target_names(target.value) + if isinstance(target, ast.Subscript): + name = _namespace_subscript_name(target) + if name is not None: + return {name} return _target_names(target.value) return set() def _root_name(node): - while isinstance(node, (ast.Attribute, ast.Subscript)): + while True: + name = _namespace_subscript_name(node) + if name is not None: + return name + if not isinstance(node, (ast.Attribute, ast.Subscript)): + break node = node.value if isinstance(node, ast.Name): return node.id return None +def _getattr_signature_target_names(node): + if not isinstance(node, ast.Call): + return set() + if not isinstance(node.func, ast.Name) or node.func.id != "getattr": + return set() + if len(node.args) < 2: + return set() + name = _root_name(node.args[0]) + if name is None: + return set() + attr = node.args[1] + if ( + isinstance(attr, ast.Constant) + and isinstance(attr.value, str) + and attr.value not in _CLASS_SIGNATURE_ATTRS + ): + return set() + return {name} + + def _attribute_target_base_names(target): if isinstance(target, ast.Attribute): name = _root_name(target.value) return {name} if name else set() + names = _getattr_signature_target_names(target) + if names: + return names if isinstance(target, ast.Subscript): return _attribute_target_base_names(target.value) if isinstance(target, (ast.List, ast.Tuple)):