Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,50 @@ dependencies using the following precedence:
tool environment): a `.venv` is created inside the ComfyUI workspace.
Use `comfy launch` to start ComfyUI with the correct Python.

ComfyUI and ComfyUI-Manager dependency installs use uv's pip-compatible installer
when uv is available, targeting the selected environment explicitly. This keeps
the default `comfy install` and `comfy update` paths aligned with comfy-cli's uv
support:

- uv-created workspace virtual environments do not need pip installed inside
them, avoiding `No module named pip` failures when the environment is otherwise
valid.
- Warm-cache restores and updates can reuse uv's resolver/cache/linking behavior,
so repeated installs often complete much faster.
- Users do not need to switch to a separate manual restore flow just to keep a
ComfyUI workspace uv-managed.

The difference is most visible when comparing the documented flow with a
uv-managed workspace:

| Step | Existing documented flow | uv-managed workspace flow |
| --- | --- | --- |
| Install comfy-cli | `pip install comfy-cli` | `uv tool install comfy-cli` or `uv run comfy ...` |
| Install ComfyUI | `comfy install` | `uv run comfy install --restore` from an existing ComfyUI workspace, or `uv run comfy --workspace <path> install` |
| Update ComfyUI | `comfy update` | `comfy update` from a workspace whose `.venv` was created by uv |
| Dependency install behavior | Historically called `<workspace-python> -m pip install ...` | Uses `<comfy-cli-python> -m uv pip install --python <workspace-python> ...` |
| Failure this avoids | A valid workspace environment can still fail with `No module named pip` if pip was not seeded into `.venv` | uv targets the workspace interpreter directly, so pip does not need to be importable inside the workspace |
| Practical result | Users may need to manually repair the environment before install/update can continue | Install, restore, Manager setup, and repeated updates can stay uv-managed and reuse uv's cache |

For a fresh machine or another SSH host, the uv-managed flow does not require
downloading the comfy-cli repository first:

| Goal | pip-first instructions | uv-first instructions |
| --- | --- | --- |
| Install the CLI tool | `pip install comfy-cli` | `uv tool install comfy-cli` |
| Check the CLI | `comfy --help` | `comfy --help` |
| Install ComfyUI into a specific path | `comfy --workspace <path> install` | `comfy --workspace <path> install` |
| Restore an existing checkout | `comfy --workspace <path> install --restore` | `comfy --workspace <path> install --restore` |
| Update an existing checkout | `comfy --workspace <path> update` | `comfy --workspace <path> update` |
| Dependency installer used by comfy-cli | The selected workspace Python may need `pip` importable | comfy-cli uses uv's installer and passes `--python <workspace-python>` |

In both flows, users run the same `comfy` commands after installing the CLI.
The uv-backed installer changes the internal dependency restore step so fresh
machines and uv-managed workspaces do not depend on pip being present inside the
ComfyUI workspace environment.

If uv is unavailable, comfy-cli falls back to `python -m pip`.

### Specifying execution path

- You can specify the path of ComfyUI where the command will be applied through path indicators as follows:
Expand Down
7 changes: 2 additions & 5 deletions comfy_cli/cmdline.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
from comfy_cli.resolve_python import resolve_workspace_python
from comfy_cli.standalone import StandalonePython
from comfy_cli.update import check_for_updates
from comfy_cli.uv import DependencyCompiler
from comfy_cli.uv import DependencyCompiler, run_pip_install
from comfy_cli.workspace_manager import WorkspaceManager, check_comfy_repo

