ComfyUI-Manager/comfyui_manager/common/manager_util.py
Dr.Lt.Data f042d73b72
Some checks failed
Python Linting / Run Ruff (push) Has been cancelled
Publish to PyPI / build-and-publish (push) Has been cancelled
feat(deps): add unified dependency resolver using uv pip compile (#2589)
* feat(deps): add unified dependency resolver using uv pip compile

- Add UnifiedDepResolver module with 7 FRs: collect, compile, install pipeline
- Integrate startup batch resolution in prestartup_script.py (module scope)
- Skip per-node pip install in execute_install_script() when unified mode active
- Add use_unified_resolver config flag following use_uv pattern
- Input sanitization: reject -r, -e, --find-links, @ file://, path separators
- Handle --index-url/--extra-index-url separation with credential redaction
- Fallback to per-node pip on resolver failure or uv unavailability
- Add 98 unit tests across 20 test classes
- Add PRD and Design docs with cm_global integration marked as DEFERRED

* fix(deps): reset use_unified_resolver flag on startup fallback

When the unified resolver fails at startup (compile error, install
error, uv unavailable, or generic exception), the runtime flag was
not being reset to False. This caused subsequent runtime installs
to incorrectly defer pip dependencies instead of falling back to
per-node pip install.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test(deps): add manual test cases for unified dependency resolver

Add environment setup guide and 16 test cases covering:
- Normal batch resolution (TC-1), disabled state (TC-2)
- Fallback paths: uv unavailable (TC-3), compile fail (TC-4),
  install fail (TC-5), generic exception (TC-16)
- install.py preservation (TC-6), runtime defer (TC-13)
- Input sanitization: dangerous patterns (TC-7), path separators
  (TC-8), index-url separation (TC-9), credential redaction (TC-10)
- Disabled pack exclusion (TC-11), no-deps path (TC-12)
- Both unified resolver guard paths (TC-14), post-fallback (TC-15)

Includes API reference, traceability matrix, and out-of-scope items.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(deps): prevent read_config() from overriding resolver fallback state

read_config() in manager_core.py unconditionally re-read
use_unified_resolver from config.ini, undoing the False set by
prestartup_script.py on resolver fallback. This caused runtime
installs to still defer deps even after a startup batch failure.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(deps): support multiple index URLs per line and optimize downgrade check

- Rewrite _split_index_url() to handle multiple --index-url /
  --extra-index-url options on a single requirements.txt line using
  regex-based parsing instead of single split.
- Cache installed_packages snapshot in collect_requirements() to avoid
  repeated subprocess calls during downgrade blacklist checks.
- Add unit tests for multi-URL lines and bare --index-url edge case.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test(deps): add E2E scripts and update test documentation

Add automated E2E test scripts for unified dependency resolver:
- setup_e2e_env.sh: idempotent environment setup (clone ComfyUI,
  create venv, install deps, symlink Manager, write config.ini)
- start_comfyui.sh: foreground-blocking launcher using
  tail -f | grep -q readiness detection
- stop_comfyui.sh: graceful SIGTERM → SIGKILL shutdown

Update test documentation reflecting E2E testing findings:
- TEST-environment-setup.md: add automated script usage, document
  caveats (PYTHONPATH, config.ini path, Manager v4 /v2/ prefix,
  Blocked by policy, bash ((var++)) trap, git+https:// rejection)
- TEST-unified-dep-resolver.md: add TC-17 (restart dependency
  detection), TC-18 (real node pack integration), Validated
  Behaviors section, normalize API port to 8199

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(deps): harden input sanitization, expand test coverage, bump version

Security:
- Add _INLINE_DANGEROUS_OPTIONS regex to catch pip options after package
  names (--find-links, --constraint, --requirement, --editable, --trusted-host,
  --global-option, --install-option and short forms)
- Stage index URLs in pending_urls, commit only after full line validation
  to prevent URL injection from rejected lines

Tests:
- Add 50 new tests: inline sanitization, false-positive guards, parse
  helpers (_parse_conflicts, _parse_install_output), exception paths
  (91 → 141 total, all pass)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(cli): add uv-compile command and --uv-compile flag for batch dependency resolution

Add two CLI entry points for the unified dependency resolver:

- `cm_cli uv-compile`: standalone batch resolution of all installed
  node pack dependencies via uv pip compile
- `cm_cli install --uv-compile`: skip per-node pip, batch-resolve all
  deps after install completes (mutually exclusive with --no-deps)

Both use a shared `_run_unified_resolve()` helper that passes real
cm_global values (pip_blacklist, pip_overrides, pip_downgrade_blacklist)
and guarantees PIPFixer.fix_broken() runs via try/finally.

Update DESIGN, PRD, and TEST docs for consistency.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 06:51:53 +09:00

638 lines
22 KiB
Python

"""
description:
`manager_util` is the lightest module shared across the prestartup_script, main code, and cm-cli of ComfyUI-Manager.
"""
import traceback
import aiohttp
import json
import threading
import os
import subprocess
import sys
import re
import logging
import platform
import shlex
import time
from functools import lru_cache
cache_lock = threading.Lock()
session_lock = threading.Lock()
comfyui_manager_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
cache_dir = os.path.join(comfyui_manager_path, '.cache') # This path is also updated together in **manager_core.update_user_directory**.
use_uv = False
use_unified_resolver = False
bypass_ssl = False
def is_manager_pip_package():
return not os.path.exists(os.path.join(comfyui_manager_path, '..', 'custom_nodes'))
def add_python_path_to_env():
if platform.system() != "Windows":
sep = ':'
else:
sep = ';'
os.environ['PATH'] = os.path.dirname(sys.executable)+sep+os.environ['PATH']
@lru_cache(maxsize=2)
def get_pip_cmd(force_uv=False):
"""
Get the base pip command, with automatic fallback to uv if pip is unavailable.
Args:
force_uv (bool): If True, use uv directly without trying pip
Returns:
list: Base command for pip operations
"""
embedded = 'python_embeded' in sys.executable
# Try pip first (unless forcing uv)
if not force_uv:
try:
test_cmd = [sys.executable] + (['-s'] if embedded else []) + ['-m', 'pip', '--version']
subprocess.check_output(test_cmd, stderr=subprocess.DEVNULL, timeout=5)
return [sys.executable] + (['-s'] if embedded else []) + ['-m', 'pip']
except Exception:
logging.warning("[ComfyUI-Manager] python -m pip not available. Falling back to uv.")
# Try uv (either forced or pip failed)
import shutil
# Try uv as Python module
try:
test_cmd = [sys.executable] + (['-s'] if embedded else []) + ['-m', 'uv', '--version']
subprocess.check_output(test_cmd, stderr=subprocess.DEVNULL, timeout=5)
logging.info("[ComfyUI-Manager] Using uv as Python module for pip operations.")
return [sys.executable] + (['-s'] if embedded else []) + ['-m', 'uv', 'pip']
except Exception:
pass
# Try standalone uv
if shutil.which('uv'):
logging.info("[ComfyUI-Manager] Using standalone uv for pip operations.")
return ['uv', 'pip']
# Nothing worked
logging.error("[ComfyUI-Manager] Neither python -m pip nor uv are available. Cannot proceed with package operations.")
raise Exception("Neither pip nor uv are available for package management")
def make_pip_cmd(cmd):
"""
Create a pip command by combining the cached base pip command with the given arguments.
Args:
cmd (list): List of pip command arguments (e.g., ['install', 'package'])
Returns:
list: Complete command list ready for subprocess execution
"""
global use_uv
base_cmd = get_pip_cmd(force_uv=use_uv)
return base_cmd + cmd
# DON'T USE StrictVersion - cannot handle pre_release version
# try:
# from distutils.version import StrictVersion
# except Exception:
# print(f"[ComfyUI-Manager] 'distutils' package not found. Activating fallback mode for compatibility.")
class StrictVersion:
def __init__(self, version_string):
self.version_string = version_string
self.major = 0
self.minor = 0
self.patch = 0
self.pre_release = None
self.parse_version_string()
def parse_version_string(self):
parts = self.version_string.split('.')
if not parts:
raise ValueError("Version string must not be empty")
self.major = int(parts[0])
self.minor = int(parts[1]) if len(parts) > 1 else 0
self.patch = int(parts[2]) if len(parts) > 2 else 0
# Handling pre-release versions if present
if len(parts) > 3:
self.pre_release = parts[3]
def __str__(self):
version = f"{self.major}.{self.minor}.{self.patch}"
if self.pre_release:
version += f"-{self.pre_release}"
return version
def __eq__(self, other):
return (self.major, self.minor, self.patch, self.pre_release) == \
(other.major, other.minor, other.patch, other.pre_release)
def __lt__(self, other):
if (self.major, self.minor, self.patch) == (other.major, other.minor, other.patch):
return self.pre_release_compare(self.pre_release, other.pre_release) < 0
return (self.major, self.minor, self.patch) < (other.major, other.minor, other.patch)
@staticmethod
def pre_release_compare(pre1, pre2):
if pre1 == pre2:
return 0
if pre1 is None:
return 1
if pre2 is None:
return -1
return -1 if pre1 < pre2 else 1
def __le__(self, other):
return self == other or self < other
def __gt__(self, other):
return not self <= other
def __ge__(self, other):
return not self < other
def __ne__(self, other):
return not self == other
def simple_hash(input_string):
hash_value = 0
for char in input_string:
hash_value = (hash_value * 31 + ord(char)) % (2**32)
return hash_value
def is_file_created_within_one_day(file_path):
if not os.path.exists(file_path):
return False
file_creation_time = os.path.getctime(file_path)
current_time = time.time()
time_difference = current_time - file_creation_time
return time_difference <= 86400
async def get_data(uri, silent=False):
if not silent:
print(f"FETCH DATA from: {uri}", end="")
if uri.startswith("http"):
async with aiohttp.ClientSession(trust_env=True, connector=aiohttp.TCPConnector(verify_ssl=not bypass_ssl)) as session:
headers = {
'Cache-Control': 'no-cache',
'Pragma': 'no-cache',
'Expires': '0'
}
async with session.get(uri, headers=headers) as resp:
json_text = await resp.text()
else:
with cache_lock:
with open(uri, "r", encoding="utf-8") as f:
json_text = f.read()
try:
json_obj = json.loads(json_text)
except Exception as e:
logging.error(f"[ComfyUI-Manager] An error occurred while fetching '{uri}': {e}")
return {}
if not silent:
print(" [DONE]")
return json_obj
def get_cache_path(uri):
cache_uri = str(simple_hash(uri)) + '_' + os.path.basename(uri).replace('&', "_").replace('?', "_").replace('=', "_")
return os.path.join(cache_dir, cache_uri+'.json')
def get_cache_state(uri):
cache_uri = get_cache_path(uri)
if not os.path.exists(cache_uri):
return "not-cached"
elif is_file_created_within_one_day(cache_uri):
return "cached"
return "expired"
def save_to_cache(uri, json_obj, silent=False):
cache_uri = get_cache_path(uri)
with cache_lock:
with open(cache_uri, "w", encoding='utf-8') as file:
json.dump(json_obj, file, indent=4, sort_keys=True)
if not silent:
logging.info(f"[ComfyUI-Manager] default cache updated: {uri}")
async def get_data_with_cache(uri, silent=False, cache_mode=True, dont_wait=False, dont_cache=False):
cache_uri = get_cache_path(uri)
if cache_mode and dont_wait:
# NOTE: return the cache if possible, even if it is expired, so do not cache
if not os.path.exists(cache_uri):
logging.error(f"[ComfyUI-Manager] The network connection is unstable, so it is operating in fallback mode: {uri}")
return {}
else:
if not is_file_created_within_one_day(cache_uri):
logging.error(f"[ComfyUI-Manager] The network connection is unstable, so it is operating in outdated cache mode: {uri}")
return await get_data(cache_uri, silent=silent)
if cache_mode and is_file_created_within_one_day(cache_uri):
json_obj = await get_data(cache_uri, silent=silent)
else:
json_obj = await get_data(uri, silent=silent)
if not dont_cache:
with cache_lock:
with open(cache_uri, "w", encoding='utf-8') as file:
json.dump(json_obj, file, indent=4, sort_keys=True)
if not silent:
logging.info(f"[ComfyUI-Manager] default cache updated: {uri}")
return json_obj
def sanitize_tag(x):
return x.replace('<', '&lt;').replace('>', '&gt;')
def extract_package_as_zip(file_path, extract_path):
import zipfile
try:
with zipfile.ZipFile(file_path, "r") as zip_ref:
zip_ref.extractall(extract_path)
extracted_files = zip_ref.namelist()
logging.info(f"Extracted zip file to {extract_path}")
return extracted_files
except zipfile.BadZipFile:
logging.error(f"File '{file_path}' is not a zip or is corrupted.")
return None
pip_map = None
def get_installed_packages(renew=False):
global pip_map
if renew or pip_map is None:
try:
result = subprocess.check_output(make_pip_cmd(['list']), universal_newlines=True)
pip_map = {}
for line in result.split('\n'):
x = line.strip()
if x:
y = line.split()
if y[0] == 'Package' or y[0].startswith('-'):
continue
normalized_name = y[0].lower().replace('-', '_')
pip_map[normalized_name] = y[1]
except subprocess.CalledProcessError:
logging.error("[ComfyUI-Manager] Failed to retrieve the information of installed pip packages.")
return {}
return pip_map
def clear_pip_cache():
global pip_map
pip_map = None
def parse_requirement_line(line):
tokens = shlex.split(line)
if not tokens:
return None
package_spec = tokens[0]
pattern = re.compile(
r'^(?P<package>[A-Za-z0-9_.+-]+)'
r'(?P<operator>==|>=|<=|!=|~=|>|<)?'
r'(?P<version>[A-Za-z0-9_.+-]*)$'
)
m = pattern.match(package_spec)
if not m:
return None
package = m.group('package')
operator = m.group('operator') or None
version = m.group('version') or None
index_url = None
if '--index-url' in tokens:
idx = tokens.index('--index-url')
if idx + 1 < len(tokens):
index_url = tokens[idx + 1]
res = {'package': package}
if operator is not None:
res['operator'] = operator
if version is not None:
res['version'] = StrictVersion(version)
if index_url is not None:
res['index_url'] = index_url
return res
torch_torchvision_torchaudio_version_map = {
'2.7.0': ('0.22.0', '2.7.0'),
'2.6.0': ('0.21.0', '2.6.0'),
'2.5.1': ('0.20.0', '2.5.0'),
'2.5.0': ('0.20.0', '2.5.0'),
'2.4.1': ('0.19.1', '2.4.1'),
'2.4.0': ('0.19.0', '2.4.0'),
'2.3.1': ('0.18.1', '2.3.1'),
'2.3.0': ('0.18.0', '2.3.0'),
'2.2.2': ('0.17.2', '2.2.2'),
'2.2.1': ('0.17.1', '2.2.1'),
'2.2.0': ('0.17.0', '2.2.0'),
'2.1.2': ('0.16.2', '2.1.2'),
'2.1.1': ('0.16.1', '2.1.1'),
'2.1.0': ('0.16.0', '2.1.0'),
'2.0.1': ('0.15.2', '2.0.1'),
'2.0.0': ('0.15.1', '2.0.0'),
}
def torch_rollback(prev):
spec = prev.split('+')
if len(spec) > 1:
platform = spec[1]
else:
cmd = make_pip_cmd(['install', '--force', 'torch', 'torchvision', 'torchaudio'])
subprocess.check_output(cmd, universal_newlines=True)
logging.error(cmd)
return
torch_ver = StrictVersion(spec[0])
torch_ver = f"{torch_ver.major}.{torch_ver.minor}.{torch_ver.patch}"
torch_torchvision_torchaudio_ver = torch_torchvision_torchaudio_version_map.get(torch_ver)
if torch_torchvision_torchaudio_ver is None:
cmd = make_pip_cmd(['install', '--pre', 'torch', 'torchvision', 'torchaudio',
'--index-url', f"https://download.pytorch.org/whl/nightly/{platform}"])
logging.info("[ComfyUI-Manager] restore PyTorch to nightly version")
else:
torchvision_ver, torchaudio_ver = torch_torchvision_torchaudio_ver
cmd = make_pip_cmd(['install', f'torch=={torch_ver}', f'torchvision=={torchvision_ver}', f"torchaudio=={torchaudio_ver}",
'--index-url', f"https://download.pytorch.org/whl/{platform}"])
logging.info(f"[ComfyUI-Manager] restore PyTorch to {torch_ver}+{platform}")
subprocess.check_output(cmd, universal_newlines=True)
class PIPFixer:
def __init__(self, prev_pip_versions, comfyui_path, manager_files_path):
self.prev_pip_versions = { **prev_pip_versions }
self.comfyui_path = comfyui_path
self.manager_files_path = manager_files_path
def fix_broken(self):
new_pip_versions = get_installed_packages(True)
# remove `comfy` python package
try:
if 'comfy' in new_pip_versions:
cmd = make_pip_cmd(['uninstall', 'comfy'])
subprocess.check_output(cmd, universal_newlines=True)
logging.warning("[ComfyUI-Manager] 'comfy' python package is uninstalled.\nWARN: The 'comfy' package is completely unrelated to ComfyUI and should never be installed as it causes conflicts with ComfyUI.")
except Exception as e:
logging.error("[ComfyUI-Manager] Failed to uninstall `comfy` python package")
logging.error(e)
# fix torch - reinstall torch packages if version is changed
try:
if 'torch' not in self.prev_pip_versions or 'torchvision' not in self.prev_pip_versions or 'torchaudio' not in self.prev_pip_versions:
logging.error("[ComfyUI-Manager] PyTorch is not installed")
elif self.prev_pip_versions['torch'] != new_pip_versions['torch'] \
or self.prev_pip_versions['torchvision'] != new_pip_versions['torchvision'] \
or self.prev_pip_versions['torchaudio'] != new_pip_versions['torchaudio']:
torch_rollback(self.prev_pip_versions['torch'])
except Exception as e:
logging.error("[ComfyUI-Manager] Failed to restore PyTorch")
logging.error(e)
# fix opencv
try:
ocp = new_pip_versions.get('opencv-contrib-python')
ocph = new_pip_versions.get('opencv-contrib-python-headless')
op = new_pip_versions.get('opencv-python')
oph = new_pip_versions.get('opencv-python-headless')
versions = [ocp, ocph, op, oph]
versions = [StrictVersion(x) for x in versions if x is not None]
versions.sort(reverse=True)
if len(versions) > 0:
# upgrade to maximum version
targets = []
cur = versions[0]
if ocp is not None and StrictVersion(ocp) != cur:
targets.append('opencv-contrib-python')
if ocph is not None and StrictVersion(ocph) != cur:
targets.append('opencv-contrib-python-headless')
if op is not None and StrictVersion(op) != cur:
targets.append('opencv-python')
if oph is not None and StrictVersion(oph) != cur:
targets.append('opencv-python-headless')
if len(targets) > 0:
for x in targets:
cmd = make_pip_cmd(['install', f"{x}=={versions[0].version_string}"])
subprocess.check_output(cmd, universal_newlines=True)
logging.info(f"[ComfyUI-Manager] 'opencv' dependencies were fixed: {targets}")
except Exception as e:
logging.error("[ComfyUI-Manager] Failed to restore opencv")
logging.error(e)
# fix missing frontend
try:
# NOTE: package name in requirements is 'comfyui-frontend-package'
# but, package name from `pip freeze` is 'comfyui_frontend_package'
# but, package name from `uv pip freeze` is 'comfyui-frontend-package'
#
# get_installed_packages returns normalized name (i.e. comfyui_frontend_package)
if 'comfyui_frontend_package' not in new_pip_versions:
requirements_path = os.path.join(self.comfyui_path, 'requirements.txt')
with open(requirements_path, 'r') as file:
lines = file.readlines()
front_line = next((line.strip() for line in lines if line.startswith('comfyui-frontend-package')), None)
if front_line is None:
logging.info("[ComfyUI-Manager] Skipped fixing the 'comfyui-frontend-package' dependency because the ComfyUI is outdated.")
else:
cmd = make_pip_cmd(['install', front_line])
subprocess.check_output(cmd , universal_newlines=True)
logging.info("[ComfyUI-Manager] 'comfyui-frontend-package' dependency were fixed")
except Exception as e:
logging.error("[ComfyUI-Manager] Failed to restore comfyui-frontend-package")
logging.error(e)
# restore based on custom list
pip_auto_fix_path = os.path.join(self.manager_files_path, "pip_auto_fix.list")
if os.path.exists(pip_auto_fix_path):
with open(pip_auto_fix_path, 'r', encoding="UTF-8", errors="ignore") as f:
fixed_list = []
for x in f.readlines():
try:
parsed = parse_requirement_line(x)
need_to_reinstall = True
normalized_name = parsed['package'].lower().replace('-', '_')
if normalized_name in new_pip_versions:
if 'version' in parsed and 'operator' in parsed:
cur = StrictVersion(new_pip_versions[normalized_name])
dest = parsed['version']
op = parsed['operator']
if cur == dest:
if op in ['==', '>=', '<=']:
need_to_reinstall = False
elif cur < dest:
if op in ['<=', '<', '~=', '!=']:
need_to_reinstall = False
elif cur > dest:
if op in ['>=', '>', '~=', '!=']:
need_to_reinstall = False
if need_to_reinstall:
cmd_args = ['install']
if 'version' in parsed and 'operator' in parsed:
cmd_args.append(parsed['package']+parsed['operator']+parsed['version'].version_string)
if 'index_url' in parsed:
cmd_args.append('--index-url')
cmd_args.append(parsed['index_url'])
cmd = make_pip_cmd(cmd_args)
subprocess.check_output(cmd, universal_newlines=True)
fixed_list.append(parsed['package'])
except Exception as e:
traceback.print_exc()
logging.error(f"[ComfyUI-Manager] Failed to restore '{x}'")
logging.error(e)
if len(fixed_list) > 0:
logging.info(f"[ComfyUI-Manager] dependencies in pip_auto_fix.json were fixed: {fixed_list}")
def sanitize(data):
return data.replace("<", "&lt;").replace(">", "&gt;")
def sanitize_filename(input_string):
result_string = re.sub(r'[^a-zA-Z0-9_]', '_', input_string)
return result_string
def robust_readlines(fullpath):
import chardet
try:
with open(fullpath, "r") as f:
return f.readlines()
except Exception:
encoding = None
with open(fullpath, "rb") as f:
raw_data = f.read()
result = chardet.detect(raw_data)
encoding = result['encoding']
if encoding is not None:
with open(fullpath, "r", encoding=encoding) as f:
return f.readlines()
print(f"[ComfyUI-Manager] Failed to recognize encoding for: {fullpath}")
return []
def restore_pip_snapshot(pips, options):
non_url = []
local_url = []
non_local_url = []
for k, v in pips.items():
# NOTE: skip torch related packages
if k.startswith("torch==") or k.startswith("torchvision==") or k.startswith("torchaudio==") or k.startswith("nvidia-"):
continue
if v == "":
non_url.append(k)
else:
if v.startswith('file:'):
local_url.append(v)
else:
non_local_url.append(v)
# restore other pips
failed = []
if '--pip-non-url' in options:
# try all at once
res = 1
try:
res = subprocess.check_output(make_pip_cmd(['install'] + non_url))
except Exception:
pass
# fallback
if res != 0:
for x in non_url:
res = 1
try:
res = subprocess.check_output(make_pip_cmd(['install', '--no-deps', x]))
except Exception:
pass
if res != 0:
failed.append(x)
if '--pip-non-local-url' in options:
for x in non_local_url:
res = 1
try:
res = subprocess.check_output(make_pip_cmd(['install', '--no-deps', x]))
except Exception:
pass
if res != 0:
failed.append(x)
if '--pip-local-url' in options:
for x in local_url:
res = 1
try:
res = subprocess.check_output(make_pip_cmd(['install', '--no-deps', x]))
except Exception:
pass
if res != 0:
failed.append(x)
print(f"Installation failed for pip packages: {failed}")