Skip to content

Commit 3beb2e5

Browse files
authored
fix: handle inline comments in requirements.txt parsing (#433)
Signed-off-by: Alexander Piskun <bigcat88@icloud.com>
1 parent 5f8937f commit 3beb2e5

6 files changed

Lines changed: 262 additions & 11 deletions

File tree

comfy_cli/command/custom_nodes/command.py

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -171,13 +171,11 @@ def execute_install_script(repo_path):
171171
if os.path.exists(requirements_path):
172172
print("Install: pip packages")
173173
python = resolve_workspace_python(workspace_manager.workspace_path)
174-
with open(requirements_path, encoding="utf-8") as requirements_file:
175-
for line in requirements_file:
176-
package_name = line.strip()
177-
if package_name and not package_name.startswith("#"):
178-
install_cmd = [python, "-m", "pip", "install", package_name]
179-
if package_name.strip() != "":
180-
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)
181179

182180
if os.path.exists(install_script_path):
183181
print("Install: install script")

comfy_cli/registry/config_parser.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@
1717
URLs,
1818
)
1919

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+
2025

2126
def create_comfynode_config():
2227
# Create the initial structure of the TOML document
@@ -249,7 +254,20 @@ def initialize_project_config():
249254
# Handle dependencies
250255
if os.path.exists("requirements.txt"):
251256
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)
253271
project["dependencies"] = dependencies
254272
else:
255273
print("Warning: 'requirements.txt' not found. No dependencies will be added.")

comfy_cli/uv.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,11 @@ def _check_call(cmd: list[str], cwd: PathLike | None = None):
3737

3838
_req_name_re: re.Pattern[str] = re.compile(r"require\s([\w-]+)")
3939

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+
4045

4146
def _req_re_closure(name: str) -> re.Pattern[str]:
4247
return re.compile(rf"({name}\S+)")
@@ -64,8 +69,8 @@ def parse_req_file(rf: PathLike, skips: list[str] | None = None):
6469
opts: list[str] = []
6570
with open(rf) as f:
6671
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:
6974
continue
7075
elif "==" in line and line.split("==")[0] in skips:
7176
continue