logging.setup_logging()
Expand Down Expand Up @@ -412,10 +412,7 @@ def update(
os.chdir(comfy_path)
subprocess.run(["git", "pull"], check=True)
python = resolve_workspace_python(comfy_path)
subprocess.run(
[python, "-m", "pip", "install", "-r", "requirements.txt"],
check=True,
)
run_pip_install(executable=python, args=["-r", "requirements.txt"], check=True)

try:
custom_nodes.command.update_node_id_cache()
Expand Down
25 changes: 12 additions & 13 deletions comfy_cli/command/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
from comfy_cli.cuda_detect import DEFAULT_CUDA_TAG
from comfy_cli.git_utils import checkout_pr, git_checkout_tag
from comfy_cli.resolve_python import ensure_workspace_python
from comfy_cli.uv import DependencyCompiler
from comfy_cli.uv import DependencyCompiler, run_pip_install
from comfy_cli.workspace_manager import WorkspaceManager, check_comfy_repo

workspace_manager = WorkspaceManager()
Expand All @@ -37,8 +37,9 @@ def get_os_details():

def _pip_install_torch(python: str, index_args: list[str]) -> subprocess.CompletedProcess:
"""Install torch, torchvision, and torchaudio with the given index arguments."""
return subprocess.run(
[python, "-m", "pip", "install", "torch", "torchvision", "torchaudio"] + index_args,
return run_pip_install(
executable=python,
args=["torch", "torchvision", "torchaudio", *index_args],
check=False,
)

Expand Down Expand Up @@ -85,16 +86,13 @@ def pip_install_comfyui_dependencies(

# install directml for AMD windows
if gpu == GPU_OPTION.AMD and plat == constants.OS.WINDOWS:
subprocess.run([python, "-m", "pip", "install", "torch-directml"], check=True)
run_pip_install(executable=python, args=["torch-directml"], check=True)

# install torch for Mac M Series
if gpu == GPU_OPTION.MAC_M_SERIES:
subprocess.run(
[
python,
"-m",
"pip",
"install",
run_pip_install(
executable=python,
args=[
"--pre",
"torch",
"torchvision",
Expand All @@ -108,7 +106,7 @@ def pip_install_comfyui_dependencies(
# install requirements.txt
if skip_requirement:
return
result = subprocess.run([python, "-m", "pip", "install", "-r", "requirements.txt"], check=False)
result = run_pip_install(executable=python, args=["-r", "requirements.txt"], check=False)
if result.returncode != 0:
rprint("Failed to install ComfyUI dependencies. Please check your environment (`comfy env`) and try again.")
sys.exit(1)
Expand All @@ -125,8 +123,9 @@ def pip_install_manager(repo_dir, python=sys.executable):
"Skipping manager installation (older ComfyUI version?).[/bold yellow]"
)
return False
result = subprocess.run(
[python, "-m", "pip", "install", "-r", constants.MANAGER_REQUIREMENTS_FILE],
result = run_pip_install(
executable=python,
args=["-r", constants.MANAGER_REQUIREMENTS_FILE],
cwd=repo_dir,
check=False,
capture_output=True,
Expand Down
38 changes: 35 additions & 3 deletions comfy_cli/uv.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import subprocess
import sys
from importlib import metadata
from importlib.util import find_spec
from pathlib import Path
from textwrap import dedent
from typing import Any, cast
Expand All @@ -15,6 +16,38 @@ def _run(cmd: list[str], cwd: PathLike, check: bool = True) -> subprocess.Comple
return subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, check=check)


def pip_install_command(executable: PathLike, args: list[str]) -> list[str]:
"""Build an installer command for a Python environment.

Prefer uv from the comfy-cli runtime and target the workspace interpreter
explicitly. This avoids requiring pip to be installed inside uv-created
workspace virtual environments.
"""
if find_spec("uv") is not None:
cmd = [sys.executable, "-m", "uv", "pip", "install", "--python", str(executable), *args]
if "--extra-index-url" in args and "--index-strategy" not in args:
cmd.extend(["--index-strategy", "unsafe-best-match"])
return cmd
return [str(executable), "-m", "pip", "install", *args]


def run_pip_install(
executable: PathLike,
args: list[str],
cwd: PathLike | None = None,
check: bool = False,
capture_output: bool = False,
text: bool = False,
) -> subprocess.CompletedProcess[Any]:
return subprocess.run(
pip_install_command(executable, args),
cwd=cwd,
check=check,
capture_output=capture_output,
text=text,
)


def _check_call(cmd: list[str], cwd: PathLike | None = None):
"""uses check_call to run pip, as reccomended by the pip maintainers.
see https://pip.pypa.io/en/stable/user_guide/#using-pip-from-your-program"""
Expand Down Expand Up @@ -126,9 +159,8 @@ def Find_Req_Files(*ders: PathLike) -> list[Path]:

@staticmethod
def Install_Build_Deps(executable: PathLike = sys.executable):
"""Use pip to install bare minimum requirements for uv to do its thing"""
cmd = [str(executable), "-m", "pip", "install", "--upgrade", "pip", "uv"]
_check_call(cmd=cmd)
"""Install uv into the target environment before running uv as a module there."""
run_pip_install(executable=executable, args=["--upgrade", "pip", "uv"], check=True)

@staticmethod
def Compile(
Expand Down
19 changes: 9 additions & 10 deletions tests/comfy_cli/command/test_manager_gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -776,14 +776,14 @@ class TestPipInstallManagerCacheClear:
"""Tests for pip_install_manager cache clearing after successful install."""

@patch("comfy_cli.command.custom_nodes.cm_cli_util.find_cm_cli")
@patch("comfy_cli.command.install.subprocess.run")
@patch("comfy_cli.command.install.run_pip_install")
@patch("os.path.exists", return_value=True)
def test_pip_install_manager_clears_cache_on_success(self, mock_exists, mock_run, mock_find_cm_cli):
def test_pip_install_manager_clears_cache_on_success(self, mock_exists, mock_install, mock_find_cm_cli):
"""When pip install succeeds, find_cm_cli cache should be cleared."""
from comfy_cli.command.install import pip_install_manager

# Simulate successful pip install
mock_run.return_value = MagicMock(returncode=0, stderr="")
mock_install.return_value = MagicMock(returncode=0, stderr="")

# Call pip_install_manager
result = pip_install_manager("/fake/repo")
Expand All @@ -793,14 +793,14 @@ def test_pip_install_manager_clears_cache_on_success(self, mock_exists, mock_run
# Verify cache_clear was called on the mock
mock_find_cm_cli.cache_clear.assert_called_once()

@patch("comfy_cli.command.install.subprocess.run")
@patch("comfy_cli.command.install.run_pip_install")
@patch("os.path.exists", return_value=True)
def test_pip_install_manager_no_cache_clear_on_failure(self, mock_exists, mock_run):
def test_pip_install_manager_no_cache_clear_on_failure(self, mock_exists, mock_install):
"""When pip install fails, cache should not be affected (function returns early)."""
from comfy_cli.command.install import pip_install_manager

# Simulate failed pip install
mock_run.return_value = MagicMock(returncode=1)
mock_install.return_value = MagicMock(returncode=1)

# Call pip_install_manager
result = pip_install_manager("/fake/repo")
Expand Down Expand Up @@ -1119,17 +1119,16 @@ def test_find_cm_cli_cache_behavior(self):
class TestPipInstallManagerEdgeCases:
"""Additional edge case tests for pip_install_manager()."""

@patch("comfy_cli.command.install.subprocess.run")
@patch("comfy_cli.command.install.run_pip_install")
@patch("os.path.exists", return_value=False)
def test_pip_install_manager_requirements_not_found(self, mock_exists, mock_run):
def test_pip_install_manager_requirements_not_found(self, mock_exists, mock_install):
"""When requirements file doesn't exist, should return False without calling pip."""
from comfy_cli.command.install import pip_install_manager

result = pip_install_manager("/fake/repo")

assert result is False
# subprocess.run should NOT be called
mock_run.assert_not_called()
mock_install.assert_not_called()


class TestValidateComfyuiManager:
Expand Down
13 changes: 4 additions & 9 deletions tests/comfy_cli/test_cmdline_python_resolution.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,14 @@ def test_uses_resolved_python(self, tmp_path):
patch.object(cmdline.workspace_manager, "workspace_path", str(tmp_path)),
patch("comfy_cli.cmdline.os.chdir"),
patch("comfy_cli.cmdline.subprocess.run") as mock_run,
patch("comfy_cli.cmdline.run_pip_install") as mock_install,
patch("comfy_cli.cmdline.custom_nodes.command.update_node_id_cache"),
):
cmdline.update(target="comfy")

mock_resolve.assert_called_once_with(str(tmp_path))
pip_call = None
for c in mock_run.call_args_list:
cmd = c[0][0]
if "-m" in cmd and "pip" in cmd:
pip_call = cmd
break

assert pip_call is not None, "pip install call not found"
assert pip_call[0] == "/resolved/python"
mock_run.assert_called_once_with(["git", "pull"], check=True)
mock_install.assert_called_once_with(executable="/resolved/python", args=["-r", "requirements.txt"], check=True)

def test_update_comfy_succeeds_when_cm_cli_missing(self, tmp_path):
"""Regression test for #403: comfy update must not crash when cm-cli is absent."""
Expand All @@ -32,6 +26,7 @@ def test_update_comfy_succeeds_when_cm_cli_missing(self, tmp_path):
patch.object(cmdline.workspace_manager, "workspace_path", str(tmp_path)),
patch("comfy_cli.cmdline.os.chdir"),
patch("comfy_cli.cmdline.subprocess.run"),
patch("comfy_cli.cmdline.run_pip_install"),
patch(
"comfy_cli.cmdline.custom_nodes.command.update_node_id_cache",
side_effect=FileNotFoundError("cm-cli not found"),
Expand Down
20 changes: 10 additions & 10 deletions tests/comfy_cli/test_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,30 +33,30 @@ def test_validate_version_empty():

class TestPipInstallManager:
@patch("comfy_cli.command.custom_nodes.cm_cli_util.find_cm_cli")
@patch("comfy_cli.command.install.subprocess.run")
@patch("comfy_cli.command.install.run_pip_install")
@patch("os.path.exists", return_value=True)
def test_success(self, mock_exists, mock_run, mock_find):
mock_run.return_value = MagicMock(returncode=0)
def test_success(self, mock_exists, mock_install, mock_find):
mock_install.return_value = MagicMock(returncode=0)
result = pip_install_manager("/fake/repo")
assert result is True
mock_run.assert_called_once()
mock_install.assert_called_once()

@patch("os.path.exists", return_value=False)
def test_missing_requirements_file(self, mock_exists):
result = pip_install_manager("/fake/repo")
assert result is False

@patch("comfy_cli.command.install.subprocess.run")
@patch("comfy_cli.command.install.run_pip_install")
@patch("os.path.exists", return_value=True)
def test_pip_failure(self, mock_exists, mock_run):
mock_run.return_value = MagicMock(returncode=1, stderr="some error")
def test_pip_failure(self, mock_exists, mock_install):
mock_install.return_value = MagicMock(returncode=1, stderr="some error")
result = pip_install_manager("/fake/repo")
assert result is False

@patch("comfy_cli.command.install.subprocess.run")
@patch("comfy_cli.command.install.run_pip_install")
@patch("os.path.exists", return_value=True)
def test_pip_failure_no_stderr(self, mock_exists, mock_run):
mock_run.return_value = MagicMock(returncode=1, stderr="")
def test_pip_failure_no_stderr(self, mock_exists, mock_install):
mock_install.return_value = MagicMock(returncode=1, stderr="")
result = pip_install_manager("/fake/repo")
assert result is False

Expand Down
Loading