refactor: centralize path utilities in core/path_utils

Extract repeated path normalization and identity logic from __init__.py,
core/resolver.py, core/downloader.py, and core/scanner.py into a shared
core/path_utils module. Also add a loading state to the local model picker
so it waits for models to load before rendering options.
This commit is contained in:
Dariusz L
2026-06-25 01:23:20 +02:00
parent 2fe2092dfc
commit 8f890f6992
6 changed files with 173 additions and 134 deletions
+10 -112
View File
@@ -1021,35 +1021,11 @@ class ModelResolverExtension:
if download_available:
def get_local_path_abs(path_value):
import os
try:
return os.path.abspath(str(path_value or ""))
except (OSError, ValueError):
return str(path_value or "")
def get_local_path_key(path_value):
import os
if not path_value:
return ""
try:
return os.path.normcase(os.path.abspath(path_value))
except (OSError, ValueError):
return os.path.normcase(str(path_value or ""))
def get_local_path_identity(path_value):
import os
if not path_value:
return ""
try:
return os.path.normcase(
os.path.realpath(os.path.abspath(path_value))
)
except (OSError, ValueError):
return get_local_path_key(path_value)
from .core.path_utils import (
get_path_abs as get_local_path_abs,
get_path_key as get_local_path_key,
get_path_identity as get_local_path_identity,
)
def get_comfy_root_path(folder_paths_module):
import os
@@ -1060,89 +1036,11 @@ class ModelResolverExtension:
except Exception:
return ""
def is_local_path_within(path_value, root_value):
import os
if not path_value or not root_value:
return False
try:
path_key = get_local_path_key(path_value)
root_key = get_local_path_key(root_value)
return os.path.commonpath([path_key, root_key]) == root_key
except Exception:
return False
def prefer_local_base_directory(
candidate,
current,
preferred_directory="",
comfy_root="",
):
if not current:
return True
if not candidate:
return False
candidate_key = get_local_path_key(candidate)
current_key = get_local_path_key(current)
preferred_key = get_local_path_key(preferred_directory)
if preferred_key:
if candidate_key == preferred_key and current_key != preferred_key:
return True
if current_key == preferred_key and candidate_key != preferred_key:
return False
if comfy_root:
candidate_is_external = not is_local_path_within(
candidate, comfy_root
)
current_is_external = not is_local_path_within(
current, comfy_root
)
if candidate_is_external != current_is_external:
return candidate_is_external
candidate_is_canonical = (
candidate_key == get_local_path_identity(candidate)
)
current_is_canonical = current_key == get_local_path_identity(
current
)
if candidate_is_canonical != current_is_canonical:
return candidate_is_canonical
return False
def dedupe_local_base_directories(
paths,
preferred_directory="",
comfy_root="",
):
import os
by_identity = {}
ordered_identities = []
for path in paths or []:
if not path or not os.path.isdir(path):
continue
path_abs = get_local_path_abs(path)
path_identity = get_local_path_identity(path_abs)
if not path_identity:
continue
current = by_identity.get(path_identity)
if not current:
by_identity[path_identity] = path_abs
ordered_identities.append(path_identity)
elif prefer_local_base_directory(
path_abs,
current,
preferred_directory,
comfy_root,
):
by_identity[path_identity] = path_abs
return [by_identity[key] for key in ordered_identities]
from .core.path_utils import (
is_path_within as is_local_path_within,
prefer_local_base_directory,
dedupe_local_base_directories,
)
def cleanup_search_progress(max_age_seconds=300):
now = time.time()
+4 -6
View File
@@ -1,4 +1,4 @@
"""
"""
Model Downloader Module
Handles downloading models from various sources with progress tracking.
@@ -23,6 +23,7 @@ from .log_system.log_funcs import (
log_exception,
)
from .resolver import normalize_sha256
from .path_utils import is_path_within, get_path_identity
try:
import folder_paths
@@ -656,13 +657,10 @@ def get_download_directory(category: str, preferred_base_directory: str = "") ->
folder_keys.append("clip")
def _normalize(path_value: str) -> str:
return os.path.normcase(os.path.abspath(path_value))
return get_path_identity(path_value)
def _is_within(path_value: str, root_value: str) -> bool:
try:
return os.path.commonpath([_normalize(path_value), _normalize(root_value)]) == _normalize(root_value)
except Exception:
return False
return is_path_within(path_value, root_value)
def _choose_preferred_path(paths: List[str], preferred_key: str = "") -> Optional[str]:
if not paths:
+116
View File
@@ -0,0 +1,116 @@
"""
Path Utilities Module
Provides helper functions for path normalization, symlink resolution,
and directory matching to be shared across all modules.
"""
import os
from typing import Any
def get_path_identity(path: str) -> str:
"""Return a stable path identity across symlinks/junctions."""
if not path:
return ""
try:
return os.path.normcase(os.path.realpath(os.path.abspath(path)))
except (OSError, ValueError):
return os.path.normcase(os.path.abspath(path))
def get_path_abs(path_value: Any) -> str:
"""Return absolute path safely, handling OSError/ValueError."""
try:
return os.path.abspath(str(path_value or ""))
except (OSError, ValueError):
return str(path_value or "")
def get_path_key(path_value: Any) -> str:
"""Return a normalized absolute path key for comparison."""
if not path_value:
return ""
try:
return os.path.normcase(os.path.abspath(str(path_value)))
except (OSError, ValueError):
return os.path.normcase(str(path_value or ""))
def is_path_within(path_value: Any, root_value: Any) -> bool:
"""Check if a path is located within a root directory."""
if not path_value or not root_value:
return False
try:
path_key = get_path_key(path_value)
root_key = get_path_key(root_value)
return os.path.commonpath([path_key, root_key]) == root_key
except Exception:
return False
def prefer_local_base_directory(
candidate: str,
current: str,
preferred_directory: str = "",
comfy_root: str = "",
) -> bool:
"""Determine if candidate directory should be preferred over current directory."""
if not current:
return True
if not candidate:
return False
candidate_key = get_path_key(candidate)
current_key = get_path_key(current)
preferred_key = get_path_key(preferred_directory)
if preferred_key:
if candidate_key == preferred_key and current_key != preferred_key:
return True
if current_key == preferred_key and candidate_key != preferred_key:
return False
if comfy_root:
candidate_is_external = not is_path_within(candidate, comfy_root)
current_is_external = not is_path_within(current, comfy_root)
if candidate_is_external != current_is_external:
return candidate_is_external
candidate_is_canonical = candidate_key == get_path_identity(candidate)
current_is_canonical = current_key == get_path_identity(current)
if candidate_is_canonical != current_is_canonical:
return candidate_is_canonical
return False
def dedupe_local_base_directories(
paths: list,
preferred_directory: str = "",
comfy_root: str = "",
) -> list:
"""Deduplicate a list of directory paths, resolving symlinks/junctions."""
by_identity = {}
ordered_identities = []
for path in paths or []:
if not path or not os.path.isdir(path):
continue
path_abs = get_path_abs(path)
path_identity = get_path_identity(path_abs)
if not path_identity:
continue
current = by_identity.get(path_identity)
if not current:
by_identity[path_identity] = path_abs
ordered_identities.append(path_identity)
elif prefer_local_base_directory(
path_abs,
current,
preferred_directory,
comfy_root,
):
by_identity[path_identity] = path_abs
return [by_identity[key] for key in ordered_identities]
+9 -10
View File
@@ -1,4 +1,4 @@
"""
"""
Core Resolver Module
Integrates all components to provide high-level API for model linking.
@@ -38,15 +38,14 @@ URL_PATTERN = re.compile(r'(https?://(?:huggingface\.co|civitai\.com)[^\s"\'<>\)
MODEL_EXTENSIONS = (".safetensors", ".ckpt", ".pt", ".pth", ".bin", ".onnx", ".gguf")
def get_path_identity(path: str) -> str:
"""Return a stable path identity across symlinks/junctions."""
if not path:
return ""
try:
return os.path.normcase(os.path.realpath(os.path.abspath(path)))
except (OSError, ValueError):
return os.path.normcase(os.path.abspath(path))
from .path_utils import (
get_path_identity,
get_path_abs,
get_path_key,
is_path_within,
prefer_local_base_directory,
dedupe_local_base_directories,
)
def strip_known_model_extension(filename: str) -> str:
+4 -2
View File
@@ -1,4 +1,4 @@
"""
"""
Directory Scanner Module
Scans configured model directories and finds available model files.
@@ -16,6 +16,8 @@ from .log_system.log_funcs import (
log_exception,
)
from .path_utils import get_path_identity
# Import folder_paths lazily - it may not be available until ComfyUI is initialized
try:
import folder_paths
@@ -45,7 +47,7 @@ _MODEL_FILES_CACHE_TTL_SECONDS = 2.0
def _path_identity(path: str) -> str:
"""Return a stable identity for dedupe across symlinks/junctions."""
return os.path.normcase(os.path.realpath(os.path.abspath(path)))
return get_path_identity(path)
def _directory_identity(path: str) -> str:
+30 -4
View File
@@ -733,6 +733,7 @@ export const missingBrowserMethods = {
const comboRefresh = container.querySelector(`#combo-refresh-${comboId}`);
const getAllModels = () => Array.isArray(this.allModels) ? this.allModels : [];
let localModelLoadToken = 0;
const normalizeModelPath = (value = '') => String(value || '')
.replace(/\//g, '\\')
.split('\\')
@@ -1003,6 +1004,15 @@ export const missingBrowserMethods = {
.join('');
return `${renderModelRows(node.models || [], selectedIdentity)}${folderHtml}`;
};
const renderLocalModelLoadingState = (message = 'Loading local models...') => {
if (!comboList) return;
comboList.innerHTML = `
<div class="mr-local-model-browser-scroll">
<div class="mr-folder-browser-empty">${this.escapeHtml(message)}</div>
</div>
`;
positionComboList();
};
const populateComboOptions = (filterText, options = {}) => {
if (!comboList) return;
const previousScrollEl = comboList.querySelector('.mr-local-model-browser-scroll');
@@ -1126,19 +1136,35 @@ export const missingBrowserMethods = {
}
positionComboList();
};
const ensureLocalModelsThenPopulate = async (filterText = '', options = {}) => {
if (Array.isArray(this.allModels) && this.allModels.length > 0) {
populateComboOptions(filterText, options);
return;
}
const token = ++localModelLoadToken;
renderLocalModelLoadingState();
try {
await this.ensureAllModelsLoaded?.();
} catch (error) {
console.warn('Model Resolver: could not load local models for picker', error);
}
if (token !== localModelLoadToken) return;
populateComboOptions(filterText, options);
};
if (comboList) {
populateComboOptions('');
ensureLocalModelsThenPopulate('');
}
if (comboInput) {
const debouncedFilter = this.debounce(() => {
populateComboOptions(comboInput.value);
ensureLocalModelsThenPopulate(comboInput.value);
}, 200);
comboInput.addEventListener('input', debouncedFilter);
comboInput.addEventListener('focus', () => {
showComboList();
populateComboOptions(comboInput.value);
ensureLocalModelsThenPopulate(comboInput.value);
});
}
@@ -1152,7 +1178,7 @@ export const missingBrowserMethods = {
return;
}
lastPriorityCategory = nextPriorityCategory;
populateComboOptions(comboInput?.value || '', { preserveScroll: true });
ensureLocalModelsThenPopulate(comboInput?.value || '', { preserveScroll: true });
};
categoryPriorityEl.addEventListener('input', refreshLocalModelPriority);
categoryPriorityEl.addEventListener('change', refreshLocalModelPriority);