From 3cf4a5eb5241652dd9fd93b1a77432642d1a2f78 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Thu, 2 Jul 2026 18:29:47 +0200 Subject: [PATCH] Fail closed on namespace dunders and metadata types --- .../test_generate_popular_node_signatures.py | 142 ++++++++++++++++++ tools/generate_popular_node_signatures.py | 38 ++++- 2 files changed, 173 insertions(+), 7 deletions(-) diff --git a/tests/test_generate_popular_node_signatures.py b/tests/test_generate_popular_node_signatures.py index b0f045c..8328fa2 100644 --- a/tests/test_generate_popular_node_signatures.py +++ b/tests/test_generate_popular_node_signatures.py @@ -1187,6 +1187,52 @@ NODE_CLASS_MAPPINGS = { self.assertEqual({}, result["nodes"]) self.assertEqual("no_static_nodes", result["pack"]["status"]) + def test_input_types_with_non_string_input_name_skips_node(self): + source = ''' +class NonStringInputNameNode: + RETURN_TYPES = ("IMAGE",) + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + 1: ("IMAGE",), + }, + } + + +NODE_CLASS_MAPPINGS = { + "NonStringInputNameNode": NonStringInputNameNode, +} +''' + result = self._extract_source(source, "non-string-input-name-pack") + + self.assertEqual({}, result["nodes"]) + self.assertEqual("no_static_nodes", result["pack"]["status"]) + + def test_input_types_with_non_string_input_type_skips_node(self): + source = ''' +class NonStringInputTypeNode: + RETURN_TYPES = ("IMAGE",) + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": (2,), + }, + } + + +NODE_CLASS_MAPPINGS = { + "NonStringInputTypeNode": NonStringInputTypeNode, +} +''' + result = self._extract_source(source, "non-string-input-type-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(): @@ -1876,6 +1922,53 @@ NODE_CLASS_MAPPINGS = { self.assertEqual({}, result["nodes"]) self.assertEqual("no_static_nodes", result["pack"]["status"]) + def test_non_string_return_type_entry_skips_node(self): + source = ''' +class NonStringReturnTypeNode: + RETURN_TYPES = (123,) + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + }, + } + + +NODE_CLASS_MAPPINGS = { + "NonStringReturnTypeNode": NonStringReturnTypeNode, +} +''' + result = self._extract_source(source, "non-string-return-type-pack") + + self.assertEqual({}, result["nodes"]) + self.assertEqual("no_static_nodes", result["pack"]["status"]) + + def test_non_string_return_name_entry_skips_node(self): + source = ''' +class NonStringReturnNameNode: + RETURN_TYPES = ("IMAGE",) + RETURN_NAMES = (456,) + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + }, + } + + +NODE_CLASS_MAPPINGS = { + "NonStringReturnNameNode": NonStringReturnNameNode, +} +''' + result = self._extract_source(source, "non-string-return-name-pack") + + self.assertEqual({}, result["nodes"]) + self.assertEqual("no_static_nodes", result["pack"]["status"]) + def test_missing_return_names_is_allowed(self): source = ''' class MissingReturnNamesNode: @@ -3028,6 +3121,55 @@ G.update(NODE_CLASS_MAPPINGS={}) self.assertEqual({}, result["nodes"]) self.assertEqual("no_static_nodes", result["pack"]["status"]) + def test_globals_dunder_setitem_invalidates_static_node_mapping(self): + source = ''' +class GlobalDunderSetitemNode: + RETURN_TYPES = ("IMAGE",) + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + }, + } + + +NODE_CLASS_MAPPINGS = { + "GlobalDunderSetitemNode": GlobalDunderSetitemNode, +} +globals().__setitem__("NODE_CLASS_MAPPINGS", {}) +''' + result = self._extract_source(source, "global-dunder-setitem-pack") + + self.assertEqual({}, result["nodes"]) + self.assertEqual("no_static_nodes", result["pack"]["status"]) + + def test_globals_alias_dunder_setitem_invalidates_static_node_mapping(self): + source = ''' +class GlobalAliasDunderSetitemNode: + RETURN_TYPES = ("IMAGE",) + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + }, + } + + +NODE_CLASS_MAPPINGS = { + "GlobalAliasDunderSetitemNode": GlobalAliasDunderSetitemNode, +} +ns = globals() +ns.__setitem__("NODE_CLASS_MAPPINGS", {}) +''' + result = self._extract_source(source, "global-alias-dunder-setitem-pack") + + self.assertEqual({}, result["nodes"]) + self.assertEqual("no_static_nodes", result["pack"]["status"]) + def test_arbitrary_call_invalidates_static_node_mapping(self): source = ''' class ArbitraryCallMappingNode: diff --git a/tools/generate_popular_node_signatures.py b/tools/generate_popular_node_signatures.py index 78c112a..1c6fcea 100644 --- a/tools/generate_popular_node_signatures.py +++ b/tools/generate_popular_node_signatures.py @@ -39,6 +39,7 @@ if hasattr(ast, "TryStar"): _CLASS_SIGNATURE_ATTRS = {"INPUT_TYPES", "RETURN_NAMES", "RETURN_TYPES"} _DYNAMIC_NAMESPACE_MUTATION = object() _NAMESPACE_FUNCTIONS = {"globals", "locals", "vars"} +_NAMESPACE_DUNDER_MUTATORS = {"__delitem__", "__setitem__"} def _literal(node, env, allow_mutable_env=True): @@ -215,6 +216,10 @@ def _namespace_mutating_call_target_names(node): return set() if _namespace_call_function_name(node.func.value) is None: return set() + if node.func.attr in _NAMESPACE_DUNDER_MUTATORS: + if node.args and isinstance(node.args[0], ast.Constant) and isinstance(node.args[0].value, str): + return {node.args[0].value} + return {_DYNAMIC_NAMESPACE_MUTATION} if node.func.attr not in _MUTATING_METHODS: return set() if node.func.attr != "update": @@ -879,8 +884,8 @@ def _collect_module_env(tree, class_bindings=None): def normalise_input_spec(spec): first = spec[0] if isinstance(spec, (list, tuple)) and spec else spec if isinstance(first, list): - return "COMBO" - return str(first) + return "COMBO" if all(isinstance(value, str) for value in first) else None + return first if isinstance(first, str) else None def _class_defs(tree): @@ -1545,7 +1550,16 @@ def _namespace_alias_mutation_target_names(stmt, aliases): def visit_Call(self, node): if isinstance(node.func, ast.Attribute): if isinstance(node.func.value, ast.Name) and node.func.value.id in aliases: - if node.func.attr == "update": + if node.func.attr in _NAMESPACE_DUNDER_MUTATORS: + if ( + node.args + and isinstance(node.args[0], ast.Constant) + and isinstance(node.args[0].value, str) + ): + names.add(node.args[0].value) + else: + names.add(_DYNAMIC_NAMESPACE_MUTATION) + elif node.func.attr == "update": for keyword in node.keywords: names.add(_DYNAMIC_NAMESPACE_MUTATION if keyword.arg is None else keyword.arg) if node.args or not node.keywords: @@ -1772,18 +1786,28 @@ def _signature_from_class(node_type, cls, display, pack_meta, class_env, input_e else: values = {} for name, spec in values.items(): - inputs[str(name)] = normalise_input_spec(spec) + if not isinstance(name, str): + return None + input_type = normalise_input_spec(spec) + if input_type is None: + return None + inputs[name] = input_type if section == "required": - required.append(str(name)) + required.append(name) output_names = [] if return_names is _MISSING: output_names = [] elif isinstance(return_names, (list, tuple)): - output_names = [str(name) for name in return_names] + if not all(isinstance(name, str) for name in return_names): + return None + output_names = list(return_names) else: return None + if not all(isinstance(value, str) for value in return_types): + return None + return { "type": node_type, "display": display or node_type, @@ -1791,7 +1815,7 @@ def _signature_from_class(node_type, cls, display, pack_meta, class_env, input_e "repository": pack_meta.get("repository", ""), "inputs": inputs, "required": required, - "outputs": [str(value) for value in return_types], + "outputs": list(return_types), "output_names": output_names, "confidence": "static_exact", }