tests/comfy_cli/registry/test_config_parser.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,3 +399,100 @@ def test_initialize_project_config_ssh_remote(tmp_path, monkeypatch):
399399
assert urls["Bug Tracker"] == "https://github.com/user/ComfyUI-TestNode/issues"
400400
assert data["project"]["name"] == "testnode"
401401
assert data["tool"]["comfy"]["DisplayName"] == "ComfyUI-TestNode"
402+
403+
404+
# Issue #431: requirements.txt → pyproject.toml migration must produce
405+
# valid PEP 508 dependency specifiers. Inline comments, full-line comments,
406+
# and pip-specific options (-r, -e, --index-url, ...) are not valid deps.
407+
408+
409+
def _init_git_repo_with_reqs(tmp_path, requirements_content: str) -> None:
410+
subprocess.run(["git", "init"], cwd=tmp_path, check=True, capture_output=True)
411+
subprocess.run(
412+
["git", "remote", "add", "origin", "https://github.com/user/ComfyUI-TestNode.git"],
413+
cwd=tmp_path,
414+
check=True,
415+
capture_output=True,
416+
)
417+
(tmp_path / "requirements.txt").write_text(requirements_content)
418+
419+
420+
def test_initialize_project_config_strips_inline_comments(tmp_path, monkeypatch):
421+
monkeypatch.chdir(tmp_path)
422+
_init_git_repo_with_reqs(
423+
tmp_path,
424+
"matplotlib>=3.3.0 # For visualization\nnumpy>=1.0 # trailing\n",
425+
)
426+
initialize_project_config()
427+
with open(tmp_path / "pyproject.toml") as f:
428+
data = tomlkit.parse(f.read())
429+
deps = [str(d) for d in data["project"]["dependencies"]]
430+
assert deps == ["matplotlib>=3.3.0", "numpy>=1.0"]
431+
432+
433+
def test_initialize_project_config_skips_full_line_comments(tmp_path, monkeypatch):
434+
monkeypatch.chdir(tmp_path)
435+
_init_git_repo_with_reqs(
436+
tmp_path,
437+
"# heading comment\nfoo>=1.0\n # indented comment\nbar\n",
438+
)
439+
initialize_project_config()
440+
with open(tmp_path / "pyproject.toml") as f:
441+
data = tomlkit.parse(f.read())
442+
deps = [str(d) for d in data["project"]["dependencies"]]
443+
assert deps == ["foo>=1.0", "bar"]
444+
445+
446+
def test_initialize_project_config_skips_pip_options(tmp_path, monkeypatch, capsys):
447+
# `-r`, `-e`, `-c`, `--index-url`, `--extra-index-url`, `--find-links`
448+
# are pip-requirements-file syntax, not PEP 508 dep specifiers. They must
449+
# not land in [project.dependencies] where downstream build tools will
450+
# error trying to parse them. Each skipped line must also produce a
451+
# visible warning so silent data loss is avoided.
452+
monkeypatch.chdir(tmp_path)
453+
_init_git_repo_with_reqs(
454+
tmp_path,
455+
"-r other.txt\n"
456+
"-e .\n"
457+
"--index-url https://pypi.org/simple\n"
458+
"--extra-index-url https://example.com/simple\n"
459+
"--find-links ./local-wheels\n"
460+
"foo>=1.0\n",
461+
)
462+
initialize_project_config()
463+
with open(tmp_path / "pyproject.toml") as f:
464+
data = tomlkit.parse(f.read())
465+
deps = [str(d) for d in data["project"]["dependencies"]]
466+
assert deps == ["foo>=1.0"]
467+
out = capsys.readouterr().out
468+
for dropped in ["-r other.txt", "-e .", "--index-url", "--extra-index-url", "--find-links"]:
469+
assert dropped in out, f"missing skip warning for {dropped!r}"
470+
471+
472+
def test_initialize_project_config_preserves_vcs_subdirectory_fragment(tmp_path, monkeypatch):
473+
# Regression guard against a naive `split("#")[0]` fix — VCS fragments
474+
# must survive because `#` is only a comment marker when preceded by
475+
# whitespace (pip's rule).
476+
monkeypatch.chdir(tmp_path)
477+
_init_git_repo_with_reqs(
478+
tmp_path,
479+
"git+https://github.com/org/mono.git#subdirectory=pkg\n",
480+
)
481+
initialize_project_config()
482+
with open(tmp_path / "pyproject.toml") as f:
483+
data = tomlkit.parse(f.read())
484+
deps = [str(d) for d in data["project"]["dependencies"]]
485+
assert deps == ["git+https://github.com/org/mono.git#subdirectory=pkg"]
486+
487+
488+
def test_initialize_project_config_vcs_with_inline_comment(tmp_path, monkeypatch):
489+
monkeypatch.chdir(tmp_path)
490+
_init_git_repo_with_reqs(
491+
tmp_path,
492+
"git+https://github.com/org/mono.git#subdirectory=pkg # monorepo dep\n",
493+
)
494+
initialize_project_config()
495+
with open(tmp_path / "pyproject.toml") as f:
496+
data = tomlkit.parse(f.read())
497+
deps = [str(d) for d in data["project"]["dependencies"]]
498+
assert deps == ["git+https://github.com/org/mono.git#subdirectory=pkg"]

tests/comfy_cli/test_custom_nodes_python_resolution.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import os
12
from unittest.mock import patch
23

34
from comfy_cli.command.custom_nodes import command
@@ -63,6 +64,72 @@ def test_install_py_uses_resolved_python(self, tmp_path):
6364
cmd = mock_check_call.call_args[0][0]
6465
assert cmd == ["/resolved/python", "install.py"]
6566

