Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e129697151 | ||
|
|
cd60f33f6d | ||
|
|
ddccefbc70 |
+112
-11
@@ -183,18 +183,22 @@ class Ctx:
|
||||
cmd_ctx = Ctx()
|
||||
|
||||
|
||||
class NodeInstallError(Exception):
|
||||
"""Raised when a node installation fails and the caller requested failure propagation."""
|
||||
pass
|
||||
|
||||
|
||||
def install_node(node_spec_str, is_all=False, cnt_msg='', **kwargs):
|
||||
exit_on_fail = kwargs.get('exit_on_fail', False)
|
||||
print(f"install_node exit on fail:{exit_on_fail}...")
|
||||
|
||||
raise_on_fail = kwargs.get('raise_on_fail', False)
|
||||
|
||||
if core.is_valid_url(node_spec_str):
|
||||
# install via urls
|
||||
res = asyncio.run(core.gitclone_install(node_spec_str, no_deps=cmd_ctx.no_deps))
|
||||
if not res.result:
|
||||
print(res.msg)
|
||||
print(f"[bold red]ERROR: An error occurred while installing '{node_spec_str}'.[/bold red]")
|
||||
if exit_on_fail:
|
||||
sys.exit(1)
|
||||
if raise_on_fail:
|
||||
raise NodeInstallError(node_spec_str)
|
||||
else:
|
||||
print(f"{cnt_msg} [INSTALLED] {node_spec_str:50}")
|
||||
else:
|
||||
@@ -229,8 +233,8 @@ def install_node(node_spec_str, is_all=False, cnt_msg='', **kwargs):
|
||||
print("")
|
||||
else:
|
||||
print(f"[bold red]ERROR: An error occurred while installing '{node_name}'.\n{res.msg}[/bold red]")
|
||||
if exit_on_fail:
|
||||
sys.exit(1)
|
||||
if raise_on_fail:
|
||||
raise NodeInstallError(node_name)
|
||||
|
||||
|
||||
def reinstall_node(node_spec_str, is_all=False, cnt_msg=''):
|
||||
@@ -238,8 +242,14 @@ def reinstall_node(node_spec_str, is_all=False, cnt_msg=''):
|
||||
|
||||
node_name, version_spec, _ = node_spec
|
||||
|
||||
# Best-effort uninstall via normal path
|
||||
unified_manager.unified_uninstall(node_name, version_spec == 'unknown')
|
||||
install_node(node_name, is_all=is_all, cnt_msg=cnt_msg)
|
||||
|
||||
# Fallback: purge all state and directories regardless of categorization
|
||||
# Handles categorization mismatch between cm_cli invocations (e.g. unknown→nightly)
|
||||
unified_manager.purge_node_state(node_name)
|
||||
|
||||
install_node(node_name, is_all=is_all, cnt_msg=cnt_msg, raise_on_fail=True)
|
||||
|
||||
|
||||
def fix_node(node_spec_str, is_all=False, cnt_msg=''):
|
||||
@@ -613,14 +623,20 @@ def for_each_nodes(nodes, act, allow_all=True, **kwargs):
|
||||
nodes = [x for x in nodes if x.lower() not in ['comfy', 'comfyui', 'all']]
|
||||
|
||||
total = len(nodes)
|
||||
i = 1
|
||||
for x in nodes:
|
||||
failed = []
|
||||
for i, x in enumerate(nodes, 1):
|
||||
try:
|
||||
act(x, is_all=is_all, cnt_msg=f'{i}/{total}', **kwargs)
|
||||
except NodeInstallError:
|
||||
failed.append(x)
|
||||
except Exception as e:
|
||||
print(f"ERROR: {e}")
|
||||
traceback.print_exc()
|
||||
i += 1
|
||||
failed.append(x)
|
||||
|
||||
if failed:
|
||||
print(f"\n[bold red]Failed nodes ({len(failed)}/{total}): {', '.join(str(x) for x in failed)}[/bold red]")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
app = typer.Typer()
|
||||
@@ -1462,6 +1478,91 @@ def export_custom_node_ids(
|
||||
print(f"{x['id']}@unknown", file=output_file)
|
||||
|
||||
|
||||
@app.command("update-cache", help="Force-fetch all remote data and populate local cache (blocking)")
|
||||
def update_cache(
|
||||
channel: Annotated[
|
||||
str,
|
||||
typer.Option(
|
||||
show_default=False,
|
||||
help="Specify the channel"
|
||||
),
|
||||
] = None,
|
||||
user_directory: str = typer.Option(
|
||||
None,
|
||||
help="user directory"
|
||||
),
|
||||
):
|
||||
cmd_ctx.set_user_directory(user_directory)
|
||||
if channel is not None:
|
||||
cmd_ctx.channel = channel
|
||||
|
||||
asyncio.run(_force_update_cache(cmd_ctx.channel))
|
||||
|
||||
|
||||
async def _force_update_cache(channel):
|
||||
"""Fetch all remote data and save to cache, bypassing pip/offline guards."""
|
||||
core.refresh_channel_dict()
|
||||
config = core.get_config()
|
||||
channel_url = config['channel_url']
|
||||
|
||||
os.makedirs(manager_util.cache_dir, exist_ok=True)
|
||||
|
||||
failed = []
|
||||
|
||||
# Step 1: Fetch channel JSON files directly (bypasses get_data_by_mode pip guard)
|
||||
filenames = [
|
||||
"custom-node-list.json",
|
||||
"extension-node-map.json",
|
||||
"model-list.json",
|
||||
"alter-list.json",
|
||||
"github-stats.json",
|
||||
]
|
||||
|
||||
async def fetch_and_cache(filename):
|
||||
try:
|
||||
if config.get('default_cache_as_channel_url'):
|
||||
uri = f"{channel_url}/{filename}"
|
||||
else:
|
||||
uri = f"{core.DEFAULT_CHANNEL}/{filename}"
|
||||
|
||||
cache_uri = str(manager_util.simple_hash(uri)) + '_' + filename
|
||||
cache_uri = os.path.join(manager_util.cache_dir, cache_uri)
|
||||
|
||||
json_obj = await manager_util.get_data(uri, silent=True)
|
||||
|
||||
with manager_util.cache_lock:
|
||||
with open(cache_uri, "w", encoding='utf-8') as file:
|
||||
json.dump(json_obj, file, indent=4, sort_keys=True)
|
||||
|
||||
print(f" [CACHED] {filename}")
|
||||
except Exception as e:
|
||||
print(f" [bold red][FAILED] {filename}: {e}[/bold red]")
|
||||
failed.append(filename)
|
||||
|
||||
print("Fetching channel data...")
|
||||
await asyncio.gather(*[fetch_and_cache(f) for f in filenames])
|
||||
|
||||
# Step 2: Reload unified_manager with remote mode
|
||||
# cache_mode='remote' makes cache_mode==False in get_cnr_data,
|
||||
# which bypasses the dont_wait block and triggers blocking fetch_all()
|
||||
print("Fetching CNR registry data...")
|
||||
try:
|
||||
await unified_manager.reload('remote', dont_wait=False)
|
||||
except Exception as e:
|
||||
print(f" [bold red][FAILED] CNR registry: {e}[/bold red]")
|
||||
failed.append("CNR registry")
|
||||
|
||||
# Step 3: Load nightly data (cache files now exist from Step 1)
|
||||
print("Loading nightly data...")
|
||||
await unified_manager.load_nightly(channel or 'default', 'cache')
|
||||
|
||||
if failed:
|
||||
print(f"\n[bold red]Cache update incomplete. Failed: {', '.join(failed)}[/bold red]")
|
||||
sys.exit(1)
|
||||
else:
|
||||
print("[bold green]Cache update complete.[/bold green]")
|
||||
|
||||
|
||||
def main():
|
||||
app()
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import yaml
|
||||
import requests
|
||||
from tqdm.auto import tqdm
|
||||
from git.remote import RemoteProgress
|
||||
from comfyui_manager.common.timestamp_utils import get_backup_branch_name
|
||||
|
||||
|
||||
comfy_path = os.environ.get('COMFYUI_PATH')
|
||||
@@ -68,6 +67,36 @@ class GitProgress(RemoteProgress):
|
||||
self.pbar.refresh()
|
||||
|
||||
|
||||
def get_backup_branch_name(repo=None):
|
||||
"""Get backup branch name with current timestamp.
|
||||
|
||||
Inlined from timestamp_utils to keep git_helper.py standalone — this script
|
||||
runs as a subprocess on Windows and must not import from comfyui_manager.
|
||||
"""
|
||||
import time as _time
|
||||
import uuid as _uuid
|
||||
|
||||
base_name = f'backup_{_time.strftime("%Y%m%d_%H%M%S")}'
|
||||
|
||||
if repo is None:
|
||||
return base_name
|
||||
|
||||
try:
|
||||
existing_branches = {b.name for b in repo.heads}
|
||||
except Exception:
|
||||
return base_name
|
||||
|
||||
if base_name not in existing_branches:
|
||||
return base_name
|
||||
|
||||
for i in range(1, 100):
|
||||
new_name = f'{base_name}_{i}'
|
||||
if new_name not in existing_branches:
|
||||
return new_name
|
||||
|
||||
return f'{base_name}_{_uuid.uuid4().hex[:6]}'
|
||||
|
||||
|
||||
def gitclone(custom_nodes_path, url, target_hash=None, repo_path=None):
|
||||
repo_name = os.path.splitext(os.path.basename(url))[0]
|
||||
|
||||
|
||||
@@ -1142,6 +1142,71 @@ class UnifiedManager:
|
||||
|
||||
return result
|
||||
|
||||
def purge_node_state(self, node_id: str):
|
||||
"""
|
||||
Remove a node's directory and clean ALL internal dictionaries regardless of categorization.
|
||||
Used by reinstall to guarantee clean state before re-installation.
|
||||
"""
|
||||
if 'comfyui-manager' in node_id.lower():
|
||||
return
|
||||
|
||||
custom_nodes_dir = os.path.normcase(os.path.realpath(get_default_custom_nodes_path()))
|
||||
paths_to_remove = set()
|
||||
|
||||
def _add_path(raw_path):
|
||||
"""Normalize and validate a path before adding to removal set."""
|
||||
if not raw_path:
|
||||
return
|
||||
resolved = os.path.normcase(os.path.realpath(raw_path))
|
||||
if resolved == custom_nodes_dir:
|
||||
logging.warning(f"[ComfyUI-Manager] purge_node_state: refusing to delete custom_nodes root: {raw_path}")
|
||||
return
|
||||
try:
|
||||
if os.path.commonpath([custom_nodes_dir, resolved]) != custom_nodes_dir:
|
||||
logging.warning(f"[ComfyUI-Manager] purge_node_state: path escapes custom_nodes scope, skipping: {raw_path}")
|
||||
return
|
||||
except ValueError:
|
||||
logging.warning(f"[ComfyUI-Manager] purge_node_state: cannot verify containment, skipping: {raw_path}")
|
||||
return
|
||||
paths_to_remove.add(resolved)
|
||||
|
||||
# Collect paths from all dictionaries
|
||||
entry = self.unknown_active_nodes.get(node_id)
|
||||
if entry is not None:
|
||||
_add_path(entry[1])
|
||||
|
||||
entry = self.active_nodes.get(node_id)
|
||||
if entry is not None:
|
||||
_add_path(entry[1])
|
||||
|
||||
entry = self.unknown_inactive_nodes.get(node_id)
|
||||
if entry is not None:
|
||||
_add_path(entry[1])
|
||||
|
||||
fullpath = self.nightly_inactive_nodes.get(node_id)
|
||||
if fullpath is not None:
|
||||
_add_path(fullpath)
|
||||
|
||||
ver_map = self.cnr_inactive_nodes.get(node_id)
|
||||
if ver_map is not None:
|
||||
for key, fp in ver_map.items():
|
||||
_add_path(fp)
|
||||
|
||||
# Convention-based fallback path
|
||||
_add_path(os.path.join(get_default_custom_nodes_path(), node_id))
|
||||
|
||||
# Remove all validated paths, then always clean dictionaries
|
||||
try:
|
||||
for path in paths_to_remove:
|
||||
if os.path.exists(path):
|
||||
try_rmtree(node_id, path)
|
||||
finally:
|
||||
self.unknown_active_nodes.pop(node_id, None)
|
||||
self.active_nodes.pop(node_id, None)
|
||||
self.unknown_inactive_nodes.pop(node_id, None)
|
||||
self.nightly_inactive_nodes.pop(node_id, None)
|
||||
self.cnr_inactive_nodes.pop(node_id, None)
|
||||
|
||||
def unified_uninstall(self, node_id: str, is_unknown: bool):
|
||||
"""
|
||||
Remove whole installed custom nodes including inactive nodes
|
||||
|
||||
@@ -1135,6 +1135,71 @@ class UnifiedManager:
|
||||
|
||||
return result
|
||||
|
||||
def purge_node_state(self, node_id: str):
|
||||
"""
|
||||
Remove a node's directory and clean ALL internal dictionaries regardless of categorization.
|
||||
Used by reinstall to guarantee clean state before re-installation.
|
||||
"""
|
||||
if 'comfyui-manager' in node_id.lower():
|
||||
return
|
||||
|
||||
custom_nodes_dir = os.path.normcase(os.path.realpath(get_default_custom_nodes_path()))
|
||||
paths_to_remove = set()
|
||||
|
||||
def _add_path(raw_path):
|
||||
"""Normalize and validate a path before adding to removal set."""
|
||||
if not raw_path:
|
||||
return
|
||||
resolved = os.path.normcase(os.path.realpath(raw_path))
|
||||
if resolved == custom_nodes_dir:
|
||||
logging.warning(f"[ComfyUI-Manager] purge_node_state: refusing to delete custom_nodes root: {raw_path}")
|
||||
return
|
||||
try:
|
||||
if os.path.commonpath([custom_nodes_dir, resolved]) != custom_nodes_dir:
|
||||
logging.warning(f"[ComfyUI-Manager] purge_node_state: path escapes custom_nodes scope, skipping: {raw_path}")
|
||||
return
|
||||
except ValueError:
|
||||
logging.warning(f"[ComfyUI-Manager] purge_node_state: cannot verify containment, skipping: {raw_path}")
|
||||
return
|
||||
paths_to_remove.add(resolved)
|
||||
|
||||
# Collect paths from all dictionaries
|
||||
entry = self.unknown_active_nodes.get(node_id)
|
||||
if entry is not None:
|
||||
_add_path(entry[1])
|
||||
|
||||
entry = self.active_nodes.get(node_id)
|
||||
if entry is not None:
|
||||
_add_path(entry[1])
|
||||
|
||||
entry = self.unknown_inactive_nodes.get(node_id)
|
||||
if entry is not None:
|
||||
_add_path(entry[1])
|
||||
|
||||
fullpath = self.nightly_inactive_nodes.get(node_id)
|
||||
if fullpath is not None:
|
||||
_add_path(fullpath)
|
||||
|
||||
ver_map = self.cnr_inactive_nodes.get(node_id)
|
||||
if ver_map is not None:
|
||||
for key, fp in ver_map.items():
|
||||
_add_path(fp)
|
||||
|
||||
# Convention-based fallback path
|
||||
_add_path(os.path.join(get_default_custom_nodes_path(), node_id))
|
||||
|
||||
# Remove all validated paths, then always clean dictionaries
|
||||
try:
|
||||
for path in paths_to_remove:
|
||||
if os.path.exists(path):
|
||||
try_rmtree(node_id, path)
|
||||
finally:
|
||||
self.unknown_active_nodes.pop(node_id, None)
|
||||
self.active_nodes.pop(node_id, None)
|
||||
self.unknown_inactive_nodes.pop(node_id, None)
|
||||
self.nightly_inactive_nodes.pop(node_id, None)
|
||||
self.cnr_inactive_nodes.pop(node_id, None)
|
||||
|
||||
def unified_uninstall(self, node_id: str, is_unknown: bool):
|
||||
"""
|
||||
Remove whole installed custom nodes including inactive nodes
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
|
||||
[project]
|
||||
name = "comfyui-manager"
|
||||
license = { text = "GPL-3.0-only" }
|
||||
version = "4.1b4"
|
||||
version = "4.1b6"
|
||||
requires-python = ">= 3.9"
|
||||
description = "ComfyUI-Manager provides features to install and manage custom nodes for ComfyUI, as well as various functionalities to assist with ComfyUI."
|
||||
readme = "README.md"
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
"""Minimal tests for git_helper standalone execution and get_backup_branch_name.
|
||||
|
||||
git_helper.py runs as a subprocess on Windows and must NOT import comfyui_manager.
|
||||
Tests validate: (1) no forbidden imports via AST, (2) subprocess-level standalone
|
||||
execution, (3) get_backup_branch_name behavioural correctness.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import pathlib
|
||||
import subprocess
|
||||
import sys
|
||||
import textwrap
|
||||
|
||||
import pytest
|
||||
|
||||
_GIT_HELPER_PATH = pathlib.Path(__file__).resolve().parents[2] / "comfyui_manager" / "common" / "git_helper.py"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. No comfyui_manager imports (AST check — no execution)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_no_comfyui_manager_import():
|
||||
"""git_helper.py must be free of comfyui_manager imports (Windows subprocess safety)."""
|
||||
tree = ast.parse(_GIT_HELPER_PATH.read_text(encoding="utf-8"))
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.ImportFrom) and node.module and "comfyui_manager" in node.module:
|
||||
pytest.fail(f"Forbidden import at line {node.lineno}: from {node.module}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Standalone subprocess execution (mirrors the actual Windows bug scenario)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_standalone_subprocess_can_load(tmp_path):
|
||||
"""git_helper.py must load in a subprocess without comfyui_manager on sys.path."""
|
||||
(tmp_path / "folder_paths.py").touch()
|
||||
|
||||
# Run git_helper.py in a clean subprocess with stripped sys.path.
|
||||
# git_helper.py has a module-level sys.argv dispatcher that calls sys.exit,
|
||||
# so we set argv to --check with a dummy path to avoid IndexError, then
|
||||
# catch the SystemExit to verify the function loaded successfully.
|
||||
script = textwrap.dedent(f"""\
|
||||
import sys, os
|
||||
sys.path = [p for p in sys.path
|
||||
if "comfyui_manager" not in p and "comfyui-manager" not in p]
|
||||
os.environ["COMFYUI_PATH"] = {str(tmp_path)!r}
|
||||
|
||||
import importlib.util
|
||||
spec = importlib.util.spec_from_file_location("git_helper", {str(_GIT_HELPER_PATH)!r})
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
|
||||
# Intercept sys.exit from module-level argv dispatcher
|
||||
real_exit = sys.exit
|
||||
exit_code = [None]
|
||||
def fake_exit(code=0):
|
||||
exit_code[0] = code
|
||||
raise SystemExit(code)
|
||||
sys.exit = fake_exit
|
||||
|
||||
try:
|
||||
spec.loader.exec_module(mod)
|
||||
except SystemExit:
|
||||
pass
|
||||
finally:
|
||||
sys.exit = real_exit
|
||||
|
||||
# The function must be defined regardless of argv dispatcher outcome
|
||||
assert hasattr(mod, "get_backup_branch_name"), "Function not defined"
|
||||
name = mod.get_backup_branch_name()
|
||||
assert name.startswith("backup_"), f"Bad name: {{name}}"
|
||||
print(f"OK: {{name}}")
|
||||
""")
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", script],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
assert result.returncode == 0, f"stderr: {result.stderr}"
|
||||
assert "OK: backup_" in result.stdout
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. get_backup_branch_name behavioural tests (function extracted via AST)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def _get_backup_branch_name():
|
||||
"""Extract and compile get_backup_branch_name from git_helper.py source."""
|
||||
source = _GIT_HELPER_PATH.read_text(encoding="utf-8")
|
||||
tree = ast.parse(source)
|
||||
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.FunctionDef) and node.name == "get_backup_branch_name":
|
||||
func_source = ast.get_source_segment(source, node)
|
||||
assert func_source is not None
|
||||
ns = {}
|
||||
exec(compile(ast.parse(func_source), "<test>", "exec"), ns) # noqa: S102
|
||||
return ns["get_backup_branch_name"]
|
||||
|
||||
pytest.fail("get_backup_branch_name not found in git_helper.py")
|
||||
|
||||
|
||||
class _FakeHead:
|
||||
def __init__(self, name):
|
||||
self.name = name
|
||||
|
||||
|
||||
class _FakeRepo:
|
||||
def __init__(self, branch_names):
|
||||
self.heads = [_FakeHead(n) for n in branch_names]
|
||||
|
||||
|
||||
def test_basic_name_format(_get_backup_branch_name):
|
||||
import time
|
||||
name = _get_backup_branch_name()
|
||||
assert name.startswith("backup_")
|
||||
expected = f"backup_{time.strftime('%Y%m%d_%H%M%S')}"
|
||||
assert name == expected
|
||||
|
||||
|
||||
def test_no_collision(_get_backup_branch_name):
|
||||
repo = _FakeRepo(["main", "dev"])
|
||||
name = _get_backup_branch_name(repo)
|
||||
assert name.startswith("backup_")
|
||||
|
||||
|
||||
def test_single_collision(_get_backup_branch_name):
|
||||
import time
|
||||
ts = time.strftime("%Y%m%d_%H%M%S")
|
||||
repo = _FakeRepo(["main", f"backup_{ts}"])
|
||||
name = _get_backup_branch_name(repo)
|
||||
assert name == f"backup_{ts}_1"
|
||||
|
||||
|
||||
def test_multi_collision(_get_backup_branch_name):
|
||||
import time
|
||||
ts = time.strftime("%Y%m%d_%H%M%S")
|
||||
repo = _FakeRepo([f"backup_{ts}", f"backup_{ts}_1", f"backup_{ts}_2"])
|
||||
name = _get_backup_branch_name(repo)
|
||||
assert name == f"backup_{ts}_3"
|
||||
|
||||
|
||||
def test_repo_none_returns_base(_get_backup_branch_name):
|
||||
name = _get_backup_branch_name(None)
|
||||
assert name.startswith("backup_")
|
||||
|
||||
|
||||
def test_repo_heads_exception_returns_base(_get_backup_branch_name):
|
||||
"""When repo.heads raises, fall back to base name without suffix."""
|
||||
|
||||
class _BrokenRepo:
|
||||
@property
|
||||
def heads(self):
|
||||
raise RuntimeError("simulated git error")
|
||||
|
||||
name = _get_backup_branch_name(_BrokenRepo())
|
||||
assert name.startswith("backup_")
|
||||
assert "_1" not in name # no suffix — exception path returns base
|
||||
|
||||
|
||||
def test_uuid_fallback_on_full_collision(_get_backup_branch_name):
|
||||
"""When all 99 suffixes are taken, fall back to UUID."""
|
||||
import time
|
||||
ts = time.strftime("%Y%m%d_%H%M%S")
|
||||
# All names from backup_TS through backup_TS_99 are taken
|
||||
taken = [f"backup_{ts}"] + [f"backup_{ts}_{i}" for i in range(1, 100)]
|
||||
repo = _FakeRepo(taken)
|
||||
name = _get_backup_branch_name(repo)
|
||||
assert name.startswith(f"backup_{ts}_")
|
||||
# UUID suffix is 6 hex chars
|
||||
suffix = name[len(f"backup_{ts}_"):]
|
||||
assert len(suffix) == 6, f"Expected 6-char UUID suffix, got: {suffix}"
|
||||
Reference in New Issue
Block a user