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.
@@ -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()