67+
def test_inline_comment_not_passed_to_pip(self, tmp_path):
68+
# Issue #431 regression: inline comments in requirements.txt must not
69+
# survive into the argv handed to pip. Pre-fix, the raw line was passed
70+
# verbatim (e.g. "matplotlib>=3.3.0 # note") and pip rejected it.
71+
bad_spec = "matplotlib>=3.3.0 # For visualization"
72+
(tmp_path / "requirements.txt").write_text(f"{bad_spec}\n")
73+
74+
with (
75+
patch(
76+
"comfy_cli.command.custom_nodes.command.resolve_workspace_python",
77+
return_value="/resolved/python",
78+
),
79+
patch.object(command.workspace_manager, "workspace_path", str(tmp_path)),
80+
patch("comfy_cli.command.custom_nodes.command.subprocess.check_call") as mock_check_call,
81+
):
82+
command.execute_install_script(str(tmp_path))
83+
84+
for call in mock_check_call.call_args_list:
85+
argv = call[0][0]
86+
assert bad_spec not in argv, f"raw comment-laden spec leaked into pip argv: {argv!r}"
87+
88+
def test_uses_pip_install_r(self, tmp_path):
89+
# Option C: delegate requirements-file parsing to pip via `-r <path>`.
90+
# This lets pip handle inline comments, line continuations, VCS URL
91+
# fragments, env markers, -e, -r, --index-url, etc.
92+
requirements_path = tmp_path / "requirements.txt"
93+
requirements_path.write_text("numpy>=1.0\n")
94+
95+
with (
96+
patch(
97+
"comfy_cli.command.custom_nodes.command.resolve_workspace_python",
98+
return_value="/resolved/python",
99+
),
100+
patch.object(command.workspace_manager, "workspace_path", str(tmp_path)),
101+
patch("comfy_cli.command.custom_nodes.command.subprocess.check_call") as mock_check_call,
102+
):
103+
command.execute_install_script(str(tmp_path))
104+
105+
mock_check_call.assert_called_once()
106+
argv = mock_check_call.call_args[0][0]
107+
assert argv == ["/resolved/python", "-m", "pip", "install", "-r", str(requirements_path)]
108+
109+
def test_requirements_path_is_absolute_when_repo_path_is_relative(self, tmp_path, monkeypatch):
110+
# try_install_script runs pip with cwd=repo_path. If requirements_path
111+
# were relative, pip would resolve `-r <rel>/requirements.txt` against
112+
# that cwd, producing a doubled path like <rel>/<rel>/requirements.txt.
113+
# Guard: the `-r` target must be absolute regardless of the input.
114+
(tmp_path / "requirements.txt").write_text("numpy>=1.0\n")
115+
monkeypatch.chdir(tmp_path.parent)
116+
relative_repo = tmp_path.name # e.g. "test_requirements_path..." relative to cwd
117+
118+
with (
119+
patch(
120+
"comfy_cli.command.custom_nodes.command.resolve_workspace_python",
121+
return_value="/resolved/python",
122+
),
123+
patch.object(command.workspace_manager, "workspace_path", str(tmp_path)),
124+
patch("comfy_cli.command.custom_nodes.command.subprocess.check_call") as mock_check_call,
125+
):
126+
command.execute_install_script(relative_repo)
127+
128+
argv = mock_check_call.call_args[0][0]
129+
target = argv[-1]
130+
assert os.path.isabs(target), f"-r target is not absolute: {target!r}"
131+
assert target == str(tmp_path / "requirements.txt")
132+
66133

67134
class TestUpdateNodeIdCache:
68135
def test_uses_resolved_python(self, tmp_path):

tests/uv/test_uv.py

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
from comfy_cli import ui
99
from comfy_cli.constants import GPU_OPTION
10-
from comfy_cli.uv import DependencyCompiler, _check_call
10+
from comfy_cli.uv import DependencyCompiler, _check_call, parse_req_file
1111

1212
hereDir = Path(__file__).parent.resolve()
1313
mockComfyDir = hereDir / "mock_comfy"
@@ -360,3 +360,69 @@ def test_check_call_no_hint_for_pip_install_uv(capsys):
360360

