comfygit 0.3.1__py3-none-any.whl
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.
- comfygit-0.3.1.dist-info/METADATA +654 -0
- comfygit-0.3.1.dist-info/RECORD +30 -0
- comfygit-0.3.1.dist-info/WHEEL +4 -0
- comfygit-0.3.1.dist-info/entry_points.txt +3 -0
- comfygit-0.3.1.dist-info/licenses/LICENSE.txt +661 -0
- comfygit_cli/__init__.py +12 -0
- comfygit_cli/__main__.py +6 -0
- comfygit_cli/cli.py +704 -0
- comfygit_cli/cli_utils.py +32 -0
- comfygit_cli/completers.py +239 -0
- comfygit_cli/completion_commands.py +246 -0
- comfygit_cli/env_commands.py +2701 -0
- comfygit_cli/formatters/__init__.py +5 -0
- comfygit_cli/formatters/error_formatter.py +141 -0
- comfygit_cli/global_commands.py +1806 -0
- comfygit_cli/interactive/__init__.py +1 -0
- comfygit_cli/logging/compressed_handler.py +150 -0
- comfygit_cli/logging/environment_logger.py +554 -0
- comfygit_cli/logging/log_compressor.py +101 -0
- comfygit_cli/logging/logging_config.py +97 -0
- comfygit_cli/resolution_strategies.py +89 -0
- comfygit_cli/strategies/__init__.py +1 -0
- comfygit_cli/strategies/conflict_resolver.py +113 -0
- comfygit_cli/strategies/interactive.py +843 -0
- comfygit_cli/strategies/rollback.py +40 -0
- comfygit_cli/utils/__init__.py +12 -0
- comfygit_cli/utils/civitai_errors.py +9 -0
- comfygit_cli/utils/orchestrator.py +252 -0
- comfygit_cli/utils/pagination.py +82 -0
- comfygit_cli/utils/progress.py +128 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Confirmation strategies for destructive git operations (checkout, reset)."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class InteractiveRollbackStrategy:
|
|
5
|
+
"""Interactive strategy that prompts user to confirm destructive operations."""
|
|
6
|
+
|
|
7
|
+
def confirm_destructive_rollback(self, git_changes: bool, workflow_changes: bool) -> bool:
|
|
8
|
+
"""Prompt user to confirm operation that will discard changes.
|
|
9
|
+
|
|
10
|
+
Args:
|
|
11
|
+
git_changes: Whether there are uncommitted git changes in .cec/
|
|
12
|
+
workflow_changes: Whether there are modified/new/deleted workflows
|
|
13
|
+
|
|
14
|
+
Returns:
|
|
15
|
+
True if user confirms, False otherwise
|
|
16
|
+
"""
|
|
17
|
+
print("ā ļø This will discard uncommitted changes:")
|
|
18
|
+
if git_changes:
|
|
19
|
+
print(" ⢠Git changes in .cec/")
|
|
20
|
+
if workflow_changes:
|
|
21
|
+
print(" ⢠Workflow modifications in ComfyUI")
|
|
22
|
+
|
|
23
|
+
response = input("\nAre you sure? This cannot be undone. (y/N): ")
|
|
24
|
+
return response.lower() == 'y'
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AutoRollbackStrategy:
|
|
28
|
+
"""Auto-confirm strategy for --yes flag."""
|
|
29
|
+
|
|
30
|
+
def confirm_destructive_rollback(self, git_changes: bool, workflow_changes: bool) -> bool:
|
|
31
|
+
"""Always confirm operation (used with --yes flag).
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
git_changes: Whether there are uncommitted git changes in .cec/
|
|
35
|
+
workflow_changes: Whether there are modified/new/deleted workflows
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
Always True
|
|
39
|
+
"""
|
|
40
|
+
return True
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""CLI utility functions."""
|
|
2
|
+
|
|
3
|
+
from .pagination import paginate
|
|
4
|
+
from .progress import create_progress_callback, show_download_stats
|
|
5
|
+
from .civitai_errors import show_civitai_auth_help
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"paginate",
|
|
9
|
+
"create_progress_callback",
|
|
10
|
+
"show_download_stats",
|
|
11
|
+
"show_civitai_auth_help",
|
|
12
|
+
]
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Civitai error handling utilities."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def show_civitai_auth_help() -> None:
|
|
5
|
+
"""Display helpful message for Civitai authentication errors."""
|
|
6
|
+
print("\nš” Civitai API key required")
|
|
7
|
+
print(" 1. Get your API key from: https://civitai.com/user/account")
|
|
8
|
+
print(" 2. Add it to ComfyGit: comfygit config --civitai-key <your-key>")
|
|
9
|
+
print(" 3. Try downloading again")
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""Orchestrator utility functions for CLI commands."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import signal
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def read_orchestrator_pid(metadata_dir: Path) -> Optional[int]:
|
|
13
|
+
"""Read orchestrator PID from file."""
|
|
14
|
+
pid_file = metadata_dir / ".orchestrator.pid"
|
|
15
|
+
if not pid_file.exists():
|
|
16
|
+
return None
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
return int(pid_file.read_text().strip())
|
|
20
|
+
except ValueError:
|
|
21
|
+
return None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _is_process_running(pid: int) -> bool:
|
|
25
|
+
"""Check if a process is running (cross-platform)."""
|
|
26
|
+
if sys.platform == "win32":
|
|
27
|
+
import ctypes
|
|
28
|
+
kernel32 = ctypes.windll.kernel32
|
|
29
|
+
SYNCHRONIZE = 0x00100000
|
|
30
|
+
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
|
31
|
+
handle = kernel32.OpenProcess(SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
|
|
32
|
+
if handle:
|
|
33
|
+
kernel32.CloseHandle(handle)
|
|
34
|
+
return True
|
|
35
|
+
return False
|
|
36
|
+
else:
|
|
37
|
+
try:
|
|
38
|
+
os.kill(pid, 0) # Signal 0 checks if process exists
|
|
39
|
+
return True
|
|
40
|
+
except ProcessLookupError:
|
|
41
|
+
return False
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def is_orchestrator_running(metadata_dir: Path) -> tuple[bool, Optional[int]]:
|
|
45
|
+
"""Check if orchestrator is running.
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
(is_running, pid) tuple
|
|
49
|
+
"""
|
|
50
|
+
pid = read_orchestrator_pid(metadata_dir)
|
|
51
|
+
if not pid:
|
|
52
|
+
return (False, None)
|
|
53
|
+
|
|
54
|
+
if _is_process_running(pid):
|
|
55
|
+
return (True, pid)
|
|
56
|
+
else:
|
|
57
|
+
return (False, pid) # PID file exists but process is dead
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def read_switch_status(metadata_dir: Path) -> Optional[dict]:
|
|
61
|
+
"""Read environment switch status."""
|
|
62
|
+
status_file = metadata_dir / ".switch_status.json"
|
|
63
|
+
if not status_file.exists():
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
with open(status_file) as f:
|
|
68
|
+
return json.load(f)
|
|
69
|
+
except (json.JSONDecodeError, IOError):
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def safe_write_command(metadata_dir: Path, command: dict) -> None:
|
|
74
|
+
"""
|
|
75
|
+
Atomically write command file for orchestrator.
|
|
76
|
+
|
|
77
|
+
Uses temp file + atomic rename to prevent partial reads.
|
|
78
|
+
"""
|
|
79
|
+
temp_file = metadata_dir / f".cmd.tmp.{os.getpid()}"
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
with open(temp_file, 'w') as f:
|
|
83
|
+
json.dump(command, f)
|
|
84
|
+
|
|
85
|
+
# Atomic rename
|
|
86
|
+
temp_file.replace(metadata_dir / ".cmd")
|
|
87
|
+
finally:
|
|
88
|
+
if temp_file.exists():
|
|
89
|
+
temp_file.unlink()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _kill_process(pid: int, force: bool = False) -> bool:
|
|
93
|
+
"""Kill a process (cross-platform).
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
pid: Process ID to kill
|
|
97
|
+
force: If True, force kill immediately
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
True if kill signal sent, False if process not found
|
|
101
|
+
"""
|
|
102
|
+
if sys.platform == "win32":
|
|
103
|
+
import ctypes
|
|
104
|
+
kernel32 = ctypes.windll.kernel32
|
|
105
|
+
PROCESS_TERMINATE = 0x0001
|
|
106
|
+
handle = kernel32.OpenProcess(PROCESS_TERMINATE, False, pid)
|
|
107
|
+
if handle:
|
|
108
|
+
result = kernel32.TerminateProcess(handle, 1)
|
|
109
|
+
kernel32.CloseHandle(handle)
|
|
110
|
+
return bool(result)
|
|
111
|
+
return False
|
|
112
|
+
else:
|
|
113
|
+
try:
|
|
114
|
+
if force:
|
|
115
|
+
os.kill(pid, signal.SIGKILL)
|
|
116
|
+
else:
|
|
117
|
+
os.kill(pid, signal.SIGTERM)
|
|
118
|
+
return True
|
|
119
|
+
except ProcessLookupError:
|
|
120
|
+
return False
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def kill_orchestrator_process(pid: int, force: bool = False) -> bool:
|
|
124
|
+
"""Kill orchestrator process.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
pid: Process ID to kill
|
|
128
|
+
force: If True, kill immediately without graceful shutdown
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
True if process was killed, False if already dead
|
|
132
|
+
"""
|
|
133
|
+
if not _is_process_running(pid):
|
|
134
|
+
return False # Already dead
|
|
135
|
+
|
|
136
|
+
if force:
|
|
137
|
+
return _kill_process(pid, force=True)
|
|
138
|
+
|
|
139
|
+
# Try graceful shutdown first
|
|
140
|
+
_kill_process(pid, force=False)
|
|
141
|
+
|
|
142
|
+
# Wait for graceful shutdown (3s)
|
|
143
|
+
for _ in range(30):
|
|
144
|
+
time.sleep(0.1)
|
|
145
|
+
if not _is_process_running(pid):
|
|
146
|
+
return True # Process died
|
|
147
|
+
|
|
148
|
+
# Still alive, force kill
|
|
149
|
+
_kill_process(pid, force=True)
|
|
150
|
+
return True
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def cleanup_orchestrator_state(metadata_dir: Path, preserve_config: bool = True) -> list[str]:
|
|
154
|
+
"""Clean up orchestrator state files.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
metadata_dir: Metadata directory path
|
|
158
|
+
preserve_config: If True, keep workspace_config.json
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
List of removed file names
|
|
162
|
+
"""
|
|
163
|
+
files_to_remove = [
|
|
164
|
+
".orchestrator.pid",
|
|
165
|
+
".control_port",
|
|
166
|
+
".cmd",
|
|
167
|
+
".switch_request.json",
|
|
168
|
+
".switch_status.json",
|
|
169
|
+
".switch.lock",
|
|
170
|
+
".startup_state.json",
|
|
171
|
+
]
|
|
172
|
+
|
|
173
|
+
removed = []
|
|
174
|
+
|
|
175
|
+
# Remove specific files
|
|
176
|
+
for filename in files_to_remove:
|
|
177
|
+
file_path = metadata_dir / filename
|
|
178
|
+
if file_path.exists():
|
|
179
|
+
try:
|
|
180
|
+
file_path.unlink()
|
|
181
|
+
removed.append(filename)
|
|
182
|
+
except OSError:
|
|
183
|
+
pass
|
|
184
|
+
|
|
185
|
+
# Remove temp command files
|
|
186
|
+
for temp_file in metadata_dir.glob(".cmd.tmp.*"):
|
|
187
|
+
try:
|
|
188
|
+
temp_file.unlink()
|
|
189
|
+
removed.append(temp_file.name)
|
|
190
|
+
except OSError:
|
|
191
|
+
pass
|
|
192
|
+
|
|
193
|
+
return removed
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def format_uptime(seconds: float) -> str:
|
|
197
|
+
"""Format uptime in human-readable format."""
|
|
198
|
+
if seconds < 60:
|
|
199
|
+
return f"{int(seconds)}s"
|
|
200
|
+
elif seconds < 3600:
|
|
201
|
+
minutes = int(seconds / 60)
|
|
202
|
+
secs = int(seconds % 60)
|
|
203
|
+
return f"{minutes}m {secs}s"
|
|
204
|
+
else:
|
|
205
|
+
hours = int(seconds / 3600)
|
|
206
|
+
minutes = int((seconds % 3600) / 60)
|
|
207
|
+
secs = int(seconds % 60)
|
|
208
|
+
return f"{hours}h {minutes}m {secs}s"
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def get_orchestrator_uptime(metadata_dir: Path, pid: int) -> Optional[float]:
|
|
212
|
+
"""Get orchestrator uptime in seconds.
|
|
213
|
+
|
|
214
|
+
Reads process start time from /proc on Linux.
|
|
215
|
+
Returns None if cannot determine.
|
|
216
|
+
"""
|
|
217
|
+
try:
|
|
218
|
+
# Linux: read from /proc
|
|
219
|
+
stat_file = Path(f"/proc/{pid}/stat")
|
|
220
|
+
if stat_file.exists():
|
|
221
|
+
stat = stat_file.read_text()
|
|
222
|
+
# Field 22 is starttime (clock ticks since boot)
|
|
223
|
+
fields = stat.split()
|
|
224
|
+
start_ticks = int(fields[21])
|
|
225
|
+
|
|
226
|
+
# Get system boot time and clock ticks per second
|
|
227
|
+
with open("/proc/uptime") as f:
|
|
228
|
+
uptime_secs = float(f.read().split()[0])
|
|
229
|
+
|
|
230
|
+
# Calculate process start time
|
|
231
|
+
clk_tck = os.sysconf(os.sysconf_names['SC_CLK_TCK'])
|
|
232
|
+
process_start = start_ticks / clk_tck
|
|
233
|
+
current_uptime = uptime_secs
|
|
234
|
+
|
|
235
|
+
return current_uptime - process_start
|
|
236
|
+
except (FileNotFoundError, ValueError, OSError):
|
|
237
|
+
pass
|
|
238
|
+
|
|
239
|
+
return None
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def tail_log_file(log_file: Path, num_lines: int = 50) -> list[str]:
|
|
243
|
+
"""Tail last N lines from log file."""
|
|
244
|
+
if not log_file.exists():
|
|
245
|
+
return []
|
|
246
|
+
|
|
247
|
+
try:
|
|
248
|
+
with open(log_file, 'r') as f:
|
|
249
|
+
lines = f.readlines()
|
|
250
|
+
return lines[-num_lines:]
|
|
251
|
+
except IOError:
|
|
252
|
+
return []
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Simple pagination utility for CLI output."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from typing import Callable, TypeVar
|
|
5
|
+
|
|
6
|
+
T = TypeVar('T')
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def paginate(
|
|
10
|
+
items: list[T],
|
|
11
|
+
render_item: Callable[[T], None],
|
|
12
|
+
page_size: int = 5,
|
|
13
|
+
header: str = ""
|
|
14
|
+
) -> None:
|
|
15
|
+
"""Display items with pagination controls.
|
|
16
|
+
|
|
17
|
+
Auto-detects if stdout is a TTY. If piped, dumps all items without pagination.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
items: List of items to paginate
|
|
21
|
+
render_item: Function to render a single item
|
|
22
|
+
page_size: Number of items per page
|
|
23
|
+
header: Optional header to display before results
|
|
24
|
+
"""
|
|
25
|
+
if not items:
|
|
26
|
+
return
|
|
27
|
+
|
|
28
|
+
# Auto-detect: if not a TTY (piped/redirected), dump everything
|
|
29
|
+
if not sys.stdout.isatty():
|
|
30
|
+
if header:
|
|
31
|
+
print(header)
|
|
32
|
+
for item in items:
|
|
33
|
+
render_item(item)
|
|
34
|
+
return
|
|
35
|
+
|
|
36
|
+
# Interactive pagination for TTY
|
|
37
|
+
total_items = len(items)
|
|
38
|
+
total_pages = (total_items + page_size - 1) // page_size
|
|
39
|
+
current_page = 0
|
|
40
|
+
|
|
41
|
+
while True:
|
|
42
|
+
# Clear screen and show header
|
|
43
|
+
print("\033[2J\033[H", end="") # Clear screen, move to top
|
|
44
|
+
|
|
45
|
+
if header:
|
|
46
|
+
print(header)
|
|
47
|
+
|
|
48
|
+
# Calculate page bounds
|
|
49
|
+
start_idx = current_page * page_size
|
|
50
|
+
end_idx = min(start_idx + page_size, total_items)
|
|
51
|
+
|
|
52
|
+
# Display items for current page
|
|
53
|
+
for item in items[start_idx:end_idx]:
|
|
54
|
+
render_item(item)
|
|
55
|
+
|
|
56
|
+
# Display pagination controls
|
|
57
|
+
print(f"\n{'ā' * 60}")
|
|
58
|
+
print(f"Page {current_page + 1}/{total_pages} (showing {start_idx + 1}-{end_idx} of {total_items})")
|
|
59
|
+
|
|
60
|
+
# Build prompt based on available navigation
|
|
61
|
+
options = []
|
|
62
|
+
if current_page < total_pages - 1:
|
|
63
|
+
options.append("[n]ext")
|
|
64
|
+
if current_page > 0:
|
|
65
|
+
options.append("[p]rev")
|
|
66
|
+
options.append("[q]uit")
|
|
67
|
+
|
|
68
|
+
prompt = f"{' | '.join(options)}: "
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
choice = input(prompt).lower().strip()
|
|
72
|
+
except (EOFError, KeyboardInterrupt):
|
|
73
|
+
print("\n")
|
|
74
|
+
break
|
|
75
|
+
|
|
76
|
+
if choice == 'n' and current_page < total_pages - 1:
|
|
77
|
+
current_page += 1
|
|
78
|
+
elif choice == 'p' and current_page > 0:
|
|
79
|
+
current_page -= 1
|
|
80
|
+
elif choice == 'q':
|
|
81
|
+
break
|
|
82
|
+
# Invalid input - just redisplay current page
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Progress display utilities for downloads and model scanning."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
|
|
5
|
+
from comfygit_core.analyzers.model_scanner import ModelScanProgress, ScanResult
|
|
6
|
+
from comfygit_core.models.shared import ModelWithLocation
|
|
7
|
+
from comfygit_core.models.workflow import BatchDownloadCallbacks
|
|
8
|
+
from comfygit_core.utils.common import format_size
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def create_progress_callback() -> Callable[[int, int | None], None]:
|
|
12
|
+
"""Create a reusable progress callback for model downloads.
|
|
13
|
+
|
|
14
|
+
Returns:
|
|
15
|
+
Callback function that displays download progress
|
|
16
|
+
"""
|
|
17
|
+
def progress_callback(downloaded: int, total: int | None):
|
|
18
|
+
"""Display progress bar using carriage return."""
|
|
19
|
+
downloaded_mb = downloaded / (1024 * 1024)
|
|
20
|
+
if total:
|
|
21
|
+
total_mb = total / (1024 * 1024)
|
|
22
|
+
pct = (downloaded / total) * 100
|
|
23
|
+
print(f"\rDownloading... {downloaded_mb:.1f} MB / {total_mb:.1f} MB ({pct:.0f}%)", end='', flush=True)
|
|
24
|
+
else:
|
|
25
|
+
print(f"\rDownloading... {downloaded_mb:.1f} MB", end='', flush=True)
|
|
26
|
+
|
|
27
|
+
return progress_callback
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def show_download_stats(model: ModelWithLocation | None) -> None:
|
|
31
|
+
"""Display statistics after successful download.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
model: Downloaded and indexed model
|
|
35
|
+
"""
|
|
36
|
+
if not model:
|
|
37
|
+
return
|
|
38
|
+
size_str = format_size(model.file_size)
|
|
39
|
+
print(f"ā Downloaded and indexed: {model.relative_path}")
|
|
40
|
+
print(f" Size: {size_str}")
|
|
41
|
+
print(f" Hash: {model.hash}")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def create_batch_download_callbacks() -> BatchDownloadCallbacks:
|
|
45
|
+
"""Create CLI callbacks for batch downloads with terminal output.
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
BatchDownloadCallbacks configured for CLI rendering
|
|
49
|
+
"""
|
|
50
|
+
def on_batch_start(count: int) -> None:
|
|
51
|
+
print(f"\nā¬ļø Downloading {count} model(s)...")
|
|
52
|
+
|
|
53
|
+
def on_file_start(name: str, idx: int, total: int) -> None:
|
|
54
|
+
print(f"\n[{idx}/{total}] {name}")
|
|
55
|
+
|
|
56
|
+
def on_file_complete(name: str, success: bool, error: str | None) -> None:
|
|
57
|
+
if success:
|
|
58
|
+
print(" ā Complete")
|
|
59
|
+
else:
|
|
60
|
+
print(f" ā Failed: {error}")
|
|
61
|
+
|
|
62
|
+
def on_batch_complete(success: int, total: int) -> None:
|
|
63
|
+
if success == total:
|
|
64
|
+
print(f"\nā
Downloaded {total} model(s)")
|
|
65
|
+
elif success > 0:
|
|
66
|
+
print(f"\nā ļø Downloaded {success}/{total} models (some failed)")
|
|
67
|
+
else:
|
|
68
|
+
print(f"\nā All downloads failed (0/{total})")
|
|
69
|
+
|
|
70
|
+
return BatchDownloadCallbacks(
|
|
71
|
+
on_batch_start=on_batch_start,
|
|
72
|
+
on_file_start=on_file_start,
|
|
73
|
+
on_file_progress=create_progress_callback(),
|
|
74
|
+
on_file_complete=on_file_complete,
|
|
75
|
+
on_batch_complete=on_batch_complete
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class ModelSyncProgress(ModelScanProgress):
|
|
80
|
+
"""CLI progress display for model scanning with conditional visibility.
|
|
81
|
+
|
|
82
|
+
Shows progress bar only when models are being processed (not just scanned).
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
def __init__(self):
|
|
86
|
+
self.total_files = 0
|
|
87
|
+
self.shown = False
|
|
88
|
+
|
|
89
|
+
def on_scan_start(self, total_files: int) -> None:
|
|
90
|
+
"""Called when scan starts."""
|
|
91
|
+
self.total_files = total_files
|
|
92
|
+
# Show initial message - we'll update it as we go
|
|
93
|
+
if total_files > 0:
|
|
94
|
+
print("š Syncing model index...", end='', flush=True)
|
|
95
|
+
self.shown = True
|
|
96
|
+
|
|
97
|
+
def on_file_processed(self, current: int, total: int, filename: str) -> None:
|
|
98
|
+
"""Update progress bar."""
|
|
99
|
+
if self.shown and total > 100: # Only show detailed progress for large directories
|
|
100
|
+
print(f"\rš Syncing model index... {current}/{total} files", end='', flush=True)
|
|
101
|
+
|
|
102
|
+
def on_scan_complete(self, result: ScanResult) -> None:
|
|
103
|
+
"""Show summary only if there were changes."""
|
|
104
|
+
has_changes = result.added_count > 0 or result.updated_count > 0
|
|
105
|
+
|
|
106
|
+
if self.shown:
|
|
107
|
+
if has_changes:
|
|
108
|
+
# Clear progress line and show summary
|
|
109
|
+
print("\r\033[K", end='') # Clear line (carriage return + clear to end of line)
|
|
110
|
+
changes = []
|
|
111
|
+
if result.added_count > 0:
|
|
112
|
+
changes.append(f"{result.added_count} added")
|
|
113
|
+
if result.updated_count > 0:
|
|
114
|
+
changes.append(f"{result.updated_count} updated")
|
|
115
|
+
print(f"ā Model index synced: {', '.join(changes)}")
|
|
116
|
+
else:
|
|
117
|
+
# Clear the line completely if no changes
|
|
118
|
+
print("\r\033[K", end='', flush=True) # Clear line completely
|
|
119
|
+
# Don't print anything - silent when no changes
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def create_model_sync_progress() -> ModelSyncProgress:
|
|
123
|
+
"""Create progress callback for model index syncing.
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
ModelSyncProgress instance that conditionally displays progress
|
|
127
|
+
"""
|
|
128
|
+
return ModelSyncProgress()
|