comfygit-deploy 0.3.4__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,438 @@
1
+ """Native process manager for running ComfyUI without Docker.
2
+
3
+ Uses `cg` CLI directly to import environments and run ComfyUI processes.
4
+ """
5
+
6
+ import asyncio
7
+ import os
8
+ import shutil
9
+ import signal
10
+ import subprocess
11
+ import time
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+
15
+ import aiohttp
16
+
17
+ from ..commands.dev import get_dev_nodes
18
+
19
+
20
+ @dataclass
21
+ class ProcessInfo:
22
+ """Info about a running ComfyUI process."""
23
+
24
+ pid: int
25
+ port: int
26
+ returncode: int | None = None
27
+
28
+
29
+ @dataclass
30
+ class DeployResult:
31
+ """Result of a deploy operation."""
32
+
33
+ success: bool
34
+ skipped: bool = False
35
+ error: str | None = None
36
+
37
+
38
+ @dataclass
39
+ class ProcessLogs:
40
+ """Captured process output."""
41
+
42
+ stdout: list[str]
43
+ stderr: list[str]
44
+
45
+
46
+ class NativeManager:
47
+ """Manages ComfyUI instances as native processes."""
48
+
49
+ def __init__(self, workspace_path: Path):
50
+ """Initialize native manager.
51
+
52
+ Args:
53
+ workspace_path: Path to ComfyGit workspace
54
+ """
55
+ self.workspace_path = workspace_path
56
+ self._processes: dict[str, subprocess.Popen] = {}
57
+ self._log_buffers: dict[str, list[str]] = {} # Ring buffers for log capture
58
+ self._max_log_lines: int = 1000 # Keep last N lines per instance
59
+
60
+ def environment_exists(self, environment_name: str) -> bool:
61
+ """Check if an environment is fully set up.
62
+
63
+ An environment is considered to exist if its ComfyUI directory is present.
64
+ This matches the RunPod script behavior for restart detection.
65
+
66
+ Args:
67
+ environment_name: Name of the environment
68
+
69
+ Returns:
70
+ True if environment has ComfyUI installed
71
+ """
72
+ comfyui_path = (
73
+ self.workspace_path / "environments" / environment_name / "ComfyUI"
74
+ )
75
+ return comfyui_path.is_dir()
76
+
77
+ def delete_environment(self, environment_name: str) -> bool:
78
+ """Delete an environment directory.
79
+
80
+ Args:
81
+ environment_name: Name of the environment to delete
82
+
83
+ Returns:
84
+ True if deleted, False if not found
85
+ """
86
+ import shutil
87
+
88
+ env_path = self.workspace_path / "environments" / environment_name
89
+ if env_path.is_dir():
90
+ shutil.rmtree(env_path)
91
+ return True
92
+ return False
93
+
94
+ async def deploy(
95
+ self,
96
+ instance_id: str,
97
+ environment_name: str,
98
+ import_source: str,
99
+ branch: str | None = None,
100
+ ) -> DeployResult:
101
+ """Deploy an environment by cloning from git.
102
+
103
+ If the environment already exists (has ComfyUI directory), skips import
104
+ and returns success with skipped=True.
105
+
106
+ Args:
107
+ instance_id: Unique instance identifier
108
+ environment_name: Name for the environment
109
+ import_source: Git URL to import from
110
+ branch: Optional branch/tag to checkout
111
+
112
+ Returns:
113
+ DeployResult with success/skipped/error status
114
+ """
115
+ # Check if environment already exists (e.g., worker restart)
116
+ if self.environment_exists(environment_name):
117
+ # Still apply dev nodes in case config changed
118
+ self._apply_dev_nodes(environment_name)
119
+ return DeployResult(success=True, skipped=True)
120
+
121
+ # Build import command
122
+ cmd = [
123
+ "cg",
124
+ "import",
125
+ import_source,
126
+ "--name",
127
+ environment_name,
128
+ "-y",
129
+ "--models",
130
+ "all",
131
+ ]
132
+ if branch:
133
+ cmd.extend(["--branch", branch])
134
+
135
+ env = os.environ.copy()
136
+ env["COMFYGIT_HOME"] = str(self.workspace_path)
137
+
138
+ # Run import in subprocess
139
+ proc = await asyncio.create_subprocess_exec(
140
+ *cmd,
141
+ env=env,
142
+ stdout=asyncio.subprocess.PIPE,
143
+ stderr=asyncio.subprocess.STDOUT,
144
+ )
145
+
146
+ # Wait for completion
147
+ stdout, _ = await proc.communicate()
148
+
149
+ if proc.returncode != 0:
150
+ output = stdout.decode() if stdout else ""
151
+ return DeployResult(
152
+ success=False,
153
+ error=f"Import failed for {instance_id}: {output}",
154
+ )
155
+
156
+ # Apply dev nodes if configured
157
+ self._apply_dev_nodes(environment_name)
158
+
159
+ return DeployResult(success=True, skipped=False)
160
+
161
+ def _apply_dev_nodes(self, environment_name: str) -> None:
162
+ """Apply configured dev nodes to an environment.
163
+
164
+ Creates symlinks and tracks with cg node add --dev.
165
+ """
166
+ dev_nodes = get_dev_nodes()
167
+ if not dev_nodes:
168
+ return
169
+
170
+ env_path = self.workspace_path / "environments" / environment_name
171
+ custom_nodes = env_path / "ComfyUI" / "custom_nodes"
172
+
173
+ if not custom_nodes.exists():
174
+ return
175
+
176
+ env = os.environ.copy()
177
+ env["COMFYGIT_HOME"] = str(self.workspace_path)
178
+
179
+ for node in dev_nodes:
180
+ target = custom_nodes / node.name
181
+ source = Path(node.path)
182
+
183
+ if not source.exists():
184
+ continue
185
+
186
+ # Create/update symlink
187
+ if target.is_symlink():
188
+ if target.resolve() != source.resolve():
189
+ target.unlink()
190
+ target.symlink_to(source)
191
+ elif target.exists():
192
+ shutil.rmtree(target)
193
+ target.symlink_to(source)
194
+ else:
195
+ target.symlink_to(source)
196
+
197
+ # Track with cg node add --dev
198
+ cmd = ["cg", "-e", environment_name, "node", "add", node.name, "--dev"]
199
+ subprocess.run(cmd, env=env, capture_output=True)
200
+
201
+ def start(
202
+ self,
203
+ instance_id: str,
204
+ environment_name: str,
205
+ port: int,
206
+ listen_host: str = "0.0.0.0",
207
+ ) -> ProcessInfo | None:
208
+ """Start ComfyUI process for an environment.
209
+
210
+ Args:
211
+ instance_id: Instance identifier for tracking
212
+ environment_name: Environment to run
213
+ port: Port for ComfyUI
214
+ listen_host: Host to listen on
215
+
216
+ Returns:
217
+ ProcessInfo if started successfully, None otherwise
218
+ """
219
+ if instance_id in self._processes:
220
+ proc = self._processes[instance_id]
221
+ if proc.poll() is None:
222
+ # Already running
223
+ return ProcessInfo(pid=proc.pid, port=port)
224
+
225
+ cmd = [
226
+ "cg",
227
+ "-e",
228
+ environment_name,
229
+ "run",
230
+ "--no-sync", # Skip sync since we just imported
231
+ "--listen",
232
+ listen_host,
233
+ "--port",
234
+ str(port),
235
+ ]
236
+
237
+ env = os.environ.copy()
238
+ env["COMFYGIT_HOME"] = str(self.workspace_path)
239
+
240
+ try:
241
+ proc = subprocess.Popen(
242
+ cmd,
243
+ env=env,
244
+ stdout=subprocess.PIPE,
245
+ stderr=subprocess.STDOUT,
246
+ start_new_session=True, # Detach from parent
247
+ text=True,
248
+ bufsize=1, # Line buffered
249
+ )
250
+ self._processes[instance_id] = proc
251
+ self._log_buffers[instance_id] = []
252
+
253
+ # Start background thread to read output
254
+ import threading
255
+ def read_output():
256
+ try:
257
+ for line in proc.stdout:
258
+ buf = self._log_buffers.get(instance_id, [])
259
+ buf.append(line.rstrip())
260
+ if len(buf) > self._max_log_lines:
261
+ buf.pop(0)
262
+ except Exception:
263
+ pass
264
+
265
+ thread = threading.Thread(target=read_output, daemon=True)
266
+ thread.start()
267
+
268
+ return ProcessInfo(pid=proc.pid, port=port)
269
+ except Exception as e:
270
+ print(f"Failed to start {instance_id}: {e}")
271
+ return None
272
+
273
+ def stop(self, instance_id: str, pid: int | None = None) -> bool:
274
+ """Stop a running ComfyUI process.
275
+
276
+ Args:
277
+ instance_id: Instance to stop
278
+ pid: Optional PID to kill if process not tracked in memory
279
+
280
+ Returns:
281
+ True if stopped (or wasn't running)
282
+ """
283
+ proc = self._processes.get(instance_id)
284
+
285
+ # If we have a tracked process, use it
286
+ if proc:
287
+ if proc.poll() is not None:
288
+ # Already dead
289
+ return True
290
+
291
+ try:
292
+ # Send SIGTERM to process group
293
+ os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
294
+
295
+ # Wait up to 5 seconds for graceful shutdown
296
+ try:
297
+ proc.wait(timeout=5)
298
+ except subprocess.TimeoutExpired:
299
+ # Force kill
300
+ os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
301
+ proc.wait(timeout=2)
302
+
303
+ return True
304
+ except ProcessLookupError:
305
+ return True
306
+ except Exception as e:
307
+ print(f"Error stopping {instance_id}: {e}")
308
+ return False
309
+
310
+ # No tracked process - try to kill by PID if provided
311
+ if pid:
312
+ try:
313
+ os.killpg(os.getpgid(pid), signal.SIGTERM)
314
+ # Give it a moment to die
315
+ time.sleep(0.5)
316
+ # Check if still alive and force kill
317
+ try:
318
+ os.kill(pid, 0) # Check if process exists
319
+ os.killpg(os.getpgid(pid), signal.SIGKILL)
320
+ except ProcessLookupError:
321
+ pass # Already dead
322
+ return True
323
+ except ProcessLookupError:
324
+ return True # Already dead
325
+ except (PermissionError, OSError):
326
+ return False
327
+
328
+ return True
329
+
330
+ def terminate(self, instance_id: str, pid: int | None = None) -> bool:
331
+ """Terminate instance and remove tracking.
332
+
333
+ Args:
334
+ instance_id: Instance to terminate
335
+ pid: Optional PID to kill if process not tracked in memory
336
+
337
+ Returns:
338
+ True if terminated successfully
339
+ """
340
+ result = self.stop(instance_id, pid=pid)
341
+ self._processes.pop(instance_id, None)
342
+ return result
343
+
344
+ def is_running(self, instance_id: str) -> bool:
345
+ """Check if instance process is running.
346
+
347
+ Args:
348
+ instance_id: Instance to check
349
+
350
+ Returns:
351
+ True if process is alive
352
+ """
353
+ proc = self._processes.get(instance_id)
354
+ if not proc:
355
+ return False
356
+ return proc.poll() is None
357
+
358
+ def get_pid(self, instance_id: str) -> int | None:
359
+ """Get PID of running instance.
360
+
361
+ Args:
362
+ instance_id: Instance to check
363
+
364
+ Returns:
365
+ PID if running, None otherwise
366
+ """
367
+ proc = self._processes.get(instance_id)
368
+ if proc and proc.poll() is None:
369
+ return proc.pid
370
+ return None
371
+
372
+ def recover_process(self, instance_id: str, pid: int) -> bool:
373
+ """Attempt to recover tracking for a process from a previous run.
374
+
375
+ Args:
376
+ instance_id: Instance identifier
377
+ pid: PID from previous run
378
+
379
+ Returns:
380
+ True if process is still alive and now tracked
381
+ """
382
+ try:
383
+ os.kill(pid, 0) # Check if process exists
384
+ # We can't recover the Popen object, but we can track the PID
385
+ # For now, just report if it's alive
386
+ return True
387
+ except (ProcessLookupError, PermissionError):
388
+ return False
389
+
390
+ def get_logs(self, instance_id: str, lines: int = 100) -> ProcessLogs:
391
+ """Get recent logs from an instance.
392
+
393
+ Args:
394
+ instance_id: Instance to get logs for
395
+ lines: Maximum number of lines to return
396
+
397
+ Returns:
398
+ ProcessLogs with stdout/stderr content
399
+ """
400
+ buf = self._log_buffers.get(instance_id, [])
401
+ # Return last N lines
402
+ return ProcessLogs(stdout=buf[-lines:], stderr=[])
403
+
404
+ async def wait_for_ready(
405
+ self,
406
+ port: int,
407
+ timeout_seconds: float = 120.0,
408
+ poll_interval: float = 2.0,
409
+ ) -> bool:
410
+ """Wait for ComfyUI to become ready by polling HTTP endpoint.
411
+
412
+ Polls the ComfyUI HTTP endpoint until it responds successfully
413
+ or the timeout is reached.
414
+
415
+ Args:
416
+ port: Port ComfyUI is listening on
417
+ timeout_seconds: Maximum time to wait (default 2 minutes)
418
+ poll_interval: Time between polling attempts
419
+
420
+ Returns:
421
+ True if ComfyUI is ready, False if timeout expired
422
+ """
423
+ url = f"http://localhost:{port}/"
424
+ deadline = time.monotonic() + timeout_seconds
425
+
426
+ async with aiohttp.ClientSession() as session:
427
+ while time.monotonic() < deadline:
428
+ try:
429
+ async with session.get(url, timeout=aiohttp.ClientTimeout(total=5)) as resp:
430
+ if resp.status == 200:
431
+ return True
432
+ except Exception:
433
+ # Connection refused, timeout, etc - keep trying
434
+ pass
435
+
436
+ await asyncio.sleep(poll_interval)
437
+
438
+ return False