Add support for extra custom node path.

Clean up tests

Add tests
This commit is contained in:
Lin Ma 2025-08-30 19:56:59 -07:00
parent a86aaa4301
commit a718339c34
6 changed files with 376 additions and 1 deletions

View File

@ -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

View File

@ -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

View File

@ -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())

View File

@ -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)

View File

@ -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]

View File

@ -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()