361361
captured = capsys.readouterr().out
362362
assert "network filesystem" not in captured
363+
364+
365+
# Issue #431: parse_req_file feeds its output into pip argv (pip download /
366+
# pip wheel). Inline comments would be rejected by pip; VCS URL fragments must
367+
# be preserved verbatim (e.g. `#subdirectory=pkg`, `#egg=foo`).
368+
369+
370+
def test_parse_req_file_strips_inline_comments(tmp_path):
371+
rf = tmp_path / "requirements.txt"
372+
rf.write_text("foo>=1.0 # trailing comment\n")
373+
assert parse_req_file(rf) == ["foo>=1.0"]
374+
375+
376+
def test_parse_req_file_strips_inline_comment_with_single_space(tmp_path):
377+
rf = tmp_path / "requirements.txt"
378+
rf.write_text("bar==2.3 # single space before hash\n")
379+
assert parse_req_file(rf) == ["bar==2.3"]
380+
381+
382+
def test_parse_req_file_skips_full_line_comments(tmp_path):
383+
rf = tmp_path / "requirements.txt"
384+
rf.write_text("# heading\nfoo>=1.0\n # indented heading\nbaz\n")
385+
assert parse_req_file(rf) == ["foo>=1.0", "baz"]
386+
387+
388+
def test_parse_req_file_preserves_vcs_subdirectory_fragment(tmp_path):
389+
# Regression guard: any naive `split("#")[0]` would break this. `#` is only
390+
# a comment marker when preceded by whitespace (pip's rule).
391+
rf = tmp_path / "requirements.txt"
392+
rf.write_text("git+https://github.com/org/mono.git#subdirectory=pkg\n")
393+
assert parse_req_file(rf) == ["git+https://github.com/org/mono.git#subdirectory=pkg"]
394+
395+
396+
def test_parse_req_file_preserves_vcs_egg_fragment(tmp_path):
397+
rf = tmp_path / "requirements.txt"
398+
rf.write_text("git+https://github.com/org/repo.git@main#egg=foo\n")
399+
assert parse_req_file(rf) == ["git+https://github.com/org/repo.git@main#egg=foo"]
400+
401+
402+
def test_parse_req_file_preserves_direct_url_hash(tmp_path):
403+
rf = tmp_path / "requirements.txt"
404+
rf.write_text("foo @ https://host/f.whl#sha256=abc123\n")
405+
assert parse_req_file(rf) == ["foo @ https://host/f.whl#sha256=abc123"]
406+
407+
408+
def test_parse_req_file_vcs_with_inline_comment_strips_only_comment(tmp_path):
409+
# The trickiest case: a VCS spec with a fragment AND a trailing comment.
410+
# Comment is preceded by whitespace so it must be stripped; fragment is
411+
# part of the URL and must survive.
412+
rf = tmp_path / "requirements.txt"
413+
rf.write_text("git+https://host/r.git#subdirectory=pkg # note\n")
414+
assert parse_req_file(rf) == ["git+https://host/r.git#subdirectory=pkg"]
415+
416+
417+
def test_parse_req_file_preserves_double_dash_options(tmp_path):
418+
rf = tmp_path / "requirements.txt"
419+
rf.write_text("--extra-index-url https://example.com/simple\nfoo\n")
420+
assert parse_req_file(rf) == ["--extra-index-url", "https://example.com/simple", "foo"]
421+
422+
423+
def test_parse_req_file_handles_crlf_line_endings(tmp_path):
424+
# Windows-authored requirements.txt files use CRLF. Verify the comment
425+
# stripper + .strip() cleanly handles the trailing \r.
426+
rf = tmp_path / "requirements.txt"
427+
rf.write_bytes(b"foo>=1.0 # note\r\nbar>=2.0\r\n")
428+
assert parse_req_file(rf) == ["foo>=1.0", "bar>=2.0"]

0 commit comments

Comments
 (0)