Skip to content

Commit ccac968

Browse files
authored
fix: model list and remove now find files in subdirectories (#372)
1 parent 75cff8b commit ccac968

2 files changed

Lines changed: 158 additions & 9 deletions

File tree

comfy_cli/command/models/models.py

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -358,14 +358,19 @@ def remove(
358358
typer.echo("No models found to remove.")
359359
return
360360

361+
model_dir_resolved = model_dir.resolve()
362+
361363
to_delete = []
362364
# Scenario #1: User provided model names to delete
363365
if model_names:
364366
# Validate and filter models to delete based on provided names
365367
missing_models = []
366368
for name in model_names:
367-
model_path = model_dir / name
368-
if model_path.exists():
369+
model_path = (model_dir / name).resolve()
370+
if not model_path.is_relative_to(model_dir_resolved):
371+
typer.echo(f"Invalid model path: {name}")
372+
continue
373+
if model_path.is_file():
369374
to_delete.append(model_path)
370375
else:
371376
missing_models.append(name)
@@ -377,7 +382,8 @@ def remove(
377382

378383
# Scenario #2: User did not provide model names, prompt for selection
379384
else:
380-
selections = ui.prompt_multi_select("Select models to delete:", [model.name for model in available_models])
385+
rel_names = [str(model.relative_to(model_dir)) for model in available_models]
386+
selections = ui.prompt_multi_select("Select models to delete:", rel_names)
381387
if not selections:
382388
typer.echo("No models selected for deletion.")
383389
return
@@ -394,9 +400,11 @@ def remove(
394400
typer.echo("Deletion canceled.")
395401

396402

397-
def list_models(path: pathlib.Path) -> list:
398-
"""List all models in the specified directory."""
399-
return [file for file in path.iterdir() if file.is_file()]
403+
def list_models(path: pathlib.Path) -> list[pathlib.Path]:
404+
"""List all model files recursively in the specified directory."""
405+
if not path.is_dir():
406+
return []
407+
return sorted(f for f in path.rglob("*") if f.is_file())
400408

401409

402410
@app.command("list")
@@ -418,6 +426,10 @@ def list_command(
418426
return
419427

420428
# Prepare data for table display
421-
data = [(model.name, f"{model.stat().st_size // 1024} KB") for model in models]
422-
column_names = ["Model Name", "Size"]
429+
data = []
430+
for model in models:
431+
rel = model.relative_to(model_dir)
432+
model_type = str(rel.parent) if len(rel.parts) > 1 else ""
433+
data.append((model.name, model_type, f"{model.stat().st_size // 1024} KB"))
434+
column_names = ["Model Name", "Type", "Size"]
423435
ui.display_table(data, column_names)

tests/comfy_cli/command/models/test_models.py

Lines changed: 138 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,141 @@
1-
from comfy_cli.command.models.models import check_civitai_url, check_huggingface_url
1+
import pathlib
2+
from unittest.mock import patch
3+
4+
import typer.testing
5+
6+
from comfy_cli.command.models.models import app, check_civitai_url, check_huggingface_url, list_models
7+
8+
9+
def _make_model_tree(tmp_path: pathlib.Path) -> pathlib.Path:
10+
"""Create a realistic model directory tree and return its root."""
11+
model_dir = tmp_path / "models"
12+
(model_dir / "root_model.safetensors").parent.mkdir(parents=True, exist_ok=True)
13+
(model_dir / "root_model.safetensors").write_bytes(b"x" * 100)
14+
(model_dir / "checkpoints").mkdir()
15+
(model_dir / "checkpoints" / "sd15.safetensors").write_bytes(b"x" * 200)
16+
(model_dir / "loras" / "SD1.5").mkdir(parents=True)
17+
(model_dir / "loras" / "SD1.5" / "detail.safetensors").write_bytes(b"x" * 300)
18+
(model_dir / "empty_dir").mkdir()
19+
return model_dir
20+
21+
22+
def test_list_models_finds_files_in_subdirectories(tmp_path):
23+
model_dir = _make_model_tree(tmp_path)
24+
result = list_models(model_dir)
25+
names = {f.name for f in result}
26+
assert "sd15.safetensors" in names
27+
deep = [f for f in result if f.name == "detail.safetensors"]
28+
assert len(deep) == 1
29+
assert deep[0].relative_to(model_dir) == pathlib.Path("loras/SD1.5/detail.safetensors")
30+
31+
32+
def test_list_models_finds_root_level_files(tmp_path):
33+
model_dir = _make_model_tree(tmp_path)
34+
result = list_models(model_dir)
35+
names = {f.name for f in result}
36+
assert "root_model.safetensors" in names
37+
38+
39+
def test_list_models_returns_empty_for_missing_directory(tmp_path):
40+
assert list_models(tmp_path / "nonexistent") == []
41+
42+
43+
def test_list_models_ignores_directories(tmp_path):
44+
model_dir = _make_model_tree(tmp_path)
45+
result = list_models(model_dir)
46+
assert all(f.is_file() for f in result)
47+
dir_names = {f.name for f in result}
48+
assert "empty_dir" not in dir_names
49+
assert "checkpoints" not in dir_names
50+
51+
52+
runner = typer.testing.CliRunner()
53+
54+
55+
def test_list_command_shows_type_column(tmp_path):
56+
_make_model_tree(tmp_path)
57+
with patch("comfy_cli.command.models.models.get_workspace", return_value=tmp_path):
58+
result = runner.invoke(app, ["list", "--relative-path", "models"])
59+
assert result.exit_code == 0
60+
assert "Type" in result.output
61+
assert "checkpoints" in result.output
62+
assert "loras/SD1.5" in result.output
63+
assert "root_model.safetensors" in result.output
64+
65+
66+
def test_remove_with_path_traversal_is_rejected(tmp_path):
67+
model_dir = tmp_path / "models"
68+
model_dir.mkdir()
69+
(model_dir / "legit.bin").write_bytes(b"x")
70+
71+
secret = tmp_path / "secret.txt"
72+
secret.write_text("sensitive")
73+
74+
with patch("comfy_cli.command.models.models.get_workspace", return_value=tmp_path):
75+
result = runner.invoke(
76+
app,
77+
["remove", "--relative-path", "models", "--model-names", "../secret.txt", "--confirm"],
78+
)
79+
assert secret.exists()
80+
assert "Invalid model path" in result.output
81+
82+
83+
def test_remove_deletes_model_in_subdirectory(tmp_path):
84+
model_dir = _make_model_tree(tmp_path)
85+
target = model_dir / "checkpoints" / "sd15.safetensors"
86+
assert target.exists()
87+
88+
with patch("comfy_cli.command.models.models.get_workspace", return_value=tmp_path):
89+
result = runner.invoke(
90+
app,
91+
["remove", "--relative-path", "models", "--model-names", "checkpoints/sd15.safetensors", "--confirm"],
92+
)
93+
assert result.exit_code == 0
94+
assert not target.exists()
95+
96+
97+
def test_remove_rejects_directory_name(tmp_path):
98+
_make_model_tree(tmp_path)
99+
100+
with patch("comfy_cli.command.models.models.get_workspace", return_value=tmp_path):
101+
result = runner.invoke(
102+
app,
103+
["remove", "--relative-path", "models", "--model-names", "checkpoints", "--confirm"],
104+
)
105+
assert (tmp_path / "models" / "checkpoints").is_dir()
106+
assert "not found" in result.output
107+
108+
109+
def test_remove_deletes_root_level_model(tmp_path):
110+
model_dir = _make_model_tree(tmp_path)
111+
target = model_dir / "root_model.safetensors"
112+
assert target.exists()
113+
114+
with patch("comfy_cli.command.models.models.get_workspace", return_value=tmp_path):
115+
result = runner.invoke(
116+
app,
117+
["remove", "--relative-path", "models", "--model-names", "root_model.safetensors", "--confirm"],
118+
)
119+
assert result.exit_code == 0
120+
assert not target.exists()
121+
122+
123+
def test_remove_interactive_shows_relative_paths(tmp_path):
124+
_make_model_tree(tmp_path)
125+
126+
with (
127+
patch("comfy_cli.command.models.models.get_workspace", return_value=tmp_path),
128+
patch("comfy_cli.command.models.models.ui") as mock_ui,
129+
):
130+
mock_ui.prompt_multi_select.return_value = ["checkpoints/sd15.safetensors"]
131+
mock_ui.prompt_confirm_action.return_value = True
132+
runner.invoke(app, ["remove", "--relative-path", "models"])
133+
134+
choices = mock_ui.prompt_multi_select.call_args[0][1]
135+
assert "checkpoints/sd15.safetensors" in choices
136+
assert "loras/SD1.5/detail.safetensors" in choices
137+
assert "root_model.safetensors" in choices
138+
assert not (tmp_path / "models" / "checkpoints" / "sd15.safetensors").exists()
2139

3140

4141
def test_valid_model_url():

0 commit comments

Comments
 (0)