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.
Files changed (48) hide show
  1. {comfy_cli-1.7.2 → comfy_cli-1.7.3}/PKG-INFO +1 -1
  2. {comfy_cli-1.7.2 → comfy_cli-1.7.3}/comfy_cli/command/custom_nodes/command.py +12 -8
  3. {comfy_cli-1.7.2 → comfy_cli-1.7.3}/comfy_cli/command/models/models.py +11 -2
  4. {comfy_cli-1.7.2 → comfy_cli-1.7.3}/comfy_cli/constants.py +1 -0
  5. {comfy_cli-1.7.2 → comfy_cli-1.7.3}/comfy_cli/file_utils.py +38 -1
  6. {comfy_cli-1.7.2 → comfy_cli-1.7.3}/comfy_cli/registry/config_parser.py +19 -1
  7. {comfy_cli-1.7.2 → comfy_cli-1.7.3}/comfy_cli/tracking.py +3 -2
  8. {comfy_cli-1.7.2 → comfy_cli-1.7.3}/comfy_cli/ui.py +4 -2
  9. {comfy_cli-1.7.2 → comfy_cli-1.7.3}/comfy_cli/uv.py +7 -2
  10. {comfy_cli-1.7.2 → comfy_cli-1.7.3}/comfy_cli.egg-info/PKG-INFO +1 -1
  11. {comfy_cli-1.7.2 → comfy_cli-1.7.3}/pyproject.toml +1 -1
  12. {comfy_cli-1.7.2 → comfy_cli-1.7.3}/tests/test_file_utils_network.py +332 -0
  13. {comfy_cli-1.7.2 → comfy_cli-1.7.3}/LICENSE +0 -0
  14. {comfy_cli-1.7.2 → comfy_cli-1.7.3}/README.md +0 -0
  15. {comfy_cli-1.7.2 → comfy_cli-1.7.3}/comfy_cli/__init__.py +0 -0
  16. {comfy_cli-1.7.2 → comfy_cli-1.7.3}/comfy_cli/__main__.py +0 -0
  17. {comfy_cli-1.7.2 → comfy_cli-1.7.3}/comfy_cli/cmdline.py +0 -0
  18. {comfy_cli-1.7.2 → comfy_cli-1.7.3}/comfy_cli/command/__init__.py +0 -0
  19. {comfy_cli-1.7.2 → comfy_cli-1.7.3}/comfy_cli/command/code_search.py +0 -0
  20. {comfy_cli-1.7.2 → comfy_cli-1.7.3}/comfy_cli/command/custom_nodes/__init__.py +0 -0
  21. {comfy_cli-1.7.2 → comfy_cli-1.7.3}/comfy_cli/command/custom_nodes/bisect_custom_nodes.py +0 -0
  22. {comfy_cli-1.7.2 → comfy_cli-1.7.3}/comfy_cli/command/custom_nodes/cm_cli_util.py +0 -0
  23. {comfy_cli-1.7.2 → comfy_cli-1.7.3}/comfy_cli/command/github/pr_info.py +0 -0
  24. {comfy_cli-1.7.2 → comfy_cli-1.7.3}/comfy_cli/command/install.py +0 -0
  25. {comfy_cli-1.7.2 → comfy_cli-1.7.3}/comfy_cli/command/launch.py +0 -0
  26. {comfy_cli-1.7.2 → comfy_cli-1.7.3}/comfy_cli/command/pr_command.py +0 -0
  27. {comfy_cli-1.7.2 → comfy_cli-1.7.3}/comfy_cli/command/run.py +0 -0
  28. {comfy_cli-1.7.2 → comfy_cli-1.7.3}/comfy_cli/config_manager.py +0 -0
  29. {comfy_cli-1.7.2 → comfy_cli-1.7.3}/comfy_cli/cuda_detect.py +0 -0
  30. {comfy_cli-1.7.2 → comfy_cli-1.7.3}/comfy_cli/env_checker.py +0 -0
  31. {comfy_cli-1.7.2 → comfy_cli-1.7.3}/comfy_cli/git_utils.py +0 -0
  32. {comfy_cli-1.7.2 → comfy_cli-1.7.3}/comfy_cli/logging.py +0 -0
  33. {comfy_cli-1.7.2 → comfy_cli-1.7.3}/comfy_cli/pr_cache.py +0 -0
  34. {comfy_cli-1.7.2 → comfy_cli-1.7.3}/comfy_cli/registry/__init__.py +0 -0
  35. {comfy_cli-1.7.2 → comfy_cli-1.7.3}/comfy_cli/registry/api.py +0 -0
  36. {comfy_cli-1.7.2 → comfy_cli-1.7.3}/comfy_cli/registry/types.py +0 -0
  37. {comfy_cli-1.7.2 → comfy_cli-1.7.3}/comfy_cli/resolve_python.py +0 -0
  38. {comfy_cli-1.7.2 → comfy_cli-1.7.3}/comfy_cli/standalone.py +0 -0
  39. {comfy_cli-1.7.2 → comfy_cli-1.7.3}/comfy_cli/typing.py +0 -0
  40. {comfy_cli-1.7.2 → comfy_cli-1.7.3}/comfy_cli/update.py +0 -0
  41. {comfy_cli-1.7.2 → comfy_cli-1.7.3}/comfy_cli/utils.py +0 -0
  42. {comfy_cli-1.7.2 → comfy_cli-1.7.3}/comfy_cli/workspace_manager.py +0 -0
  43. {comfy_cli-1.7.2 → comfy_cli-1.7.3}/comfy_cli.egg-info/SOURCES.txt +0 -0
  44. {comfy_cli-1.7.2 → comfy_cli-1.7.3}/comfy_cli.egg-info/dependency_links.txt +0 -0
  45. {comfy_cli-1.7.2 → comfy_cli-1.7.3}/comfy_cli.egg-info/entry_points.txt +0 -0
  46. {comfy_cli-1.7.2 → comfy_cli-1.7.3}/comfy_cli.egg-info/requires.txt +0 -0
  47. {comfy_cli-1.7.2 → comfy_cli-1.7.3}/comfy_cli.egg-info/top_level.txt +0 -0
  48. {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.2
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
- with open(requirements_path, encoding="utf-8") as requirements_file:
174
- for line in requirements_file:
175
- package_name = line.strip()
176
- if package_name and not package_name.startswith("#"):
177
- install_cmd = [python, "-m", "pip", "install", package_name]
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
- download_file(node_version.download_url, local_filename)
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 != "civitai.com" and not host.endswith(".civitai.com"):
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
- download_file(url, local_filepath, headers, downloader=resolved_downloader)
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 _TRANSIENT_EXCEPTIONS as exc:
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 = [line.strip() for line in req_file if line.strip()]
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
- # enable_tracking = config_manager.get_bool(constants.CONFIG_KEY_ENABLE_TRACKING)
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
- console.print(f"[red]{message}[/]")
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 or line.startswith("#"):
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.2
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.2" # Will be filled in by the CI/CD pipeline. Check publish_package.py.
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