Skip to content

Commit cba3952

Browse files
authored
Merge branch 'Comfy-Org:main' into patch-3
2 parents 0bf0cae + 46501fa commit cba3952

File tree

9 files changed

+368
-59
lines changed

9 files changed

+368
-59
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,3 +61,4 @@ requirements.compiled
6161
override.txt
6262
.coverage
6363
coverage.xml
64+

DEV_README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,32 @@ either run `pip install -e .` again (which will reinstall), or manually
5858
uninstall `pip uninstall comfy-cli` and reinstall, or even cleaning your conda
5959
env and reinstalling the package (`pip install -e .`)
6060

61+
## Packaging custom nodes with `.comfyignore`
62+
63+
`comfy node pack` and `comfy node publish` now read an optional `.comfyignore`
64+
file in the project root. The syntax matches `.gitignore` (implemented with
65+
`PathSpec`'s `gitwildmatch` rules), so you can reuse familiar patterns to keep
66+
development-only artifacts out of your published archive.
67+
68+
- Patterns are evaluated against paths relative to the directory you run the
69+
command from (usually the repo root).
70+
- Files required by the pack command itself (e.g. `__init__.py`, `web/*`) are
71+
still forced into the archive even if they match an ignore pattern.
72+
- If no `.comfyignore` is present the command falls back to the original
73+
behavior and zips every git-tracked file.
74+
75+
Example `.comfyignore`:
76+
77+
```gitignore
78+
docs/
79+
frontend/
80+
tests/
81+
*.psd
82+
```
83+
84+
Commit the file alongside your node so teammates and CI pipelines produce the
85+
same trimmed package.
86+
6187
## Adding a new command
6288

6389
- Register it under `comfy_cli/cmdline.py`

comfy_cli/command/custom_nodes/command.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -942,7 +942,7 @@ def registry_install(
942942

943943
@app.command(
944944
"pack",
945-
help="Pack the current node into a zip file. Ignorining .gitignore files.",
945+
help="Pack the current node into a zip file using git-tracked files and honoring .comfyignore patterns.",
946946
)
947947
@tracking.track_command("pack")
948948
def pack():

comfy_cli/command/models/models.py

Lines changed: 56 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1+
import contextlib
12
import os
23
import pathlib
34
import sys
45
from typing import Annotated, Optional
5-
from urllib.parse import unquote, urlparse
6+
from urllib.parse import parse_qs, unquote, urlparse
67

78
import requests
89
import typer
@@ -74,39 +75,65 @@ def check_huggingface_url(url: str) -> tuple[bool, Optional[str], Optional[str],
7475
return True, repo_id, filename, folder_name, branch_name
7576

7677

77-
def check_civitai_url(url: str) -> tuple[bool, bool, int, int]:
78+
def check_civitai_url(url: str) -> tuple[bool, bool, Optional[int], Optional[int]]:
7879
"""
7980
Returns:
80-
is_civitai_model_url: True if the url is a civitai model url
81-
is_civitai_api_url: True if the url is a civitai api url
82-
model_id: The model id or None if it's api url
83-
version_id: The version id or None if it doesn't have version id info
81+
is_civitai_model_url: True if the url is a civitai *web* model url (e.g. /models/12345)
82+
is_civitai_api_url: True if the url is a civitai *api* url useful for resolving downloads
83+
model_id: The model id (for /models/*), else None
84+
version_id: The version id (for /api/download/models/* or ?modelVersionId=), else None
8485
"""
85-
prefix = "civitai.com"
8686
try:
87-
if prefix in url:
88-
# URL is civitai api download url: https://civitai.com/api/download/models/12345
89-
if "civitai.com/api/download" in url:
90-
# This is a direct download link
91-
version_id = url.strip("/").split("/")[-1]
92-
return False, True, None, int(version_id)
93-
94-
# URL is civitai web url (e.g.
95-
# - https://civitai.com/models/43331
96-
# - https://civitai.com/models/43331/majicmix-realistic
97-
subpath = url[url.find(prefix) + len(prefix) :].strip("/")
98-
url_parts = subpath.split("?")
99-
if len(url_parts) > 1:
100-
model_id = url_parts[0].split("/")[1]
101-
version_id = url_parts[1].split("=")[1]
102-
return True, False, int(model_id), int(version_id)
103-
else:
104-
model_id = subpath.split("/")[1]
105-
return True, False, int(model_id), None
106-
except (ValueError, IndexError):
87+
parsed = urlparse(url)
88+
host = (parsed.hostname or "").lower()
89+
if host != "civitai.com" and not host.endswith(".civitai.com"):
90+
return False, False, None, None
91+
p_parts = [p for p in parsed.path.split("/") if p]
92+
query = parse_qs(parsed.query)
93+
94+
if len(p_parts) >= 4 and p_parts[0] == "api":
95+
# Case 1: /api/download/models/<version_id>
96+
# e.g. https://civitai.com/api/download/models/1617665?type=Model&format=SafeTensor
97+
if p_parts[1] == "download" and p_parts[2] == "models":
98+
try:
99+
version_id = int(p_parts[3])
100+
return False, True, None, version_id
101+
except ValueError:
102+
return False, True, None, None
103+
104+
# Case 2: /api/v1/model-versions/<version_id>
105+
if p_parts[1] == "v1" and p_parts[2] in ("model-versions", "modelVersions"):
106+
try:
107+
version_id = int(p_parts[3])
108+
return False, True, None, version_id
109+
except ValueError:
110+
return False, True, None, None
111+
112+
# Case 3: /models/<model_id>[/*] with optional ?modelVersionId=<id>
113+
# e.g. https://civitai.com/models/43331
114+
# https://civitai.com/models/43331/majicmix-realistic?modelVersionId=485088
115+
if len(p_parts) >= 2 and p_parts[0] == "models":
116+
try:
117+
model_id = int(p_parts[1])
118+
except ValueError:
119+
return False, False, None, None
120+
version_id = None
121+
mv = query.get("modelVersionId")
122+
if mv and len(mv) > 0:
123+
with contextlib.suppress(ValueError):
124+
version_id = int(mv[0])
125+
if version_id is None:
126+
mv = query.get("version")
127+
if mv and len(mv) > 0:
128+
with contextlib.suppress(ValueError):
129+
version_id = int(mv[0])
130+
return True, False, model_id, version_id
131+
132+
return False, False, None, None
133+
134+
except Exception:
107135
print("Error parsing CivitAI model URL")
108-
109-
return False, False, None, None
136+
return False, False, None, None
110137

111138

112139
def request_civitai_model_version_api(version_id: int, headers: Optional[dict] = None):

comfy_cli/file_utils.py

Lines changed: 109 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import json
22
import os
33
import pathlib
4+
import subprocess
45
import zipfile
5-
from typing import Optional
6+
from typing import Optional, Union
67

78
import httpx
89
import requests
10+
from pathspec import PathSpec
911

1012
from comfy_cli import constants, ui
1113

@@ -55,7 +57,7 @@ def check_unauthorized(url: str, headers: Optional[dict] = None) -> bool:
5557
bool: True if the response status code is 401, False otherwise.
5658
"""
5759
try:
58-
response = requests.get(url, headers=headers, allow_redirects=True)
60+
response = requests.get(url, headers=headers, allow_redirects=True, stream=True)
5961
return response.status_code == 401
6062
except requests.RequestException:
6163
# If there's an error making the request, we can't determine if it's unauthorized
@@ -86,61 +88,139 @@ def download_file(url: str, local_filepath: pathlib.Path, headers: Optional[dict
8688
raise DownloadException(f"Failed to download file.\n{status_reason}")
8789

8890

91+
def _load_comfyignore_spec(ignore_filename: str = ".comfyignore") -> Optional[PathSpec]:
92+
if not os.path.exists(ignore_filename):
93+
return None
94+
try:
95+
with open(ignore_filename, encoding="utf-8") as ignore_file:
96+
patterns = [line.strip() for line in ignore_file if line.strip() and not line.lstrip().startswith("#")]
97+
except OSError:
98+
return None
99+
100+
if not patterns:
101+
return None
102+
103+
return PathSpec.from_lines("gitwildmatch", patterns)
104+
105+
106+
def list_git_tracked_files(base_path: Union[str, os.PathLike] = ".") -> list[str]:
107+
try:
108+
result = subprocess.check_output(
109+
["git", "-C", os.fspath(base_path), "ls-files"],
110+
text=True,
111+
)
112+
except (subprocess.SubprocessError, FileNotFoundError):
113+
return []
114+
115+
return [line for line in result.splitlines() if line.strip()]
116+
117+
118+
def _normalize_path(path: str) -> str:
119+
rel_path = os.path.relpath(path, start=".")
120+
if rel_path == ".":
121+
return ""
122+
return rel_path.replace("\\", "/")
123+
124+
125+
def _is_force_included(rel_path: str, include_prefixes: list[str]) -> bool:
126+
return any(rel_path == prefix or rel_path.startswith(prefix + "/") for prefix in include_prefixes if prefix)
127+
128+
89129
def zip_files(zip_filename, includes=None):
90-
"""
91-
Zip all files in the current directory that are tracked by git,
92-
plus any additional directories specified in includes.
93-
"""
130+
"""Zip git-tracked files respecting optional .comfyignore patterns."""
94131
includes = includes or []
95-
included_paths = set()
96-
git_files = []
132+
include_prefixes: list[str] = [_normalize_path(os.path.normpath(include.lstrip("/"))) for include in includes]
97133

98-
try:
99-
import subprocess
134+
included_paths: set[str] = set()
135+
git_files: list[str] = []
100136

101-
git_files = subprocess.check_output(["git", "ls-files"], text=True).splitlines()
102-
except (subprocess.SubprocessError, FileNotFoundError):
137+
ignore_spec = _load_comfyignore_spec()
138+
139+
def should_ignore(rel_path: str) -> bool:
140+
if not ignore_spec:
141+
return False
142+
if _is_force_included(rel_path, include_prefixes):
143+
return False
144+
return ignore_spec.match_file(rel_path)
145+
146+
zip_target = os.fspath(zip_filename)
147+
zip_abs_path = os.path.abspath(zip_target)
148+
zip_basename = os.path.basename(zip_abs_path)
149+
150+
git_files = list_git_tracked_files(".")
151+
if not git_files:
103152
print("Warning: Not in a git repository or git not installed. Zipping all files.")
104153

105-
# Zip only git-tracked files
106-
with zipfile.ZipFile(zip_filename, "w", zipfile.ZIP_DEFLATED) as zipf:
154+
with zipfile.ZipFile(zip_target, "w", zipfile.ZIP_DEFLATED) as zipf:
107155
if git_files:
108156
for file_path in git_files:
109-
if zip_filename in file_path:
157+
if file_path == zip_basename:
158+
continue
159+
160+
rel_path = _normalize_path(file_path)
161+
if should_ignore(rel_path):
162+
continue
163+
164+
actual_path = os.path.normpath(file_path)
165+
if os.path.abspath(actual_path) == zip_abs_path:
110166
continue
111-
if os.path.exists(file_path):
112-
zipf.write(file_path)
113-
included_paths.add(file_path)
167+
if os.path.exists(actual_path):
168+
arcname = rel_path or os.path.basename(actual_path)
169+
zipf.write(actual_path, arcname)
170+
included_paths.add(rel_path)
114171
else:
115172
print(f"File not found. Not including in zip: {file_path}")
116173
else:
117174
for root, dirs, files in os.walk("."):
118175
if ".git" in dirs:
119176
dirs.remove(".git")
177+
dirs[:] = [d for d in dirs if not should_ignore(_normalize_path(os.path.join(root, d)))]
120178
for file in files:
121179
file_path = os.path.join(root, file)
122-
# Skip zipping the zip file itself
123-
if zip_filename in file_path:
180+
rel_path = _normalize_path(file_path)
181+
if (
182+
os.path.abspath(file_path) == zip_abs_path
183+
or rel_path in included_paths
184+
or should_ignore(rel_path)
185+
):
124186
continue
125-
relative_path = os.path.relpath(file_path, start=".")
126-
zipf.write(file_path, relative_path)
127-
included_paths.add(file_path)
187+
arcname = rel_path or file_path
188+
zipf.write(file_path, arcname)
189+
included_paths.add(rel_path)
128190

129191
for include_dir in includes:
130-
include_dir = include_dir.lstrip("/")
192+
include_dir = os.path.normpath(include_dir.lstrip("/"))
193+
rel_include = _normalize_path(include_dir)
194+
195+
if os.path.isfile(include_dir):
196+
if not should_ignore(rel_include) and rel_include not in included_paths:
197+
arcname = rel_include or include_dir
198+
zipf.write(include_dir, arcname)
199+
included_paths.add(rel_include)
200+
continue
201+
131202
if not os.path.exists(include_dir):
132203
print(f"Warning: Included directory '{include_dir}' does not exist, creating empty directory")
133-
zipf.writestr(f"{include_dir}/", "")
204+
arcname = rel_include or include_dir
205+
if not arcname.endswith("/"):
206+
arcname = arcname + "/"
207+
zipf.writestr(arcname, "")
134208
continue
135209

136210
for root, dirs, files in os.walk(include_dir):
211+
dirs[:] = [d for d in dirs if not should_ignore(_normalize_path(os.path.join(root, d)))]
137212
for file in files:
138213
file_path = os.path.join(root, file)
139-
if zip_filename in file_path or file_path in included_paths:
214+
rel_path = _normalize_path(file_path)
215+
if (
216+
os.path.abspath(file_path) == zip_abs_path
217+
or rel_path in included_paths
218+
or should_ignore(rel_path)
219+
):
140220
continue
141-
relative_path = os.path.relpath(file_path, start=".")
142-
zipf.write(file_path, relative_path)
143-
included_paths.add(file_path)
221+
arcname = rel_path or file_path
222+
zipf.write(file_path, arcname)
223+
included_paths.add(rel_path)
144224

145225

146226
def upload_file_to_signed_url(signed_url: str, file_path: str):

0 commit comments

Comments
 (0)