From 1bb97c480dc32a54d2ebe17a09619bf416381cfb Mon Sep 17 00:00:00 2001 From: RUiNtheExtinct Date: Sun, 28 Dec 2025 12:16:03 +0530 Subject: [PATCH] fix: Show custom node import failure reasons in summary When custom nodes fail to import, the summary now shows the exception type and message instead of just "(IMPORT FAILED)". Before: 0.0 seconds (IMPORT FAILED): custom_nodes/my_node After: 0.0 seconds (IMPORT FAILED: ImportError: No module named 'xyz'): custom_nodes/my_node Changes: - Add IMPORT_FAILED_REASONS dict to store failure context - Capture exception type and first line of message (max 100 chars) - Include failure reason in import summary output This helps users quickly diagnose why custom nodes failed to load without needing to scroll through the full traceback. Fixes #11454 --- nodes.py | 14 ++- tests-unit/nodes_test/__init__.py | 0 .../nodes_test/test_import_failure_reasons.py | 89 +++++++++++++++++++ 3 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 tests-unit/nodes_test/__init__.py create mode 100644 tests-unit/nodes_test/test_import_failure_reasons.py diff --git a/nodes.py b/nodes.py index 7d83ecb21..0c1b5e9e6 100644 --- a/nodes.py +++ b/nodes.py @@ -2103,6 +2103,10 @@ EXTENSION_WEB_DIRS = {} # Dictionary of successfully loaded module names and associated directories. LOADED_MODULE_DIRS = {} +# Dictionary of import failure reasons keyed by module path. +# Used to provide diagnostic information in the import summary. +IMPORT_FAILED_REASONS: dict[str, str] = {} + def get_module_name(module_path: str) -> str: """ @@ -2217,6 +2221,9 @@ async def load_custom_node(module_path: str, ignore=set(), module_parent="custom logging.warning(f"Skip {module_path} module for custom nodes due to the lack of NODE_CLASS_MAPPINGS or NODES_LIST (need one).") return False except Exception as e: + # Capture one-line failure reason for the import summary + error_msg = str(e).split('\n')[0][:100] # First line, max 100 chars + IMPORT_FAILED_REASONS[module_path] = f"{type(e).__name__}: {error_msg}" logging.warning(traceback.format_exc()) logging.warning(f"Cannot import {module_path} module for custom nodes: {e}") return False @@ -2262,7 +2269,12 @@ async def init_external_custom_nodes(): if n[2]: import_message = "" else: - import_message = " (IMPORT FAILED)" + # Include failure reason if available + reason = IMPORT_FAILED_REASONS.get(n[1], "") + if reason: + import_message = f" (IMPORT FAILED: {reason})" + else: + import_message = " (IMPORT FAILED)" logging.info("{:6.1f} seconds{}: {}".format(n[0], import_message, n[1])) logging.info("") diff --git a/tests-unit/nodes_test/__init__.py b/tests-unit/nodes_test/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests-unit/nodes_test/test_import_failure_reasons.py b/tests-unit/nodes_test/test_import_failure_reasons.py new file mode 100644 index 000000000..476fba7ea --- /dev/null +++ b/tests-unit/nodes_test/test_import_failure_reasons.py @@ -0,0 +1,89 @@ +"""Tests for custom node import failure reason reporting.""" + +import pytest +import tempfile +import os +import shutil +from unittest.mock import patch, MagicMock +import asyncio + + +class TestImportFailureReasons: + """Test that import failures include diagnostic information.""" + + def test_import_failure_reason_format(self): + """Test that failure reason is formatted correctly.""" + # Simulate the formatting logic + exception = ImportError("No module named 'missing_dep'") + error_msg = str(exception).split('\n')[0][:100] + reason = f"{type(exception).__name__}: {error_msg}" + + assert reason == "ImportError: No module named 'missing_dep'" + + def test_import_failure_reason_truncation(self): + """Test that long error messages are truncated.""" + long_msg = "a" * 200 + exception = ValueError(long_msg) + error_msg = str(exception).split('\n')[0][:100] + reason = f"{type(exception).__name__}: {error_msg}" + + # Should be truncated to 100 chars for the message part + assert len(error_msg) == 100 + assert reason.startswith("ValueError: ") + + def test_import_failure_reason_multiline(self): + """Test that only first line of error is used.""" + multi_line_msg = "First line\nSecond line\nThird line" + exception = RuntimeError(multi_line_msg) + error_msg = str(exception).split('\n')[0][:100] + reason = f"{type(exception).__name__}: {error_msg}" + + assert reason == "RuntimeError: First line" + assert "Second line" not in reason + + def test_import_failure_reason_various_exceptions(self): + """Test formatting for various exception types.""" + test_cases = [ + (ModuleNotFoundError("No module named 'foo'"), "ModuleNotFoundError: No module named 'foo'"), + (SyntaxError("invalid syntax"), "SyntaxError: invalid syntax"), + (AttributeError("'NoneType' object has no attribute 'bar'"), "AttributeError: 'NoneType' object has no attribute 'bar'"), + (FileNotFoundError("[Errno 2] No such file"), "FileNotFoundError: [Errno 2] No such file"), + ] + + for exception, expected in test_cases: + error_msg = str(exception).split('\n')[0][:100] + reason = f"{type(exception).__name__}: {error_msg}" + assert reason == expected, f"Failed for {type(exception).__name__}" + + +class TestImportSummaryOutput: + """Test the import summary output format.""" + + def test_summary_message_with_reason(self): + """Test that summary includes reason when available.""" + reason = "ImportError: No module named 'xyz'" + import_message = f" (IMPORT FAILED: {reason})" + + assert import_message == " (IMPORT FAILED: ImportError: No module named 'xyz')" + + def test_summary_message_without_reason(self): + """Test fallback when no reason is available.""" + reason = "" + if reason: + import_message = f" (IMPORT FAILED: {reason})" + else: + import_message = " (IMPORT FAILED)" + + assert import_message == " (IMPORT FAILED)" + + def test_summary_format_string(self): + """Test the full summary line format.""" + time_taken = 0.05 + import_message = " (IMPORT FAILED: ImportError: missing module)" + module_path = "/path/to/custom_nodes/my_node" + + summary_line = "{:6.1f} seconds{}: {}".format(time_taken, import_message, module_path) + + assert "0.1 seconds" in summary_line + assert "(IMPORT FAILED: ImportError: missing module)" in summary_line + assert module_path in summary_line