From a718339c344e9a37382392f93b57b22605b28e97 Mon Sep 17 00:00:00 2001 From: Lin Ma Date: Sat, 30 Aug 2025 19:56:59 -0700 Subject: [PATCH] Add support for extra custom node path. Clean up tests Add tests --- EXTRA_CUSTOM_NODE_PATHS_README.md | 120 ++++++++++++++++ extra_custom_node_paths.yaml.example | 18 +++ folder_paths.py | 21 +++ main.py | 7 +- tests-unit/test_extra_custom_node_paths.py | 152 +++++++++++++++++++++ utils/extra_custom_node_config.py | 59 ++++++++ 6 files changed, 376 insertions(+), 1 deletion(-) create mode 100644 EXTRA_CUSTOM_NODE_PATHS_README.md create mode 100644 extra_custom_node_paths.yaml.example create mode 100644 tests-unit/test_extra_custom_node_paths.py create mode 100644 utils/extra_custom_node_config.py diff --git a/EXTRA_CUSTOM_NODE_PATHS_README.md b/EXTRA_CUSTOM_NODE_PATHS_README.md new file mode 100644 index 000000000..98e676de6 --- /dev/null +++ b/EXTRA_CUSTOM_NODE_PATHS_README.md @@ -0,0 +1,120 @@ +# Extra Custom Node Paths + +This feature allows you to load custom nodes from multiple directories, similar to how extra model paths work in ComfyUI. + +## Overview + +ComfyUI now supports loading custom nodes from multiple directories through a YAML configuration file called `extra_custom_node_paths.yaml`. This is useful when you want to: + +- Organize custom nodes in different locations +- Share custom nodes between multiple ComfyUI installations +- Keep custom nodes separate from the main ComfyUI directory + +## Configuration File + +Create a file called `extra_custom_node_paths.yaml` in your ComfyUI root directory: + +```yaml +# Configuration for custom nodes +# The custom_nodes directory is always called "custom_nodes" and is located under base_path +# Each config section can only have one base_path + +# Example configuration using base_path +# This will look for custom_nodes under the specified base_path +custom_nodes_example: + base_path: /path/to/base1 + +# Another example with a different path +other_custom_nodes: + base_path: /path/to/base + +# You can add more custom node path configurations as needed +# additional_custom_nodes: +# base_path: another/base/path +``` + +### Configuration Options + +- **base_path**: The base directory path where the `custom_nodes` subdirectory is located + - Can be relative or absolute paths + - Relative paths are resolved relative to the YAML file location + - The system automatically looks for a `custom_nodes` subdirectory under each base_path +``` + +## How It Works + +1. **Automatic Loading**: ComfyUI automatically looks for `extra_custom_node_paths.yaml` in the root directory +2. **Path Resolution**: All base paths are resolved to absolute paths and validated +3. **Subdirectory Detection**: The system automatically appends `custom_nodes` to each base path +4. **Integration**: Custom node paths are added to the existing custom nodes system +5. **Compatibility**: Works seamlessly with existing custom node functionality + +## Example Use Cases + +### Shared Custom Nodes + +```yaml +# Share custom nodes between multiple ComfyUI installations +shared_nodes: + base_path: /shared/custom_nodes +``` + +### Relative Paths + +```yaml +# Use relative paths for portable configurations +portable_config: + base_path: ../shared_custom_nodes +``` + +## File Structure + +The custom node directories should follow the standard ComfyUI custom node structure: + +``` +base_path/ +└── custom_nodes/ + ├── node_name_1/ + │ ├── __init__.py + │ └── nodes.py + ├── node_name_2/ + │ ├── __init__.py + │ └── nodes.py + └── ... +``` + +## Troubleshooting + +### Path Not Found +- Ensure the directory exists and is accessible +- Check file permissions +- Verify the path is correctly formatted in the YAML file +- Make sure there's a `custom_nodes` subdirectory under the specified base_path + +### Custom Nodes Not Loading +- Check the ComfyUI console for error messages +- Verify the YAML syntax is correct +- Ensure the custom node directories contain valid Python modules + +## Technical Details + +- Custom node paths are loaded during ComfyUI startup +- Paths are validated for existence before being added +- The system maintains the order of paths (default paths first) +- Duplicate paths are automatically handled +- All paths are normalized and resolved to absolute paths +- Each config section can only specify one base_path + +## Compatibility + +This feature is compatible with: +- All existing custom node functionality +- The existing custom node loading system +- Command line arguments and configuration files +- All supported operating systems + +## See Also + +- [Extra Model Paths](../extra_model_paths.yaml) - Similar functionality for model paths +- [Custom Nodes Documentation](../custom_nodes/) - General custom node information +- [ComfyUI CLI Arguments](../comfy/cli_args.py) - Command line options diff --git a/extra_custom_node_paths.yaml.example b/extra_custom_node_paths.yaml.example new file mode 100644 index 000000000..f28257268 --- /dev/null +++ b/extra_custom_node_paths.yaml.example @@ -0,0 +1,18 @@ +# Rename this to extra_custom_node_paths.yaml and ComfyUI will load it + +# Configuration for custom nodes +# The custom_nodes directory is always called "custom_nodes" and is located under base_path +# Each config section can only have one base_path + +# Example configuration using base_path +# This will look for custom_nodes under the specified base_path +comfyui: + base_path: ../ + +# Another example with a different path +# other_custom_nodes: +# base_path: /path/to/base + +# You can add more custom node path configurations as needed +# additional_custom_nodes: +# base_path: another/base/path diff --git a/folder_paths.py b/folder_paths.py index f110d832b..0aadfcf47 100644 --- a/folder_paths.py +++ b/folder_paths.py @@ -427,3 +427,24 @@ def get_input_subfolders() -> list[str]: return sorted(folders) except FileNotFoundError: return [] + +def get_custom_nodes_directories() -> list[str]: + """Get the list of custom node directories. + + Returns: + list[str]: List of paths to custom node directories + """ + if "custom_nodes" not in folder_names_and_paths: + return [] + return list(folder_names_and_paths["custom_nodes"][0]) + + +def add_custom_node_directory(directory: str) -> None: + """Add a new custom node directory to the list of custom node directories""" + global folder_names_and_paths + if "custom_nodes" in folder_names_and_paths: + paths, _exts = folder_names_and_paths["custom_nodes"] + if directory not in paths: + paths.append(directory) + else: + folder_names_and_paths["custom_nodes"] = ([directory], set()) diff --git a/main.py b/main.py index 9b2a33011..fd3e4d761 100644 --- a/main.py +++ b/main.py @@ -8,7 +8,7 @@ import time from comfy.cli_args import args from app.logger import setup_logger import itertools -import utils.extra_config +import utils.extra_config, utils.extra_custom_node_config import logging import sys from comfy_execution.progress import get_progress_state @@ -32,6 +32,11 @@ def apply_custom_paths(): for config_path in itertools.chain(*args.extra_model_paths_config): utils.extra_config.load_extra_path_config(config_path) + #extra custom node paths + extra_custom_node_paths_config_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "extra_custom_node_paths.yaml") + if os.path.isfile(extra_custom_node_paths_config_path): + utils.extra_custom_node_config.load_extra_custom_node_path_config(extra_custom_node_paths_config_path) + # --output-directory, --input-directory, --user-directory if args.output_directory: output_dir = os.path.abspath(args.output_directory) diff --git a/tests-unit/test_extra_custom_node_paths.py b/tests-unit/test_extra_custom_node_paths.py new file mode 100644 index 000000000..e15694b97 --- /dev/null +++ b/tests-unit/test_extra_custom_node_paths.py @@ -0,0 +1,152 @@ +import os +import sys +import pytest +import yaml +from pathlib import Path +from unittest.mock import patch, mock_open, MagicMock + +# Add the project root to the Python path +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from utils.extra_custom_node_config import load_extra_custom_node_path_config, get_current_custom_node_paths +import folder_paths + +@pytest.fixture(autouse=True) +def setup_teardown(): + """Fixture to save and restore the original folder paths state.""" + # Save original state + original_paths = folder_paths.folder_names_and_paths.copy() + original_custom_nodes = folder_paths.get_custom_nodes_directories() + + # Reset custom nodes for each test + if "custom_nodes" in folder_paths.folder_names_and_paths: + folder_paths.folder_names_and_paths["custom_nodes"] = (list(original_custom_nodes), set()) + + yield # Test runs here + + # Restore original state + folder_paths.folder_names_and_paths = original_paths + + +def test_load_extra_custom_node_paths(): + """Test loading custom node paths from YAML config.""" + # Mock YAML content + yaml_content = """ + custom_nodes_config: + base_path: /custom/nodes/path + another_config: + base_path: /another/nodes/path + """ + + # Set up mocks + with patch('builtins.open', mock_open(read_data=yaml_content)) as mock_file, \ + patch('os.path.exists', return_value=True), \ + patch('os.path.isdir', return_value=True), \ + patch('os.path.abspath', side_effect=lambda x: x) as mock_abspath: + + # Call the function + load_extra_custom_node_path_config("dummy_path.yaml") + + # Verify the file was opened + mock_file.assert_called_once_with("dummy_path.yaml", 'r', encoding='utf-8') + + # Verify the paths were added + custom_paths = folder_paths.get_custom_nodes_directories() + # Normalize paths for consistent comparison + normalized_paths = [os.path.normpath(p).replace('\\', '/').lower() for p in custom_paths] + expected_paths = ["/custom/nodes/path/custom_nodes", + "/another/nodes/path/custom_nodes"] + + for path in expected_paths: + assert path.lower() in normalized_paths, f"Expected path {path} not found in {normalized_paths}" + + +def test_load_extra_custom_node_paths_with_tilde(): + """Test that paths with tilde are expanded.""" + yaml_content = """ + custom_nodes_config: + base_path: ~/custom_nodes + """ + + with patch('builtins.open', mock_open(read_data=yaml_content)) as mock_file, \ + patch('os.path.expanduser', side_effect=lambda x: x.replace('~', '/home/user')), \ + patch('os.path.exists', return_value=True), \ + patch('os.path.isdir', return_value=True), \ + patch('os.path.abspath', side_effect=lambda x: x): + + load_extra_custom_node_path_config("dummy_path.yaml") + + custom_paths = folder_paths.get_custom_nodes_directories() + # Normalize paths for consistent comparison + normalized_paths = [os.path.normpath(p).replace('\\', '/').lower() for p in custom_paths] + expected_path = "/home/user/custom_nodes/custom_nodes" + assert expected_path.lower() in normalized_paths, f"Expected path {expected_path} not found in {normalized_paths}" + + +def test_load_extra_custom_node_paths_with_env_vars(): + """Test that environment variables in paths are expanded.""" + yaml_content = """ + custom_nodes_config: + base_path: ${MY_CUSTOM_NODES_PATH}/custom_nodes + """ + + with patch('builtins.open', mock_open(read_data=yaml_content)), \ + patch.dict('os.environ', {'MY_CUSTOM_NODES_PATH': '/env/path'}), \ + patch('os.path.exists', return_value=True), \ + patch('os.path.isdir', return_value=True), \ + patch('os.path.abspath', side_effect=lambda x: x): + + load_extra_custom_node_path_config("dummy_path.yaml") + + custom_paths = folder_paths.get_custom_nodes_directories() + # Normalize paths for consistent comparison + normalized_paths = [os.path.normpath(p).replace('\\', '/').lower() for p in custom_paths] + expected_path = "/env/path/custom_nodes/custom_nodes" + assert expected_path.lower() in normalized_paths, f"Expected path {expected_path} not found in {normalized_paths}" + + +def test_nonexistent_paths_are_skipped(): + """Test that non-existent paths are skipped with a warning.""" + yaml_content = """ + custom_nodes_config: + base_path: /nonexistent/path + """ + + with patch('builtins.open', mock_open(read_data=yaml_content)), \ + patch('os.path.exists', return_value=False), \ + patch('os.path.isdir', return_value=False), \ + patch('logging.warning') as mock_warning: + + load_extra_custom_node_path_config("dummy_path.yaml") + + # Verify warning was logged + mock_warning.assert_called_once() + assert "does not exist" in mock_warning.call_args[0][0] + + # Verify path was not added + assert "/nonexistent/path/custom_nodes" not in folder_paths.get_custom_nodes_directories() + + +def test_invalid_yaml_handling(): + """Test that invalid YAML is handled gracefully.""" + with patch('builtins.open', mock_open(read_data='invalid: yaml: : :')), \ + patch('logging.error') as mock_error: + + load_extra_custom_node_path_config("invalid.yaml") + + # Verify error was logged + mock_error.assert_called_once() + assert "Failed to load extra custom node paths" in mock_error.call_args[0][0] + + +def test_empty_config(): + """Test that empty config doesn't cause errors.""" + with patch('builtins.open', mock_open(read_data='')), \ + patch('yaml.safe_load', return_value={'custom_nodes_config': {}}), \ + patch('logging.info') as mock_info: + + load_extra_custom_node_path_config("empty.yaml") + + # Verify info was logged + mock_info.assert_called_once() + assert "No custom node paths found in configuration" in mock_info.call_args[0][0] diff --git a/utils/extra_custom_node_config.py b/utils/extra_custom_node_config.py new file mode 100644 index 000000000..f223865ea --- /dev/null +++ b/utils/extra_custom_node_config.py @@ -0,0 +1,59 @@ +import os +import yaml +import folder_paths +import logging + +def load_extra_custom_node_path_config(yaml_path): + """ + Load extra custom node paths from a YAML configuration file. + Similar to load_extra_path_config but specifically for custom nodes. + Each config section can have one base_path, and custom_nodes will be looked for under that path. + """ + try: + with open(yaml_path, 'r', encoding='utf-8') as stream: + config = yaml.safe_load(stream) + except Exception as e: + logging.error(f"Failed to load extra custom node paths config from {yaml_path}: {e}") + return + + yaml_dir = os.path.dirname(os.path.abspath(yaml_path)) + + # Collect all custom node paths from the YAML configuration + all_custom_node_paths = [] + + if not config: + return + + for c in config: + conf = config[c] + if conf is None: + continue + + # Handle base_path (creates custom_nodes subdirectory) + if "base_path" in conf: + base_path = conf["base_path"] + # Process the base path + expanded_path = os.path.expandvars(os.path.expanduser(base_path)) + if not os.path.isabs(expanded_path): + expanded_path = os.path.abspath(os.path.join(yaml_dir, expanded_path)) + + # Create the custom_nodes subdirectory path + custom_nodes_path = os.path.join(expanded_path, "custom_nodes") + all_custom_node_paths.append(custom_nodes_path) + + # Add all custom node paths to the custom_nodes folder list + if all_custom_node_paths: + for custom_node_path in all_custom_node_paths: + if os.path.exists(custom_node_path): + logging.info(f"Adding extra custom node search path: {custom_node_path}") + folder_paths.add_custom_node_directory(custom_node_path) + else: + logging.warning(f"Custom node path does not exist, skipping: {custom_node_path}") + + logging.info(f"Added {len(all_custom_node_paths)} custom node directories") + else: + logging.info("No custom node paths found in configuration") + +def get_current_custom_node_paths() -> list[str]: + """Get the current list of all custom node paths for debugging purposes""" + return folder_paths.get_custom_nodes_directories()