comfy-cli 1.7.2__tar.gz → 1.7.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {comfy_cli-1.7.2 → comfy_cli-1.7.3}/PKG-INFO +1 -1
- {comfy_cli-1.7.2 → comfy_cli-1.7.3}/comfy_cli/command/custom_nodes/command.py +12 -8
- {comfy_cli-1.7.2 → comfy_cli-1.7.3}/comfy_cli/command/models/models.py +11 -2
- {comfy_cli-1.7.2 → comfy_cli-1.7.3}/comfy_cli/constants.py +1 -0
- {comfy_cli-1.7.2 → comfy_cli-1.7.3}/comfy_cli/file_utils.py +38 -1
- {comfy_cli-1.7.2 → comfy_cli-1.7.3}/comfy_cli/registry/config_parser.py +19 -1
- {comfy_cli-1.7.2 → comfy_cli-1.7.3}/comfy_cli/tracking.py +3 -2
- {comfy_cli-1.7.2 → comfy_cli-1.7.3}/comfy_cli/ui.py +4 -2
- {comfy_cli-1.7.2 → comfy_cli-1.7.3}/comfy_cli/uv.py +7 -2
- {comfy_cli-1.7.2 → comfy_cli-1.7.3}/comfy_cli.egg-info/PKG-INFO +1 -1
- {comfy_cli-1.7.2 → comfy_cli-1.7.3}/pyproject.toml +1 -1
- {comfy_cli-1.7.2 → comfy_cli-1.7.3}/tests/test_file_utils_network.py +332 -0
- {comfy_cli-1.7.2 → comfy_cli-1.7.3}/LICENSE +0 -0
- {comfy_cli-1.7.2 → comfy_cli-1.7.3}/README.md +0 -0
- {comfy_cli-1.7.2 → comfy_cli-1.7.3}/comfy_cli/__init__.py +0 -0
- {comfy_cli-1.7.2 → comfy_cli-1.7.3}/comfy_cli/__main__.py +0 -0
- {comfy_cli-1.7.2 → comfy_cli-1.7.3}/comfy_cli/cmdline.py +0 -0
- {comfy_cli-1.7.2 → comfy_cli-1.7.3}/comfy_cli/command/__init__.py +0 -0
- {comfy_cli-1.7.2 → comfy_cli-1.7.3}/comfy_cli/command/code_search.py +0 -0
- {comfy_cli-1.7.2 → comfy_cli-1.7.3}/comfy_cli/command/custom_nodes/__init__.py +0 -0
- {comfy_cli-1.7.2 → comfy_cli-1.7.3}/comfy_cli/command/custom_nodes/bisect_custom_nodes.py +0 -0
- {comfy_cli-1.7.2 → comfy_cli-1.7.3}/comfy_cli/command/custom_nodes/cm_cli_util.py +0 -0
- {comfy_cli-1.7.2 → comfy_cli-1.7.3}/comfy_cli/command/github/pr_info.py +0 -0
- {comfy_cli-1.7.2 → comfy_cli-1.7.3}/comfy_cli/command/install.py +0 -0
- {comfy_cli-1.7.2 → comfy_cli-1.7.3}/comfy_cli/command/launch.py +0 -0
- {comfy_cli-1.7.2 → comfy_cli-1.7.3}/comfy_cli/command/pr_command.py +0 -0
- {comfy_cli-1.7.2 → comfy_cli-1.7.3}/comfy_cli/command/run.py +0 -0
- {comfy_cli-1.7.2 → comfy_cli-1.7.3}/comfy_cli/config_manager.py +0 -0
- {comfy_cli-1.7.2 → comfy_cli-1.7.3}/comfy_cli/cuda_detect.py +0 -0
- {comfy_cli-1.7.2 → comfy_cli-1.7.3}/comfy_cli/env_checker.py +0 -0
- {comfy_cli-1.7.2 → comfy_cli-1.7.3}/comfy_cli/git_utils.py +0 -0
- {comfy_cli-1.7.2 → comfy_cli-1.7.3}/comfy_cli/logging.py +0 -0
- {comfy_cli-1.7.2 → comfy_cli-1.7.3}/comfy_cli/pr_cache.py +0 -0
- {comfy_cli-1.7.2 → comfy_cli-1.7.3}/comfy_cli/registry/__init__.py +0 -0
- {comfy_cli-1.7.2 → comfy_cli-1.7.3}/comfy_cli/registry/api.py +0 -0
- {comfy_cli-1.7.2 → comfy_cli-1.7.3}/comfy_cli/registry/types.py +0 -0
- {comfy_cli-1.7.2 → comfy_cli-1.7.3}/comfy_cli/resolve_python.py +0 -0
- {comfy_cli-1.7.2 → comfy_cli-1.7.3}/comfy_cli/standalone.py +0 -0
- {comfy_cli-1.7.2 → comfy_cli-1.7.3}/comfy_cli/typing.py +0 -0
- {comfy_cli-1.7.2 → comfy_cli-1.7.3}/comfy_cli/update.py +0 -0
- {comfy_cli-1.7.2 → comfy_cli-1.7.3}/comfy_cli/utils.py +0 -0
- {comfy_cli-1.7.2 → comfy_cli-1.7.3}/comfy_cli/workspace_manager.py +0 -0
- {comfy_cli-1.7.2 → comfy_cli-1.7.3}/comfy_cli.egg-info/SOURCES.txt +0 -0
- {comfy_cli-1.7.2 → comfy_cli-1.7.3}/comfy_cli.egg-info/dependency_links.txt +0 -0
- {comfy_cli-1.7.2 → comfy_cli-1.7.3}/comfy_cli.egg-info/entry_points.txt +0 -0
- {comfy_cli-1.7.2 → comfy_cli-1.7.3}/comfy_cli.egg-info/requires.txt +0 -0
- {comfy_cli-1.7.2 → comfy_cli-1.7.3}/comfy_cli.egg-info/top_level.txt +0 -0
- {comfy_cli-1.7.2 → comfy_cli-1.7.3}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: comfy-cli
|
|
3
|
-
Version: 1.7.
|
|
3
|
+
Version: 1.7.3
|
|
4
4
|
Summary: A CLI tool for installing and using ComfyUI.
|
|
5
5
|
Maintainer-email: Yoland Yan <yoland@drip.art>, James Kwon <hongilkwon316@gmail.com>, Robin Huang <robin@drip.art>, "Dr.Lt.Data" <dr.lt.data@gmail.com>
|
|
6
6
|
License: GPL-3.0-only
|
|
@@ -18,6 +18,7 @@ from comfy_cli.command.custom_nodes.cm_cli_util import execute_cm_cli, find_cm_c
|
|
|
18
18
|
from comfy_cli.config_manager import ConfigManager
|
|
19
19
|
from comfy_cli.constants import NODE_ZIP_FILENAME
|
|
20
20
|
from comfy_cli.file_utils import (
|
|
21
|
+
DownloadException,
|
|
21
22
|
download_file,
|
|
22
23
|
extract_package_as_zip,
|
|
23
24
|
upload_file_to_signed_url,
|
|
@@ -170,13 +171,11 @@ def execute_install_script(repo_path):
|
|
|
170
171
|
if os.path.exists(requirements_path):
|
|
171
172
|
print("Install: pip packages")
|
|
172
173
|
python = resolve_workspace_python(workspace_manager.workspace_path)
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
if package_name.strip() != "":
|
|
179
|
-
try_install_script(repo_path, install_cmd)
|
|
174
|
+
# Absolute path so pip doesn't re-resolve it against cwd=repo_path
|
|
175
|
+
# in try_install_script, which would double the path if repo_path
|
|
176
|
+
# is relative.
|
|
177
|
+
install_cmd = [python, "-m", "pip", "install", "-r", os.path.abspath(requirements_path)]
|
|
178
|
+
try_install_script(repo_path, install_cmd)
|
|
180
179
|
|
|
181
180
|
if os.path.exists(install_script_path):
|
|
182
181
|
print("Install: install script")
|
|
@@ -1168,7 +1167,12 @@ def registry_install(
|
|
|
1168
1167
|
|
|
1169
1168
|
local_filename = node_specific_path / f"{node_id}-{node_version.version}.zip"
|
|
1170
1169
|
logging.debug(f"Start downloading the node {node_id} version {node_version.version} to {local_filename}")
|
|
1171
|
-
|
|
1170
|
+
try:
|
|
1171
|
+
download_file(node_version.download_url, local_filename)
|
|
1172
|
+
except DownloadException as e:
|
|
1173
|
+
logging.error(f"Failed to download node {node_id} version {node_version.version}: {e}")
|
|
1174
|
+
ui.display_error_message(f"Failed to download the custom node {node_id}: {e}")
|
|
1175
|
+
raise typer.Exit(code=1) from None
|
|
1172
1176
|
|
|
1173
1177
|
# Extract the downloaded archive to the custom_node directory on the workspace.
|
|
1174
1178
|
logging.debug(f"Start extracting the node {node_id} version {node_version.version} to {custom_nodes_path}")
|
|
@@ -8,6 +8,7 @@ from urllib.parse import parse_qs, unquote, urlparse
|
|
|
8
8
|
import requests
|
|
9
9
|
import typer
|
|
10
10
|
from rich import print
|
|
11
|
+
from rich.markup import escape
|
|
11
12
|
|
|
12
13
|
from comfy_cli import constants, tracking, ui
|
|
13
14
|
from comfy_cli.config_manager import ConfigManager
|
|
@@ -20,6 +21,8 @@ app = typer.Typer()
|
|
|
20
21
|
workspace_manager = WorkspaceManager()
|
|
21
22
|
config_manager = ConfigManager()
|
|
22
23
|
|
|
24
|
+
_CIVITAI_SUBDOMAIN_SUFFIXES = tuple(f".{h}" for h in constants.CIVITAI_ALLOWED_HOSTS)
|
|
25
|
+
|
|
23
26
|
|
|
24
27
|
model_path_map = {
|
|
25
28
|
"lora": "loras",
|
|
@@ -98,7 +101,7 @@ def check_civitai_url(url: str) -> tuple[bool, bool, int | None, int | None]:
|
|
|
98
101
|
try:
|
|
99
102
|
parsed = urlparse(url)
|
|
100
103
|
host = (parsed.hostname or "").lower()
|
|
101
|
-
if host
|
|
104
|
+
if host not in constants.CIVITAI_ALLOWED_HOSTS and not host.endswith(_CIVITAI_SUBDOMAIN_SUFFIXES):
|
|
102
105
|
return False, False, None, None
|
|
103
106
|
p_parts = [p for p in parsed.path.split("/") if p]
|
|
104
107
|
query = parse_qs(parsed.query)
|
|
@@ -354,7 +357,13 @@ def download(
|
|
|
354
357
|
print(f"Model downloaded successfully to: {output_path}")
|
|
355
358
|
else:
|
|
356
359
|
print(f"Start downloading URL: {url} into {local_filepath}")
|
|
357
|
-
|
|
360
|
+
try:
|
|
361
|
+
download_file(url, local_filepath, headers, downloader=resolved_downloader)
|
|
362
|
+
except DownloadException as e:
|
|
363
|
+
# escape() so a dynamic error message containing "[/]" or similar
|
|
364
|
+
# rich-markup syntax doesn't trigger MarkupError or get mis-rendered.
|
|
365
|
+
print(f"[bold red]{escape(str(e))}[/bold red]")
|
|
366
|
+
raise typer.Exit(code=1) from None
|
|
358
367
|
|
|
359
368
|
elapsed = time.monotonic() - start_time
|
|
360
369
|
print(f"Done in {_format_elapsed(elapsed)}")
|
|
@@ -47,6 +47,7 @@ CONFIG_KEY_UV_COMPILE_DEFAULT = "uv_compile_default"
|
|
|
47
47
|
|
|
48
48
|
CIVITAI_API_TOKEN_KEY = "civitai_api_token"
|
|
49
49
|
CIVITAI_API_TOKEN_ENV_KEY = "CIVITAI_API_TOKEN"
|
|
50
|
+
CIVITAI_ALLOWED_HOSTS: tuple[str, ...] = ("civitai.com", "civitai.red")
|
|
50
51
|
HF_API_TOKEN_KEY = "hf_api_token"
|
|
51
52
|
HF_API_TOKEN_ENV_KEY = "HF_API_TOKEN"
|
|
52
53
|
|
|
@@ -4,6 +4,7 @@ import pathlib
|
|
|
4
4
|
import subprocess
|
|
5
5
|
import time
|
|
6
6
|
import zipfile
|
|
7
|
+
from http import HTTPStatus
|
|
7
8
|
|
|
8
9
|
import httpx
|
|
9
10
|
import requests
|
|
@@ -82,6 +83,7 @@ def _poll_aria2_download(download) -> None:
|
|
|
82
83
|
DownloadColumn(),
|
|
83
84
|
TransferSpeedColumn(),
|
|
84
85
|
TimeRemainingColumn(),
|
|
86
|
+
transient=True,
|
|
85
87
|
) as progress:
|
|
86
88
|
task = progress.add_task("Downloading...", total=None)
|
|
87
89
|
|
|
@@ -172,6 +174,22 @@ _TRANSIENT_EXCEPTIONS = (
|
|
|
172
174
|
httpx.ProtocolError,
|
|
173
175
|
httpx.ProxyError,
|
|
174
176
|
)
|
|
177
|
+
# HTTP statuses that typically indicate a transient server-side or rate-limit
|
|
178
|
+
# problem worth retrying with backoff. Auth/not-found/redirect statuses stay
|
|
179
|
+
# out of this set so they fail fast.
|
|
180
|
+
_RETRIABLE_STATUSES = frozenset({408, 429, 500, 502, 503, 504})
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class _TransientHTTPStatusError(Exception):
|
|
184
|
+
"""Retriable HTTP status returned by the server (e.g. 500/503/429)."""
|
|
185
|
+
|
|
186
|
+
def __init__(self, status_code: int, reason: str):
|
|
187
|
+
self.status_code = status_code
|
|
188
|
+
self.reason = reason
|
|
189
|
+
super().__init__(f"HTTP {status_code}: {reason}")
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
_RETRIABLE_EXCEPTIONS = _TRANSIENT_EXCEPTIONS + (_TransientHTTPStatusError,)
|
|
175
193
|
|
|
176
194
|
|
|
177
195
|
def _cleanup_partial(filepath: pathlib.Path) -> None:
|
|
@@ -184,6 +202,14 @@ def _cleanup_partial(filepath: pathlib.Path) -> None:
|
|
|
184
202
|
|
|
185
203
|
def _friendly_network_error(exc: Exception) -> str:
|
|
186
204
|
"""Return a user-friendly description of a network error."""
|
|
205
|
+
if isinstance(exc, _TransientHTTPStatusError):
|
|
206
|
+
try:
|
|
207
|
+
phrase = HTTPStatus(exc.status_code).phrase
|
|
208
|
+
return f"the server returned HTTP {exc.status_code} {phrase}"
|
|
209
|
+
except ValueError:
|
|
210
|
+
return f"the server returned HTTP {exc.status_code}"
|
|
211
|
+
if isinstance(exc, httpx.InvalidURL):
|
|
212
|
+
return f"invalid URL ({exc})"
|
|
187
213
|
if isinstance(exc, httpx.ReadTimeout):
|
|
188
214
|
return "the server stopped sending data (read timeout)"
|
|
189
215
|
if isinstance(exc, httpx.ConnectTimeout):
|
|
@@ -221,6 +247,8 @@ def _download_file_httpx(
|
|
|
221
247
|
except _TRANSIENT_EXCEPTIONS:
|
|
222
248
|
error_body = ""
|
|
223
249
|
status_reason = guess_status_code_reason(response.status_code, error_body)
|
|
250
|
+
if response.status_code in _RETRIABLE_STATUSES:
|
|
251
|
+
raise _TransientHTTPStatusError(response.status_code, status_reason)
|
|
224
252
|
raise DownloadException(f"Failed to download file.\n{status_reason}")
|
|
225
253
|
|
|
226
254
|
content_length = response.headers.get("Content-Length")
|
|
@@ -261,7 +289,7 @@ def download_file(url: str, local_filepath: pathlib.Path, headers: dict | None =
|
|
|
261
289
|
try:
|
|
262
290
|
_download_file_httpx(url, local_filepath, headers, state=state)
|
|
263
291
|
return
|
|
264
|
-
except
|
|
292
|
+
except _RETRIABLE_EXCEPTIONS as exc:
|
|
265
293
|
last_exc = exc
|
|
266
294
|
# Only clean up if _download_file_httpx actually opened the destination —
|
|
267
295
|
# otherwise we'd delete an unrelated pre-existing file at the same path.
|
|
@@ -272,6 +300,15 @@ def download_file(url: str, local_filepath: pathlib.Path, headers: dict | None =
|
|
|
272
300
|
print(f"Download error (attempt {attempt + 1}/{_DOWNLOAD_MAX_RETRIES}): {_friendly_network_error(exc)}")
|
|
273
301
|
print(f"Retrying in {wait}s...")
|
|
274
302
|
time.sleep(wait)
|
|
303
|
+
except (httpx.HTTPError, httpx.InvalidURL) as exc:
|
|
304
|
+
# Non-retriable httpx errors (e.g. UnsupportedProtocol, TooManyRedirects,
|
|
305
|
+
# DecodingError, InvalidURL). Fail fast and convert to DownloadException
|
|
306
|
+
# so callers only need to handle one error type.
|
|
307
|
+
# InvalidURL inherits directly from Exception (not HTTPError), hence the
|
|
308
|
+
# explicit inclusion.
|
|
309
|
+
if state["file_opened"]:
|
|
310
|
+
_cleanup_partial(local_filepath)
|
|
311
|
+
raise DownloadException(f"Download failed: {_friendly_network_error(exc)}") from exc
|
|
275
312
|
except KeyboardInterrupt:
|
|
276
313
|
# Only prompt/cleanup if we actually opened the destination this attempt.
|
|
277
314
|
# If the interrupt arrived during connection setup, there is no partial
|
|
@@ -17,6 +17,11 @@ from comfy_cli.registry.types import (
|
|
|
17
17
|
URLs,
|
|
18
18
|
)
|
|
19
19
|
|
|
20
|
+
# Mirrors pip's requirements-file comment rule: `#` only starts a comment when
|
|
21
|
+
# preceded by whitespace, so VCS URL fragments (`#subdirectory=`, `#egg=`) and
|
|
22
|
+
# direct-URL hashes (`#sha256=`) survive.
|
|
23
|
+
_inline_comment_re: re.Pattern[str] = re.compile(r"(^|\s+)#.*$")
|
|
24
|
+
|
|
20
25
|
|
|
21
26
|
def create_comfynode_config():
|
|
22
27
|
# Create the initial structure of the TOML document
|
|
@@ -249,7 +254,20 @@ def initialize_project_config():
|
|
|
249
254
|
# Handle dependencies
|
|
250
255
|
if os.path.exists("requirements.txt"):
|
|
251
256
|
with open("requirements.txt") as req_file:
|
|
252
|
-
dependencies = [
|
|
257
|
+
dependencies: list[str] = []
|
|
258
|
+
for raw in req_file:
|
|
259
|
+
# Strip inline/full-line comments, then skip pip-requirements-file
|
|
260
|
+
# options (-r, -e, -c, --index-url, ...) which are not valid
|
|
261
|
+
# PEP 508 deps and would break downstream build tooling.
|
|
262
|
+
line = _inline_comment_re.sub("", raw).strip()
|
|
263
|
+
if not line:
|
|
264
|
+
continue
|
|
265
|
+
if line.startswith("-"):
|
|
266
|
+
print(
|
|
267
|
+
f"Warning: skipping pip-only option from requirements.txt (not valid as PEP 508 dep): {line!r}"
|
|
268
|
+
)
|
|
269
|
+
continue
|
|
270
|
+
dependencies.append(line)
|
|
253
271
|
project["dependencies"] = dependencies
|
|
254
272
|
else:
|
|
255
273
|
print("Warning: 'requirements.txt' not found. No dependencies will be added.")
|
|
@@ -45,8 +45,7 @@ def track_event(event_name: str, properties: any = None):
|
|
|
45
45
|
if properties is None:
|
|
46
46
|
properties = {}
|
|
47
47
|
logging.debug(f"tracking event called with event_name: {event_name} and properties: {properties}")
|
|
48
|
-
|
|
49
|
-
enable_tracking = False
|
|
48
|
+
enable_tracking = config_manager.get_bool(constants.CONFIG_KEY_ENABLE_TRACKING)
|
|
50
49
|
if not enable_tracking:
|
|
51
50
|
return
|
|
52
51
|
|
|
@@ -98,6 +97,7 @@ def init_tracking(enable_tracking: bool):
|
|
|
98
97
|
"""
|
|
99
98
|
Initialize the tracking system by setting the user identifier and tracking enabled status.
|
|
100
99
|
"""
|
|
100
|
+
global user_id
|
|
101
101
|
logging.debug(f"Initializing tracking with enable_tracking: {enable_tracking}")
|
|
102
102
|
config_manager.set(constants.CONFIG_KEY_ENABLE_TRACKING, str(enable_tracking))
|
|
103
103
|
if not enable_tracking:
|
|
@@ -109,6 +109,7 @@ def init_tracking(enable_tracking: bool):
|
|
|
109
109
|
curr_user_id = str(uuid.uuid4())
|
|
110
110
|
config_manager.set(constants.CONFIG_KEY_USER_ID, curr_user_id)
|
|
111
111
|
logging.debug(f'Setting user identifier for tracking user_id: {curr_user_id}."')
|
|
112
|
+
user_id = curr_user_id
|
|
112
113
|
|
|
113
114
|
# Note: only called once when the user interacts with the CLI for the
|
|
114
115
|
# first time iff the permission is granted.
|
|
@@ -28,7 +28,7 @@ def show_progress(iterable, total, description="Downloading..."):
|
|
|
28
28
|
Yields:
|
|
29
29
|
bytes: Chunks of data as they are processed.
|
|
30
30
|
"""
|
|
31
|
-
with Progress() as progress:
|
|
31
|
+
with Progress(transient=True) as progress:
|
|
32
32
|
task = progress.add_task(description, total=total)
|
|
33
33
|
for chunk in iterable:
|
|
34
34
|
yield chunk
|
|
@@ -181,4 +181,6 @@ def display_error_message(message: str) -> None:
|
|
|
181
181
|
Args:
|
|
182
182
|
message (str): The error message to display.
|
|
183
183
|
"""
|
|
184
|
-
|
|
184
|
+
# markup=False so a dynamic message containing e.g. "[/]" doesn't raise
|
|
185
|
+
# MarkupError or silently strip bracketed substrings.
|
|
186
|
+
console.print(message, style="red", markup=False)
|
|
@@ -37,6 +37,11 @@ def _check_call(cmd: list[str], cwd: PathLike | None = None):
|
|
|
37
37
|
|
|
38
38
|
_req_name_re: re.Pattern[str] = re.compile(r"require\s([\w-]+)")
|
|
39
39
|
|
|
40
|
+
# Mirrors pip's requirements-file comment rule (pip._internal.req.req_file.COMMENT_RE):
|
|
41
|
+
# `#` only starts a comment when preceded by whitespace (or starts the line), so
|
|
42
|
+
# VCS URL fragments like `#subdirectory=pkg` and `#egg=foo` survive.
|
|
43
|
+
_inline_comment_re: re.Pattern[str] = re.compile(r"(^|\s+)#.*$")
|
|
44
|
+
|
|
40
45
|
|
|
41
46
|
def _req_re_closure(name: str) -> re.Pattern[str]:
|
|
42
47
|
return re.compile(rf"({name}\S+)")
|
|
@@ -64,8 +69,8 @@ def parse_req_file(rf: PathLike, skips: list[str] | None = None):
|
|
|
64
69
|
opts: list[str] = []
|
|
65
70
|
with open(rf) as f:
|
|
66
71
|
for line in f:
|
|
67
|
-
line = line.strip()
|
|
68
|
-
if not line
|
|
72
|
+
line = _inline_comment_re.sub("", line).strip()
|
|
73
|
+
if not line:
|
|
69
74
|
continue
|
|
70
75
|
elif "==" in line and line.split("==")[0] in skips:
|
|
71
76
|
continue
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: comfy-cli
|
|
3
|
-
Version: 1.7.
|
|
3
|
+
Version: 1.7.3
|
|
4
4
|
Summary: A CLI tool for installing and using ComfyUI.
|
|
5
5
|
Maintainer-email: Yoland Yan <yoland@drip.art>, James Kwon <hongilkwon316@gmail.com>, Robin Huang <robin@drip.art>, "Dr.Lt.Data" <dr.lt.data@gmail.com>
|
|
6
6
|
License: GPL-3.0-only
|
|
@@ -5,7 +5,7 @@ requires = [ "setuptools>=61" ]
|
|
|
5
5
|
|
|
6
6
|
[project]
|
|
7
7
|
name = "comfy-cli"
|
|
8
|
-
version = "v1.7.
|
|
8
|
+
version = "v1.7.3" # Will be filled in by the CI/CD pipeline. Check publish_package.py.
|
|
9
9
|
description = "A CLI tool for installing and using ComfyUI."
|
|
10
10
|
readme = "README.md"
|
|
11
11
|
keywords = [ "comfyui", "stable diffusion" ]
|
|
@@ -10,6 +10,7 @@ from comfy_cli.file_utils import (
|
|
|
10
10
|
DownloadException,
|
|
11
11
|
_cleanup_partial,
|
|
12
12
|
_friendly_network_error,
|
|
13
|
+
_TransientHTTPStatusError,
|
|
13
14
|
check_unauthorized,
|
|
14
15
|
download_file,
|
|
15
16
|
extract_package_as_zip,
|
|
@@ -192,6 +193,16 @@ def _make_failing_iter(data=b"partial", exc=None):
|
|
|
192
193
|
return factory
|
|
193
194
|
|
|
194
195
|
|
|
196
|
+
def _make_status_response(status_code, body=b""):
|
|
197
|
+
"""Create a mock httpx response for a non-200 status."""
|
|
198
|
+
mock = Mock()
|
|
199
|
+
mock.status_code = status_code
|
|
200
|
+
mock.read.return_value = body
|
|
201
|
+
mock.__enter__ = Mock(return_value=mock)
|
|
202
|
+
mock.__exit__ = Mock(return_value=None)
|
|
203
|
+
return mock
|
|
204
|
+
|
|
205
|
+
|
|
195
206
|
class TestCleanupPartial:
|
|
196
207
|
def test_removes_existing_file(self, tmp_path):
|
|
197
208
|
f = tmp_path / "partial.bin"
|
|
@@ -236,6 +247,28 @@ class TestFriendlyNetworkError:
|
|
|
236
247
|
msg = _friendly_network_error(RuntimeError("boom"))
|
|
237
248
|
assert msg == "boom"
|
|
238
249
|
|
|
250
|
+
def test_transient_http_status_known_code_includes_phrase(self):
|
|
251
|
+
# HTTP 503 -> "Service Unavailable" (from stdlib http.HTTPStatus).
|
|
252
|
+
msg = _friendly_network_error(_TransientHTTPStatusError(503, "some reason from body"))
|
|
253
|
+
assert "HTTP 503" in msg
|
|
254
|
+
assert "Service Unavailable" in msg
|
|
255
|
+
|
|
256
|
+
def test_transient_http_status_500_includes_phrase(self):
|
|
257
|
+
msg = _friendly_network_error(_TransientHTTPStatusError(500, ""))
|
|
258
|
+
assert "HTTP 500" in msg
|
|
259
|
+
assert "Internal Server Error" in msg
|
|
260
|
+
|
|
261
|
+
def test_transient_http_status_unknown_code_falls_back(self):
|
|
262
|
+
# 599 is not a standard HTTPStatus; fall back to just the numeric code.
|
|
263
|
+
msg = _friendly_network_error(_TransientHTTPStatusError(599, "weird"))
|
|
264
|
+
assert "HTTP 599" in msg
|
|
265
|
+
# No crash, no stdlib phrase embedded (since there isn't one).
|
|
266
|
+
|
|
267
|
+
def test_invalid_url(self):
|
|
268
|
+
msg = _friendly_network_error(httpx.InvalidURL("Request URL is missing a scheme"))
|
|
269
|
+
assert "invalid URL" in msg
|
|
270
|
+
assert "missing a scheme" in msg
|
|
271
|
+
|
|
239
272
|
|
|
240
273
|
class TestDownloadTimeout:
|
|
241
274
|
@patch("httpx.stream")
|
|
@@ -528,3 +561,302 @@ class TestDownloadPartialCleanup:
|
|
|
528
561
|
mock_prompt.assert_called_once()
|
|
529
562
|
assert dest.exists()
|
|
530
563
|
assert dest.read_bytes() == b"partial data"
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
class TestDownloadHTTPStatusRetry:
|
|
567
|
+
"""Retry behavior for transient HTTP status codes (5xx, 429, 408)."""
|
|
568
|
+
|
|
569
|
+
@patch("comfy_cli.file_utils.time.sleep")
|
|
570
|
+
@patch("httpx.stream")
|
|
571
|
+
def test_500_retried_and_succeeds(self, mock_stream, mock_sleep, tmp_path):
|
|
572
|
+
"""Download retries on HTTP 500 and succeeds on the next attempt."""
|
|
573
|
+
mock_stream.side_effect = [
|
|
574
|
+
_make_status_response(500),
|
|
575
|
+
_make_ok_response(content=b"ok"),
|
|
576
|
+
]
|
|
577
|
+
|
|
578
|
+
dest = tmp_path / "model.bin"
|
|
579
|
+
download_file("http://example.com/model.bin", dest)
|
|
580
|
+
|
|
581
|
+
assert dest.read_bytes() == b"ok"
|
|
582
|
+
assert mock_stream.call_count == 2
|
|
583
|
+
mock_sleep.assert_called_once_with(2)
|
|
584
|
+
|
|
585
|
+
@patch("comfy_cli.file_utils.time.sleep")
|
|
586
|
+
@patch("httpx.stream")
|
|
587
|
+
def test_502_retried(self, mock_stream, mock_sleep, tmp_path):
|
|
588
|
+
mock_stream.side_effect = [
|
|
589
|
+
_make_status_response(502),
|
|
590
|
+
_make_ok_response(content=b"ok"),
|
|
591
|
+
]
|
|
592
|
+
|
|
593
|
+
dest = tmp_path / "model.bin"
|
|
594
|
+
download_file("http://example.com/model.bin", dest)
|
|
595
|
+
assert mock_stream.call_count == 2
|
|
596
|
+
|
|
597
|
+
@patch("comfy_cli.file_utils.time.sleep")
|
|
598
|
+
@patch("httpx.stream")
|
|
599
|
+
def test_503_retried(self, mock_stream, mock_sleep, tmp_path):
|
|
600
|
+
mock_stream.side_effect = [
|
|
601
|
+
_make_status_response(503),
|
|
602
|
+
_make_ok_response(content=b"ok"),
|
|
603
|
+
]
|
|
604
|
+
|
|
605
|
+
dest = tmp_path / "model.bin"
|
|
606
|
+
download_file("http://example.com/model.bin", dest)
|
|
607
|
+
assert mock_stream.call_count == 2
|
|
608
|
+
|
|
609
|
+
@patch("comfy_cli.file_utils.time.sleep")
|
|
610
|
+
@patch("httpx.stream")
|
|
611
|
+
def test_504_retried(self, mock_stream, mock_sleep, tmp_path):
|
|
612
|
+
mock_stream.side_effect = [
|
|
613
|
+
_make_status_response(504),
|
|
614
|
+
_make_ok_response(content=b"ok"),
|
|
615
|
+
]
|
|
616
|
+
|
|
617
|
+
dest = tmp_path / "model.bin"
|
|
618
|
+
download_file("http://example.com/model.bin", dest)
|
|
619
|
+
assert mock_stream.call_count == 2
|
|
620
|
+
|
|
621
|
+
@patch("comfy_cli.file_utils.time.sleep")
|
|
622
|
+
@patch("httpx.stream")
|
|
623
|
+
def test_429_retried(self, mock_stream, mock_sleep, tmp_path):
|
|
624
|
+
mock_stream.side_effect = [
|
|
625
|
+
_make_status_response(429),
|
|
626
|
+
_make_ok_response(content=b"ok"),
|
|
627
|
+
]
|
|
628
|
+
|
|
629
|
+
dest = tmp_path / "model.bin"
|
|
630
|
+
download_file("http://example.com/model.bin", dest)
|
|
631
|
+
assert mock_stream.call_count == 2
|
|
632
|
+
|
|
633
|
+
@patch("comfy_cli.file_utils.time.sleep")
|
|
634
|
+
@patch("httpx.stream")
|
|
635
|
+
def test_408_retried(self, mock_stream, mock_sleep, tmp_path):
|
|
636
|
+
mock_stream.side_effect = [
|
|
637
|
+
_make_status_response(408),
|
|
638
|
+
_make_ok_response(content=b"ok"),
|
|
639
|
+
]
|
|
640
|
+
|
|
641
|
+
dest = tmp_path / "model.bin"
|
|
642
|
+
download_file("http://example.com/model.bin", dest)
|
|
643
|
+
assert mock_stream.call_count == 2
|
|
644
|
+
|
|
645
|
+
@patch("comfy_cli.file_utils.time.sleep")
|
|
646
|
+
@patch("httpx.stream")
|
|
647
|
+
def test_all_retries_exhausted_on_500(self, mock_stream, mock_sleep, tmp_path):
|
|
648
|
+
"""After 3 failed attempts on 500, a DownloadException is raised with a friendly message."""
|
|
649
|
+
mock_stream.side_effect = [
|
|
650
|
+
_make_status_response(500),
|
|
651
|
+
_make_status_response(500),
|
|
652
|
+
_make_status_response(500),
|
|
653
|
+
]
|
|
654
|
+
|
|
655
|
+
dest = tmp_path / "model.bin"
|
|
656
|
+
with pytest.raises(DownloadException, match="Download failed after 3 attempts") as exc_info:
|
|
657
|
+
download_file("http://example.com/model.bin", dest)
|
|
658
|
+
|
|
659
|
+
assert "HTTP 500" in str(exc_info.value)
|
|
660
|
+
# The stdlib HTTPStatus phrase is surfaced so the user knows what 500 means.
|
|
661
|
+
assert "Internal Server Error" in str(exc_info.value)
|
|
662
|
+
assert mock_stream.call_count == 3
|
|
663
|
+
# The last transient HTTP error must be chained as __cause__ for debuggability.
|
|
664
|
+
assert isinstance(exc_info.value.__cause__, _TransientHTTPStatusError)
|
|
665
|
+
assert exc_info.value.__cause__.status_code == 500
|
|
666
|
+
|
|
667
|
+
@patch("comfy_cli.file_utils.time.sleep")
|
|
668
|
+
@patch("httpx.stream")
|
|
669
|
+
def test_retry_body_read_timeout_still_retries(self, mock_stream, mock_sleep, tmp_path):
|
|
670
|
+
"""If reading the 500 response body itself times out, we still retry the request."""
|
|
671
|
+
fail_resp = Mock()
|
|
672
|
+
fail_resp.status_code = 500
|
|
673
|
+
fail_resp.read.side_effect = httpx.ReadTimeout("body read timed out")
|
|
674
|
+
fail_resp.__enter__ = Mock(return_value=fail_resp)
|
|
675
|
+
fail_resp.__exit__ = Mock(return_value=None)
|
|
676
|
+
|
|
677
|
+
mock_stream.side_effect = [fail_resp, _make_ok_response(content=b"ok")]
|
|
678
|
+
|
|
679
|
+
dest = tmp_path / "model.bin"
|
|
680
|
+
download_file("http://example.com/model.bin", dest)
|
|
681
|
+
|
|
682
|
+
assert dest.read_bytes() == b"ok"
|
|
683
|
+
assert mock_stream.call_count == 2
|
|
684
|
+
|
|
685
|
+
@patch("comfy_cli.file_utils.time.sleep")
|
|
686
|
+
@patch("httpx.stream")
|
|
687
|
+
def test_mixed_transient_errors_eventually_succeed(self, mock_stream, mock_sleep, tmp_path):
|
|
688
|
+
"""Retries work across a mix of network-level and HTTP-status errors."""
|
|
689
|
+
mock_stream.side_effect = [
|
|
690
|
+
_make_status_response(503),
|
|
691
|
+
httpx.ReadTimeout("timeout"),
|
|
692
|
+
_make_ok_response(content=b"finally"),
|
|
693
|
+
]
|
|
694
|
+
|
|
695
|
+
dest = tmp_path / "model.bin"
|
|
696
|
+
download_file("http://example.com/model.bin", dest)
|
|
697
|
+
|
|
698
|
+
assert dest.read_bytes() == b"finally"
|
|
699
|
+
assert mock_stream.call_count == 3
|
|
700
|
+
|
|
701
|
+
@patch("comfy_cli.file_utils.time.sleep")
|
|
702
|
+
@patch("httpx.stream")
|
|
703
|
+
def test_404_not_retried(self, mock_stream, mock_sleep, tmp_path):
|
|
704
|
+
"""404 fails fast without retry."""
|
|
705
|
+
mock_stream.return_value = _make_status_response(404)
|
|
706
|
+
|
|
707
|
+
with pytest.raises(DownloadException, match="Failed to download file"):
|
|
708
|
+
download_file("http://example.com/model.bin", tmp_path / "model.bin")
|
|
709
|
+
|
|
710
|
+
assert mock_stream.call_count == 1
|
|
711
|
+
mock_sleep.assert_not_called()
|
|
712
|
+
|
|
713
|
+
@patch("comfy_cli.file_utils.time.sleep")
|
|
714
|
+
@patch("httpx.stream")
|
|
715
|
+
def test_401_not_retried(self, mock_stream, mock_sleep, tmp_path):
|
|
716
|
+
"""401 fails fast without retry."""
|
|
717
|
+
mock_stream.return_value = _make_status_response(401)
|
|
718
|
+
|
|
719
|
+
with pytest.raises(DownloadException, match="Failed to download file"):
|
|
720
|
+
download_file("http://example.com/model.bin", tmp_path / "model.bin")
|
|
721
|
+
|
|
722
|
+
assert mock_stream.call_count == 1
|
|
723
|
+
mock_sleep.assert_not_called()
|
|
724
|
+
|
|
725
|
+
@patch("comfy_cli.file_utils.time.sleep")
|
|
726
|
+
@patch("httpx.stream")
|
|
727
|
+
def test_403_not_retried(self, mock_stream, mock_sleep, tmp_path):
|
|
728
|
+
"""403 fails fast without retry."""
|
|
729
|
+
mock_stream.return_value = _make_status_response(403)
|
|
730
|
+
|
|
731
|
+
with pytest.raises(DownloadException, match="Failed to download file"):
|
|
732
|
+
download_file("http://example.com/model.bin", tmp_path / "model.bin")
|
|
733
|
+
|
|
734
|
+
assert mock_stream.call_count == 1
|
|
735
|
+
mock_sleep.assert_not_called()
|
|
736
|
+
|
|
737
|
+
@patch("comfy_cli.file_utils.time.sleep")
|
|
738
|
+
@patch("httpx.stream")
|
|
739
|
+
def test_preexisting_file_preserved_on_http_status_retry_exhaust(self, mock_stream, mock_sleep, tmp_path):
|
|
740
|
+
"""A pre-existing file at the destination is NOT deleted when all retries fail on HTTP 500.
|
|
741
|
+
|
|
742
|
+
The retriable HTTP status is raised before _download_file_httpx opens the output file.
|
|
743
|
+
"""
|
|
744
|
+
mock_stream.side_effect = [
|
|
745
|
+
_make_status_response(500),
|
|
746
|
+
_make_status_response(500),
|
|
747
|
+
_make_status_response(500),
|
|
748
|
+
]
|
|
749
|
+
|
|
750
|
+
dest = tmp_path / "model.bin"
|
|
751
|
+
dest.write_bytes(b"IMPORTANT pre-existing data")
|
|
752
|
+
|
|
753
|
+
with pytest.raises(DownloadException, match="Download failed after 3 attempts"):
|
|
754
|
+
download_file("http://example.com/model.bin", dest)
|
|
755
|
+
|
|
756
|
+
assert dest.exists()
|
|
757
|
+
assert dest.read_bytes() == b"IMPORTANT pre-existing data"
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
class TestDownloadNonRetriableHTTPError:
|
|
761
|
+
"""Non-retriable httpx errors (UnsupportedProtocol, TooManyRedirects, etc.) are wrapped
|
|
762
|
+
as DownloadException so callers only need to handle one error type and users don't
|
|
763
|
+
see a raw Python traceback."""
|
|
764
|
+
|
|
765
|
+
@patch("comfy_cli.file_utils.time.sleep")
|
|
766
|
+
@patch("httpx.stream")
|
|
767
|
+
def test_unsupported_protocol_wrapped(self, mock_stream, mock_sleep, tmp_path):
|
|
768
|
+
mock_stream.side_effect = httpx.UnsupportedProtocol("Request URL has an unsupported protocol 'ftp://'")
|
|
769
|
+
|
|
770
|
+
with pytest.raises(DownloadException, match="Download failed") as exc_info:
|
|
771
|
+
download_file("ftp://example.com/model.bin", tmp_path / "model.bin")
|
|
772
|
+
|
|
773
|
+
assert isinstance(exc_info.value.__cause__, httpx.UnsupportedProtocol)
|
|
774
|
+
assert mock_stream.call_count == 1
|
|
775
|
+
mock_sleep.assert_not_called()
|
|
776
|
+
|
|
777
|
+
@patch("comfy_cli.file_utils.time.sleep")
|
|
778
|
+
@patch("httpx.stream")
|
|
779
|
+
def test_too_many_redirects_wrapped(self, mock_stream, mock_sleep, tmp_path):
|
|
780
|
+
mock_stream.side_effect = httpx.TooManyRedirects("Exceeded maximum allowed redirects")
|
|
781
|
+
|
|
782
|
+
with pytest.raises(DownloadException, match="Download failed") as exc_info:
|
|
783
|
+
download_file("http://example.com/model.bin", tmp_path / "model.bin")
|
|
784
|
+
|
|
785
|
+
assert isinstance(exc_info.value.__cause__, httpx.TooManyRedirects)
|
|
786
|
+
assert mock_stream.call_count == 1
|
|
787
|
+
mock_sleep.assert_not_called()
|
|
788
|
+
|
|
789
|
+
@patch("comfy_cli.file_utils.time.sleep")
|
|
790
|
+
@patch("httpx.stream")
|
|
791
|
+
def test_decoding_error_wrapped(self, mock_stream, mock_sleep, tmp_path):
|
|
792
|
+
mock_stream.side_effect = httpx.DecodingError("Invalid compressed data")
|
|
793
|
+
|
|
794
|
+
with pytest.raises(DownloadException, match="Download failed") as exc_info:
|
|
795
|
+
download_file("http://example.com/model.bin", tmp_path / "model.bin")
|
|
796
|
+
|
|
797
|
+
assert isinstance(exc_info.value.__cause__, httpx.DecodingError)
|
|
798
|
+
assert mock_stream.call_count == 1
|
|
799
|
+
mock_sleep.assert_not_called()
|
|
800
|
+
|
|
801
|
+
@patch("comfy_cli.file_utils.time.sleep")
|
|
802
|
+
@patch("httpx.stream")
|
|
803
|
+
def test_invalid_url_wrapped(self, mock_stream, mock_sleep, tmp_path):
|
|
804
|
+
"""httpx.InvalidURL does NOT subclass httpx.HTTPError — it must still be wrapped
|
|
805
|
+
as DownloadException so a malformed URL doesn't leak as a Typer traceback."""
|
|
806
|
+
mock_stream.side_effect = httpx.InvalidURL("Request URL is missing a scheme")
|
|
807
|
+
|
|
808
|
+
with pytest.raises(DownloadException, match="Download failed") as exc_info:
|
|
809
|
+
download_file("no-scheme-url", tmp_path / "model.bin")
|
|
810
|
+
|
|
811
|
+
assert isinstance(exc_info.value.__cause__, httpx.InvalidURL)
|
|
812
|
+
assert "invalid URL" in str(exc_info.value)
|
|
813
|
+
assert mock_stream.call_count == 1
|
|
814
|
+
mock_sleep.assert_not_called()
|
|
815
|
+
|
|
816
|
+
@patch("httpx.stream")
|
|
817
|
+
def test_invalid_url_preserves_preexisting_file(self, mock_stream, tmp_path):
|
|
818
|
+
"""InvalidURL is raised before the output file is opened — any pre-existing
|
|
819
|
+
file at the destination path must be left intact."""
|
|
820
|
+
mock_stream.side_effect = httpx.InvalidURL("bad")
|
|
821
|
+
|
|
822
|
+
dest = tmp_path / "model.bin"
|
|
823
|
+
dest.write_bytes(b"IMPORTANT pre-existing data")
|
|
824
|
+
|
|
825
|
+
with pytest.raises(DownloadException):
|
|
826
|
+
download_file("not-a-url", dest)
|
|
827
|
+
|
|
828
|
+
assert dest.exists()
|
|
829
|
+
assert dest.read_bytes() == b"IMPORTANT pre-existing data"
|
|
830
|
+
|
|
831
|
+
@patch("httpx.stream")
|
|
832
|
+
def test_preexisting_file_preserved_on_non_retriable_error(self, mock_stream, tmp_path):
|
|
833
|
+
"""A non-retriable httpx error before the output file is opened must not delete
|
|
834
|
+
an unrelated pre-existing file at the destination path."""
|
|
835
|
+
mock_stream.side_effect = httpx.UnsupportedProtocol("nope")
|
|
836
|
+
|
|
837
|
+
dest = tmp_path / "model.bin"
|
|
838
|
+
dest.write_bytes(b"IMPORTANT pre-existing data")
|
|
839
|
+
|
|
840
|
+
with pytest.raises(DownloadException):
|
|
841
|
+
download_file("ftp://example.com/model.bin", dest)
|
|
842
|
+
|
|
843
|
+
assert dest.exists()
|
|
844
|
+
assert dest.read_bytes() == b"IMPORTANT pre-existing data"
|
|
845
|
+
|
|
846
|
+
@patch("httpx.stream")
|
|
847
|
+
def test_partial_file_cleaned_up_on_mid_stream_non_retriable(self, mock_stream, tmp_path):
|
|
848
|
+
"""If a non-retriable error is raised AFTER the output file is opened (mid-stream),
|
|
849
|
+
the partial file is cleaned up."""
|
|
850
|
+
resp = Mock()
|
|
851
|
+
resp.status_code = 200
|
|
852
|
+
resp.headers = {"Content-Length": "100"}
|
|
853
|
+
resp.iter_bytes = Mock(side_effect=_make_failing_iter(b"partial", httpx.DecodingError("bad")))
|
|
854
|
+
resp.__enter__ = Mock(return_value=resp)
|
|
855
|
+
resp.__exit__ = Mock(return_value=None)
|
|
856
|
+
mock_stream.return_value = resp
|
|
857
|
+
|
|
858
|
+
dest = tmp_path / "model.bin"
|
|
859
|
+
with pytest.raises(DownloadException):
|
|
860
|
+
download_file("http://example.com/model.bin", dest)
|
|
861
|
+
|
|
862
|
+
assert not dest.exists()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|