diff --git a/.gitignore b/.gitignore index fc426eda4..68111d55f 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ __pycache__/ /custom_nodes/ !custom_nodes/example_node.py.example extra_model_paths.yaml +extra_paths.yaml /.vs .vscode/ .idea/ diff --git a/extra_model_paths.yaml.example b/extra_model_paths.yaml.example index 9c395c0b2..aeb7ce7fe 100644 --- a/extra_model_paths.yaml.example +++ b/extra_model_paths.yaml.example @@ -1,8 +1,16 @@ #Rename this to extra_model_paths.yaml and ComfyUI will load it +# +# DEPRECATED: extra_model_paths.yaml is superseded by extra_paths.yaml, which supports +# all path configuration (system dirs, custom_nodes, and models) in a cleaner format. +# See extra_paths.yaml.example. This file continues to work for backward compatibility. #config for comfyui #your base path should be either an existing comfy install or a central folder where you store all of your models, loras, etc. +# When base_path is set, any standard subdirectory that exists on disk is automatically +# registered — the explicit paths below are optional and only needed to override a +# category or point it to a non-standard location. + #comfyui: # base_path: path/to/comfyui/ # # You can use is_default to mark that these folders should be listed first, and used as the default dirs for eg downloads diff --git a/extra_paths.yaml.example b/extra_paths.yaml.example new file mode 100644 index 000000000..b08b00744 --- /dev/null +++ b/extra_paths.yaml.example @@ -0,0 +1,84 @@ +#Rename this to extra_paths.yaml and ComfyUI will load it +#This is the successor to extra_model_paths.yaml and supports all path configuration in one file. + +#config for comfyui +#Set base_path to your ComfyUI install root. System directories (output, input, temp, user) +#and custom_nodes are resolved relative to base_path. +# +#Model paths go under the 'models' block. If you only set models/base_path, all standard +#subdirectories that exist on disk are automatically registered — no need to list them. +#Explicit paths under models/ are optional and only needed to override a specific category +#or point it to a non-standard location. + +#comfyui: +# base_path: path/to/comfyui/ +# # System directories (relative to base_path, or absolute) +# output: output/ +# input: input/ +# temp: temp/ +# user: user/ +# # Custom nodes directory (not auto-scanned; explicit only) +# custom_nodes: custom_nodes/ +# models: +# base_path: models/ +# # You can use is_default to mark that these folders should be listed first, +# # and used as the default dirs for eg downloads +# #is_default: true +# checkpoints: checkpoints/ +# text_encoders: | +# text_encoders/ +# clip/ # legacy location still supported +# clip_vision: clip_vision/ +# configs: configs/ +# controlnet: | +# controlnet/ +# t2i_adapter/ +# diffusion_models: | +# diffusion_models/ +# unet/ +# diffusers: diffusers/ +# embeddings: embeddings/ +# frame_interpolation: frame_interpolation/ +# gligen: gligen/ +# hypernetworks: hypernetworks/ +# latent_upscale_models: latent_upscale_models/ +# loras: loras/ +# model_patches: model_patches/ +# photomaker: photomaker/ +# style_models: style_models/ +# upscale_models: upscale_models/ +# vae: vae/ +# vae_approx: vae_approx/ +# audio_encoders: audio_encoders/ +# classifiers: classifiers/ + + +#config for a1111 ui +#all you have to do is uncomment this (remove the #) and change the base_path to where yours is installed + +#a111: +# models: +# base_path: path/to/stable-diffusion-webui/ +# checkpoints: models/Stable-diffusion +# configs: models/Stable-diffusion +# vae: models/VAE +# loras: | +# models/Lora +# models/LyCORIS +# upscale_models: | +# models/ESRGAN +# models/RealESRGAN +# models/SwinIR +# embeddings: embeddings +# hypernetworks: models/hypernetworks +# controlnet: models/ControlNet + + +# For a full list of supported model category keys (style_models, vae_approx, hypernetworks, +# photomaker, model_patches, audio_encoders, classifiers, etc.) see folder_paths.py. + +#other_ui: +# models: +# base_path: path/to/ui +# checkpoints: models/checkpoints +# gligen: models/gligen diff --git a/main.py b/main.py index a6fdaf43c..1bbaed771 100644 --- a/main.py +++ b/main.py @@ -101,9 +101,21 @@ if args.enable_manager: def apply_custom_paths(): - # extra model paths - extra_model_paths_config_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "extra_model_paths.yaml") - if os.path.isfile(extra_model_paths_config_path): + install_dir = os.path.dirname(os.path.realpath(__file__)) + + # extra_paths.yaml — primary config (superset of extra_model_paths.yaml) + extra_paths_config_path = os.path.join(install_dir, "extra_paths.yaml") + extra_model_paths_config_path = os.path.join(install_dir, "extra_model_paths.yaml") + + if os.path.isfile(extra_paths_config_path): + utils.extra_config.load_extra_path_config(extra_paths_config_path, allow_system_dirs=True) + if os.path.isfile(extra_model_paths_config_path): + logging.warning( + "Both extra_paths.yaml and extra_model_paths.yaml found; " + "ignoring the deprecated extra_model_paths.yaml. " + "Please remove or migrate its entries to extra_paths.yaml." + ) + elif os.path.isfile(extra_model_paths_config_path): utils.extra_config.load_extra_path_config(extra_model_paths_config_path) if args.extra_model_paths_config: diff --git a/tests-unit/utils/extra_config_test.py b/tests-unit/utils/extra_config_test.py index eae1aa3d3..69007d753 100644 --- a/tests-unit/utils/extra_config_test.py +++ b/tests-unit/utils/extra_config_test.py @@ -301,3 +301,320 @@ def test_load_extra_path_config_no_base_path( actual_diffusion = folder_paths.folder_names_and_paths["diffusion_models"][0] assert len(actual_diffusion) == 1, "Should have one path for 'diffusion_models'." assert actual_diffusion[0] == os.path.abspath(expected_unet) + + +@patch("yaml.safe_load") +def test_load_extra_path_config_implicit_subdirs( + mock_yaml_load, clear_folder_paths, tmp_path +): + """ + When base_path is set and no explicit sub-paths are declared, any subdir + whose name matches a known category is auto-registered. + """ + # Create real subdirs that match known categories + (tmp_path / "checkpoints").mkdir() + (tmp_path / "loras").mkdir() + (tmp_path / "unknown_dir").mkdir() # not a registered category — should be ignored + + config_data = { + "comfyui": { + "base_path": str(tmp_path), + } + } + mock_yaml_load.return_value = config_data + + # Pre-populate only the categories we're testing so clear_folder_paths doesn't hide them + folder_paths.folder_names_and_paths["checkpoints"] = ([], set()) + folder_paths.folder_names_and_paths["loras"] = ([], set()) + + yaml_path = str(tmp_path / "extra_model_paths.yaml") + with open(yaml_path, "w") as f: + f.write("") # content ignored; yaml.safe_load is mocked + + load_extra_path_config(yaml_path) + + assert str(tmp_path / "checkpoints") in folder_paths.folder_names_and_paths["checkpoints"][0] + assert str(tmp_path / "loras") in folder_paths.folder_names_and_paths["loras"][0] + assert "unknown_dir" not in folder_paths.folder_names_and_paths + + +@patch("yaml.safe_load") +def test_implicit_scan_excludes_custom_nodes( + mock_yaml_load, clear_folder_paths, tmp_path +): + """custom_nodes must never be auto-registered by the implicit scan.""" + (tmp_path / "custom_nodes").mkdir() + (tmp_path / "checkpoints").mkdir() + + config_data = {"comfyui": {"base_path": str(tmp_path)}} + mock_yaml_load.return_value = config_data + + folder_paths.folder_names_and_paths["checkpoints"] = ([], set()) + folder_paths.folder_names_and_paths["custom_nodes"] = ([], set()) + + yaml_path = str(tmp_path / "extra_paths.yaml") + with open(yaml_path, "w") as f: + f.write("") + + load_extra_path_config(yaml_path) + + assert str(tmp_path / "checkpoints") in folder_paths.folder_names_and_paths["checkpoints"][0] + assert str(tmp_path / "custom_nodes") not in folder_paths.folder_names_and_paths["custom_nodes"][0], \ + "custom_nodes must not be auto-registered by the implicit scan" + + +@patch("yaml.safe_load") +def test_load_extra_path_config_explicit_overrides_implicit( + mock_yaml_load, clear_folder_paths, tmp_path +): + """ + Explicit sub-path declarations take precedence; the implicit scan must not + double-register a category that was already declared explicitly. + """ + (tmp_path / "loras").mkdir() + custom_loras = tmp_path / "my_custom_loras" + custom_loras.mkdir() + + config_data = { + "comfyui": { + "base_path": str(tmp_path), + "loras": "my_custom_loras", # explicit override + } + } + mock_yaml_load.return_value = config_data + folder_paths.folder_names_and_paths["loras"] = ([], set()) + + yaml_path = str(tmp_path / "extra_model_paths.yaml") + with open(yaml_path, "w") as f: + f.write("") + + load_extra_path_config(yaml_path) + + registered = folder_paths.folder_names_and_paths["loras"][0] + assert str(custom_loras) in registered + assert str(tmp_path / "loras") not in registered, "Implicit path must not override explicit" + + +@pytest.fixture +def save_restore_system_dirs(): + """Save and restore folder_paths system directories around a test.""" + saved = { + "output": folder_paths.get_output_directory(), + "input": folder_paths.get_input_directory(), + "temp": folder_paths.get_temp_directory(), + "user": folder_paths.get_user_directory(), + } + yield + folder_paths.set_output_directory(saved["output"]) + folder_paths.set_input_directory(saved["input"]) + folder_paths.set_temp_directory(saved["temp"]) + folder_paths.set_user_directory(saved["user"]) + + +@patch("yaml.safe_load") +def test_system_dir_keys(mock_yaml_load, save_restore_system_dirs, tmp_path): + """System directory keys (output, input, temp, user) call set_*_directory().""" + config_data = { + "comfyui": { + "base_path": str(tmp_path), + "output": "my_output/", + "input": "my_input/", + "temp": "my_temp/", + "user": "my_user/", + } + } + mock_yaml_load.return_value = config_data + + yaml_path = str(tmp_path / "extra_paths.yaml") + with open(yaml_path, "w") as f: + f.write("") + + load_extra_path_config(yaml_path, allow_system_dirs=True) + + assert folder_paths.get_output_directory() == os.path.normpath(str(tmp_path / "my_output")) + assert folder_paths.get_input_directory() == os.path.normpath(str(tmp_path / "my_input")) + assert folder_paths.get_temp_directory() == os.path.normpath(str(tmp_path / "my_temp")) + assert folder_paths.get_user_directory() == os.path.normpath(str(tmp_path / "my_user")) + + +@patch("yaml.safe_load") +def test_system_dir_keys_not_applied_for_legacy(mock_yaml_load, save_restore_system_dirs, tmp_path): + """System directory keys are ignored when allow_system_dirs=False (legacy extra_model_paths.yaml).""" + original_output = folder_paths.get_output_directory() + config_data = { + "comfyui": { + "base_path": str(tmp_path), + "output": "my_output/", + } + } + mock_yaml_load.return_value = config_data + + yaml_path = str(tmp_path / "extra_model_paths.yaml") + with open(yaml_path, "w") as f: + f.write("") + + load_extra_path_config(yaml_path) # allow_system_dirs defaults to False + + # output should be unchanged — treated as a model category, not a system dir + assert folder_paths.get_output_directory() == original_output + + +@patch("yaml.safe_load") +def test_nested_models_block(mock_yaml_load, clear_folder_paths, tmp_path): + """Nested models: block registers model paths relative to models/base_path.""" + config_data = { + "comfyui": { + "base_path": str(tmp_path), + "models": { + "base_path": "models/", + "checkpoints": "checkpoints/", + "loras": "loras/", + }, + } + } + mock_yaml_load.return_value = config_data + + folder_paths.folder_names_and_paths["checkpoints"] = ([], set()) + folder_paths.folder_names_and_paths["loras"] = ([], set()) + + yaml_path = str(tmp_path / "extra_paths.yaml") + with open(yaml_path, "w") as f: + f.write("") + + load_extra_path_config(yaml_path) + + expected_ckpt = os.path.normpath(str(tmp_path / "models" / "checkpoints")) + expected_loras = os.path.normpath(str(tmp_path / "models" / "loras")) + assert expected_ckpt in folder_paths.folder_names_and_paths["checkpoints"][0] + assert expected_loras in folder_paths.folder_names_and_paths["loras"][0] + + +@patch("yaml.safe_load") +def test_nested_models_is_default(mock_yaml_load, clear_folder_paths, tmp_path): + """is_default under models: applies to all model paths in that block.""" + config_data = { + "comfyui": { + "models": { + "base_path": str(tmp_path), + "is_default": True, + "checkpoints": "checkpoints/", + }, + } + } + mock_yaml_load.return_value = config_data + folder_paths.folder_names_and_paths["checkpoints"] = ([], set()) + + yaml_path = str(tmp_path / "extra_paths.yaml") + with open(yaml_path, "w") as f: + f.write("") + + mock_add = Mock() + with patch.object(folder_paths, "add_model_folder_path", mock_add): + load_extra_path_config(yaml_path) + + call = mock_add.call_args_list[0] + assert call.args[0] == "checkpoints" + assert call.args[2] is True, "is_default under models: must be passed as True" + + +@patch("yaml.safe_load") +def test_nested_models_multipath(mock_yaml_load, clear_folder_paths, tmp_path): + """Multi-line path values inside models: register multiple paths per category.""" + config_data = { + "comfyui": { + "models": { + "base_path": str(tmp_path), + "text_encoders": "text_encoders/\nclip/", + }, + } + } + mock_yaml_load.return_value = config_data + folder_paths.folder_names_and_paths["text_encoders"] = ([], set()) + + yaml_path = str(tmp_path / "extra_paths.yaml") + with open(yaml_path, "w") as f: + f.write("") + + load_extra_path_config(yaml_path) + + registered = folder_paths.folder_names_and_paths["text_encoders"][0] + assert os.path.normpath(str(tmp_path / "text_encoders")) in registered + assert os.path.normpath(str(tmp_path / "clip")) in registered + + +@patch("yaml.safe_load") +def test_nested_models_auto_scan(mock_yaml_load, clear_folder_paths, tmp_path): + """models: with only base_path auto-scans for known categories that exist on disk.""" + (tmp_path / "models" / "checkpoints").mkdir(parents=True) + (tmp_path / "models" / "loras").mkdir() + + config_data = { + "comfyui": { + "base_path": str(tmp_path), + "models": {"base_path": "models/"}, + } + } + mock_yaml_load.return_value = config_data + folder_paths.folder_names_and_paths["checkpoints"] = ([], set()) + folder_paths.folder_names_and_paths["loras"] = ([], set()) + + yaml_path = str(tmp_path / "extra_paths.yaml") + with open(yaml_path, "w") as f: + f.write("") + + load_extra_path_config(yaml_path) + + assert os.path.normpath(str(tmp_path / "models" / "checkpoints")) in \ + folder_paths.folder_names_and_paths["checkpoints"][0] + assert os.path.normpath(str(tmp_path / "models" / "loras")) in \ + folder_paths.folder_names_and_paths["loras"][0] + + +@patch("yaml.safe_load") +def test_explicit_custom_nodes_key(mock_yaml_load, clear_folder_paths, tmp_path): + """Explicit custom_nodes key in a block registers the path via add_model_folder_path.""" + config_data = { + "comfyui": { + "base_path": str(tmp_path), + "custom_nodes": "my_nodes/", + } + } + mock_yaml_load.return_value = config_data + folder_paths.folder_names_and_paths["custom_nodes"] = ([], set()) + + yaml_path = str(tmp_path / "extra_paths.yaml") + with open(yaml_path, "w") as f: + f.write("") + + load_extra_path_config(yaml_path) + + assert os.path.normpath(str(tmp_path / "my_nodes")) in \ + folder_paths.folder_names_and_paths["custom_nodes"][0] + + +@patch("yaml.safe_load") +def test_nested_models_inherits_block_base(mock_yaml_load, clear_folder_paths, tmp_path): + """models: block without its own base_path inherits the outer block's base_path.""" + config_data = { + "comfyui": { + "base_path": str(tmp_path), + "is_default": True, + "models": { + "checkpoints": "models/checkpoints/", + }, + } + } + mock_yaml_load.return_value = config_data + folder_paths.folder_names_and_paths["checkpoints"] = ([], set()) + + yaml_path = str(tmp_path / "extra_paths.yaml") + with open(yaml_path, "w") as f: + f.write("") + + load_extra_path_config(yaml_path) + + expected = os.path.normpath(str(tmp_path / "models" / "checkpoints")) + paths = folder_paths.folder_names_and_paths["checkpoints"][0] + assert expected in paths + # is_default inherited: path should be at index 0 + assert paths[0] == expected diff --git a/utils/extra_config.py b/utils/extra_config.py index a0fcda9e8..d14a40a35 100644 --- a/utils/extra_config.py +++ b/utils/extra_config.py @@ -1,34 +1,101 @@ +from __future__ import annotations + import os import yaml import folder_paths import logging -def load_extra_path_config(yaml_path): +_SYSTEM_DIR_KEYS = frozenset({"output", "input", "temp", "user"}) + + +def _resolve_base(raw: str, parent_base: str | None, yaml_dir: str) -> str: + """Resolve a base_path value: expand vars/user, join onto parent_base or yaml_dir if relative.""" + raw = os.path.expandvars(os.path.expanduser(raw)) + if not os.path.isabs(raw): + anchor = parent_base if parent_base else yaml_dir + raw = os.path.abspath(os.path.join(anchor, raw)) + return os.path.normpath(raw) + + +def _add_model_paths(category: str, raw_value: str, base: str | None, yaml_dir: str, is_default: bool) -> None: + """Split a (possibly multi-line) path value and register each path as a model folder.""" + for raw in str(raw_value).split("\n"): + raw = raw.strip() + if not raw: + continue + if base and not os.path.isabs(raw): + full_path = os.path.join(base, raw) + elif not os.path.isabs(raw): + full_path = os.path.abspath(os.path.join(yaml_dir, raw)) + else: + full_path = raw + normalized = os.path.normpath(full_path) + logging.info("Adding extra search path %s %s", category, normalized) + folder_paths.add_model_folder_path(category, normalized, is_default) + + +def _implicit_scan(base: str, exclude: set[str], is_default: bool) -> None: + """Auto-register base// for known model categories that exist on disk. + + custom_nodes and system directory keys are always excluded from the scan. + """ + skip = _SYSTEM_DIR_KEYS | {"custom_nodes"} | exclude + for category in folder_paths.folder_names_and_paths: + if category in skip: + continue + path = os.path.normpath(os.path.join(base, category)) + if os.path.isdir(path): + logging.info("Adding extra search path %s %s", category, path) + folder_paths.add_model_folder_path(category, path, is_default) + + +def load_extra_path_config(yaml_path: str, allow_system_dirs: bool = False) -> None: with open(yaml_path, 'r', encoding='utf-8') as stream: config = yaml.safe_load(stream) yaml_dir = os.path.dirname(os.path.abspath(yaml_path)) - for c in config: - conf = config[c] + + for _block_name, conf in config.items(): if conf is None: continue - base_path = None + + # Pop block-level meta keys (preserved for flat backward-compat style) + block_base = None if "base_path" in conf: - base_path = conf.pop("base_path") - base_path = os.path.expandvars(os.path.expanduser(base_path)) - if not os.path.isabs(base_path): - base_path = os.path.abspath(os.path.join(yaml_dir, base_path)) - is_default = False - if "is_default" in conf: - is_default = conf.pop("is_default") - for x in conf: - for y in conf[x].split("\n"): - if len(y) == 0: - continue - full_path = y - if base_path: - full_path = os.path.join(base_path, full_path) - elif not os.path.isabs(full_path): - full_path = os.path.abspath(os.path.join(yaml_dir, y)) - normalized_path = os.path.normpath(full_path) - logging.info("Adding extra search path {} {}".format(x, normalized_path)) - folder_paths.add_model_folder_path(x, normalized_path, is_default) + block_base = _resolve_base(conf.pop("base_path"), None, yaml_dir) + block_is_default = bool(conf.pop("is_default", False)) + + has_models_block = False + flat_model_keys: set[str] = set() + + for key, value in conf.items(): + if allow_system_dirs and key in _SYSTEM_DIR_KEYS: + # System directory override → set_*_directory() + path = _resolve_base(str(value).strip(), block_base, yaml_dir) + logging.info("Setting %s directory to %s", key, path) + getattr(folder_paths, f"set_{key}_directory")(path) + + elif key == "custom_nodes": + _add_model_paths("custom_nodes", value, block_base, yaml_dir, block_is_default) + + elif key == "models" and isinstance(value, dict): + # New nested style: models: { base_path, is_default, } + has_models_block = True + models_conf = dict(value) + models_base = block_base + if "base_path" in models_conf: + models_base = _resolve_base(models_conf.pop("base_path"), block_base, yaml_dir) + models_is_default = bool(models_conf.pop("is_default", block_is_default)) + explicit: set[str] = set(models_conf.keys()) + for cat, raw in models_conf.items(): + _add_model_paths(cat, raw, models_base, yaml_dir, models_is_default) + if models_base: + _implicit_scan(models_base, explicit, models_is_default) + + else: + # Flat model key — backward-compat style + _add_model_paths(key, value, block_base, yaml_dir, block_is_default) + flat_model_keys.add(key) + + # Flat-style implicit scan (only when no nested models: block) + if block_base and not has_models_block: + _implicit_scan(block_base, flat_model_keys, block_is_default)