comfygit-core 0.2.0__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.
Files changed (93) hide show
  1. comfygit_core/analyzers/custom_node_scanner.py +109 -0
  2. comfygit_core/analyzers/git_change_parser.py +156 -0
  3. comfygit_core/analyzers/model_scanner.py +318 -0
  4. comfygit_core/analyzers/node_classifier.py +58 -0
  5. comfygit_core/analyzers/node_git_analyzer.py +77 -0
  6. comfygit_core/analyzers/status_scanner.py +362 -0
  7. comfygit_core/analyzers/workflow_dependency_parser.py +143 -0
  8. comfygit_core/caching/__init__.py +16 -0
  9. comfygit_core/caching/api_cache.py +210 -0
  10. comfygit_core/caching/base.py +212 -0
  11. comfygit_core/caching/comfyui_cache.py +100 -0
  12. comfygit_core/caching/custom_node_cache.py +320 -0
  13. comfygit_core/caching/workflow_cache.py +797 -0
  14. comfygit_core/clients/__init__.py +4 -0
  15. comfygit_core/clients/civitai_client.py +412 -0
  16. comfygit_core/clients/github_client.py +349 -0
  17. comfygit_core/clients/registry_client.py +230 -0
  18. comfygit_core/configs/comfyui_builtin_nodes.py +1614 -0
  19. comfygit_core/configs/comfyui_models.py +62 -0
  20. comfygit_core/configs/model_config.py +151 -0
  21. comfygit_core/constants.py +82 -0
  22. comfygit_core/core/environment.py +1635 -0
  23. comfygit_core/core/workspace.py +898 -0
  24. comfygit_core/factories/environment_factory.py +419 -0
  25. comfygit_core/factories/uv_factory.py +61 -0
  26. comfygit_core/factories/workspace_factory.py +109 -0
  27. comfygit_core/infrastructure/sqlite_manager.py +156 -0
  28. comfygit_core/integrations/__init__.py +7 -0
  29. comfygit_core/integrations/uv_command.py +318 -0
  30. comfygit_core/logging/logging_config.py +15 -0
  31. comfygit_core/managers/environment_git_orchestrator.py +316 -0
  32. comfygit_core/managers/environment_model_manager.py +296 -0
  33. comfygit_core/managers/export_import_manager.py +116 -0
  34. comfygit_core/managers/git_manager.py +667 -0
  35. comfygit_core/managers/model_download_manager.py +252 -0
  36. comfygit_core/managers/model_symlink_manager.py +166 -0
  37. comfygit_core/managers/node_manager.py +1378 -0
  38. comfygit_core/managers/pyproject_manager.py +1321 -0
  39. comfygit_core/managers/user_content_symlink_manager.py +436 -0
  40. comfygit_core/managers/uv_project_manager.py +569 -0
  41. comfygit_core/managers/workflow_manager.py +1944 -0
  42. comfygit_core/models/civitai.py +432 -0
  43. comfygit_core/models/commit.py +18 -0
  44. comfygit_core/models/environment.py +293 -0
  45. comfygit_core/models/exceptions.py +378 -0
  46. comfygit_core/models/manifest.py +132 -0
  47. comfygit_core/models/node_mapping.py +201 -0
  48. comfygit_core/models/protocols.py +248 -0
  49. comfygit_core/models/registry.py +63 -0
  50. comfygit_core/models/shared.py +356 -0
  51. comfygit_core/models/sync.py +42 -0
  52. comfygit_core/models/system.py +204 -0
  53. comfygit_core/models/workflow.py +914 -0
  54. comfygit_core/models/workspace_config.py +71 -0
  55. comfygit_core/py.typed +0 -0
  56. comfygit_core/repositories/migrate_paths.py +49 -0
  57. comfygit_core/repositories/model_repository.py +958 -0
  58. comfygit_core/repositories/node_mappings_repository.py +246 -0
  59. comfygit_core/repositories/workflow_repository.py +57 -0
  60. comfygit_core/repositories/workspace_config_repository.py +121 -0
  61. comfygit_core/resolvers/global_node_resolver.py +459 -0
  62. comfygit_core/resolvers/model_resolver.py +250 -0
  63. comfygit_core/services/import_analyzer.py +218 -0
  64. comfygit_core/services/model_downloader.py +422 -0
  65. comfygit_core/services/node_lookup_service.py +251 -0
  66. comfygit_core/services/registry_data_manager.py +161 -0
  67. comfygit_core/strategies/__init__.py +4 -0
  68. comfygit_core/strategies/auto.py +72 -0
  69. comfygit_core/strategies/confirmation.py +69 -0
  70. comfygit_core/utils/comfyui_ops.py +125 -0
  71. comfygit_core/utils/common.py +164 -0
  72. comfygit_core/utils/conflict_parser.py +232 -0
  73. comfygit_core/utils/dependency_parser.py +231 -0
  74. comfygit_core/utils/download.py +216 -0
  75. comfygit_core/utils/environment_cleanup.py +111 -0
  76. comfygit_core/utils/filesystem.py +178 -0
  77. comfygit_core/utils/git.py +1184 -0
  78. comfygit_core/utils/input_signature.py +145 -0
  79. comfygit_core/utils/model_categories.py +52 -0
  80. comfygit_core/utils/pytorch.py +71 -0
  81. comfygit_core/utils/requirements.py +211 -0
  82. comfygit_core/utils/retry.py +242 -0
  83. comfygit_core/utils/symlink_utils.py +119 -0
  84. comfygit_core/utils/system_detector.py +258 -0
  85. comfygit_core/utils/uuid.py +28 -0
  86. comfygit_core/utils/uv_error_handler.py +158 -0
  87. comfygit_core/utils/version.py +73 -0
  88. comfygit_core/utils/workflow_hash.py +90 -0
  89. comfygit_core/validation/resolution_tester.py +297 -0
  90. comfygit_core-0.2.0.dist-info/METADATA +939 -0
  91. comfygit_core-0.2.0.dist-info/RECORD +93 -0
  92. comfygit_core-0.2.0.dist-info/WHEEL +4 -0
  93. comfygit_core-0.2.0.dist-info/licenses/LICENSE.txt +661 -0
@@ -0,0 +1,242 @@
1
+ """Retry utilities with exponential backoff for API rate limiting."""
2
+
3
+ import random
4
+ import time
5
+ import urllib.error
6
+ from collections.abc import Callable
7
+ from functools import wraps
8
+ from typing import Any
9
+
10
+ from ..logging.logging_config import get_logger
11
+
12
+ logger = get_logger(__name__)
13
+
14
+
15
+ class RetryConfig:
16
+ """Configuration for retry behavior."""
17
+
18
+ def __init__(
19
+ self,
20
+ max_retries: int = 5,
21
+ initial_delay: float = 1.0,
22
+ max_delay: float = 60.0,
23
+ exponential_base: float = 2.0,
24
+ jitter: bool = True,
25
+ ):
26
+ """Initialize retry configuration.
27
+
28
+ Args:
29
+ max_retries: Maximum number of retry attempts
30
+ initial_delay: Initial delay in seconds
31
+ max_delay: Maximum delay in seconds
32
+ exponential_base: Base for exponential backoff
33
+ jitter: Add random jitter to delays
34
+ """
35
+ self.max_retries = max_retries
36
+ self.initial_delay = initial_delay
37
+ self.max_delay = max_delay
38
+ self.exponential_base = exponential_base
39
+ self.jitter = jitter
40
+
41
+
42
+ def calculate_backoff_delay(attempt: int, config: RetryConfig) -> float:
43
+ """Calculate exponential backoff delay with optional jitter.
44
+
45
+ Args:
46
+ attempt: Current attempt number (0-based)
47
+ config: Retry configuration
48
+
49
+ Returns:
50
+ Delay in seconds
51
+ """
52
+ # Calculate exponential backoff
53
+ delay = min(
54
+ config.initial_delay * (config.exponential_base**attempt), config.max_delay
55
+ )
56
+
57
+ # Add jitter if enabled
58
+ if config.jitter:
59
+ # Add random jitter between 0 and 25% of the delay
60
+ jitter_amount = delay * 0.25 * random.random()
61
+ delay += jitter_amount
62
+
63
+ return delay
64
+
65
+
66
+ def is_rate_limit_error(error: Exception) -> bool:
67
+ """Check if an error is a rate limit error.
68
+
69
+ Args:
70
+ error: The exception to check
71
+
72
+ Returns:
73
+ True if this is a rate limit error
74
+ """
75
+ if isinstance(error, urllib.error.HTTPError):
76
+ # GitHub returns 403 for rate limits
77
+ if error.code == 403:
78
+ # Check headers for rate limit indication
79
+ headers = error.headers
80
+ if headers.get("X-RateLimit-Remaining") == "0":
81
+ return True
82
+ # Also check for rate limit message in response
83
+ try:
84
+ error_data = error.read().decode("utf-8")
85
+ if (
86
+ "rate limit" in error_data.lower()
87
+ or "api rate limit" in error_data.lower()
88
+ ):
89
+ return True
90
+ except Exception:
91
+ pass
92
+ # Some APIs return 429 for rate limits
93
+ elif error.code == 429:
94
+ return True
95
+
96
+ return False
97
+
98
+
99
+ def retry_on_rate_limit(config: RetryConfig | None = None):
100
+ """Decorator for retrying functions that may hit rate limits.
101
+
102
+ Args:
103
+ config: Retry configuration (uses defaults if not provided)
104
+
105
+ Returns:
106
+ Decorated function
107
+ """
108
+ if config is None:
109
+ config = RetryConfig()
110
+
111
+ def decorator(func: Callable):
112
+ @wraps(func)
113
+ def wrapper(*args, **kwargs):
114
+ last_exception = None
115
+
116
+ for attempt in range(config.max_retries + 1):
117
+ try:
118
+ return func(*args, **kwargs)
119
+
120
+ except Exception as e:
121
+ last_exception = e
122
+
123
+ # Check if this is a rate limit error
124
+ if is_rate_limit_error(e):
125
+ if attempt < config.max_retries:
126
+ delay = calculate_backoff_delay(attempt, config)
127
+ logger.warning(
128
+ f"Rate limit hit in {func.__name__}, "
129
+ f"retrying in {delay:.1f}s (attempt {attempt + 1}/{config.max_retries})"
130
+ )
131
+ time.sleep(delay)
132
+ continue
133
+ else:
134
+ logger.error(
135
+ f"Rate limit hit in {func.__name__}, "
136
+ f"max retries ({config.max_retries}) exceeded"
137
+ )
138
+
139
+ # Re-raise if not a rate limit error
140
+ raise
141
+
142
+ # If we get here, we've exhausted retries
143
+ if last_exception:
144
+ raise last_exception
145
+
146
+ return wrapper
147
+
148
+ return decorator
149
+
150
+
151
+ def retry_with_backoff(
152
+ func: Callable,
153
+ args: tuple = (),
154
+ kwargs: dict | None = None,
155
+ config: RetryConfig | None = None,
156
+ on_retry: Callable[[int, Exception], None] | None = None,
157
+ ) -> Any:
158
+ """Execute a function with retry logic and exponential backoff.
159
+
160
+ Args:
161
+ func: Function to execute
162
+ args: Positional arguments for the function
163
+ kwargs: Keyword arguments for the function
164
+ config: Retry configuration
165
+ on_retry: Optional callback called on each retry with (attempt, exception)
166
+
167
+ Returns:
168
+ Result of the function call
169
+
170
+ Raises:
171
+ The last exception if all retries are exhausted
172
+ """
173
+ if kwargs is None:
174
+ kwargs = {}
175
+ if config is None:
176
+ config = RetryConfig()
177
+
178
+ last_exception = None
179
+
180
+ for attempt in range(config.max_retries + 1):
181
+ try:
182
+ return func(*args, **kwargs)
183
+
184
+ except Exception as e:
185
+ last_exception = e
186
+
187
+ # Check if this is a rate limit error
188
+ if is_rate_limit_error(e):
189
+ if attempt < config.max_retries:
190
+ delay = calculate_backoff_delay(attempt, config)
191
+
192
+ # Call retry callback if provided
193
+ if on_retry:
194
+ on_retry(attempt, e)
195
+
196
+ logger.warning(
197
+ f"Rate limit hit, retrying in {delay:.1f}s "
198
+ f"(attempt {attempt + 1}/{config.max_retries})"
199
+ )
200
+ time.sleep(delay)
201
+ continue
202
+ else:
203
+ logger.error(
204
+ f"Rate limit hit, max retries ({config.max_retries}) exceeded"
205
+ )
206
+
207
+ # Re-raise if not a rate limit error or if retries exhausted
208
+ raise
209
+
210
+ # Should not reach here, but just in case
211
+ if last_exception:
212
+ raise last_exception
213
+
214
+
215
+ class RateLimitManager:
216
+ """Manages rate limiting across multiple API calls."""
217
+
218
+ def __init__(self, min_interval: float = 0.1):
219
+ """Initialize rate limit manager.
220
+
221
+ Args:
222
+ min_interval: Minimum interval between API calls in seconds
223
+ """
224
+ self.min_interval = min_interval
225
+ self.last_call_time = {}
226
+
227
+ def wait_if_needed(self, api_key: str):
228
+ """Wait if necessary to respect rate limits.
229
+
230
+ Args:
231
+ api_key: Unique key for the API being called
232
+ """
233
+ current_time = time.time()
234
+
235
+ if api_key in self.last_call_time:
236
+ elapsed = current_time - self.last_call_time[api_key]
237
+ if elapsed < self.min_interval:
238
+ sleep_time = self.min_interval - elapsed
239
+ logger.debug(f"Rate limiting: sleeping for {sleep_time:.3f}s")
240
+ time.sleep(sleep_time)
241
+
242
+ self.last_call_time[api_key] = time.time()
@@ -0,0 +1,119 @@
1
+ """Shared utilities for cross-platform symlink operations."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import subprocess
6
+ from pathlib import Path
7
+
8
+ from ..models.exceptions import CDEnvironmentError
9
+
10
+
11
+ def is_link(path: Path) -> bool:
12
+ """Detect both symlinks and junctions (Windows).
13
+
14
+ Args:
15
+ path: Path to check
16
+
17
+ Returns:
18
+ True if path is a symlink or junction, False otherwise
19
+ """
20
+ if path.is_symlink():
21
+ return True
22
+ # Python 3.12+: Direct junction check
23
+ if hasattr(os.path, 'isjunction') and os.path.isjunction(path):
24
+ return True
25
+ # Fallback: Check if path resolution differs (works for junctions and symlinks)
26
+ try:
27
+ return path.exists() and path.absolute() != path.resolve()
28
+ except (OSError, RuntimeError):
29
+ return False
30
+
31
+
32
+ def create_platform_link(link_path: Path, target_path: Path, name: str) -> None:
33
+ """Create symlink (Unix) or junction (Windows).
34
+
35
+ Args:
36
+ link_path: Path where symlink should be created
37
+ target_path: Path that symlink should point to
38
+ name: Directory name for error messages (e.g., "models", "input")
39
+
40
+ Raises:
41
+ CDEnvironmentError: If link creation fails
42
+ """
43
+ try:
44
+ if os.name == "nt": # Windows
45
+ create_windows_junction(link_path, target_path, name)
46
+ else: # Linux/macOS
47
+ os.symlink(target_path, link_path)
48
+ except CDEnvironmentError:
49
+ # Re-raise CDEnvironmentError as-is
50
+ raise
51
+ except Exception as e:
52
+ raise CDEnvironmentError(f"Failed to create {name} symlink: {e}") from e
53
+
54
+
55
+ def create_windows_junction(link_path: Path, target_path: Path, name: str) -> None:
56
+ """Create junction on Windows using mklink command.
57
+
58
+ Args:
59
+ link_path: Path where junction should be created
60
+ target_path: Path that junction should point to
61
+ name: Directory name for error messages
62
+
63
+ Raises:
64
+ CDEnvironmentError: If junction creation fails
65
+ """
66
+ # Use mklink /J for directory junction (no admin required)
67
+ result = subprocess.run(
68
+ [
69
+ "mklink",
70
+ "/J",
71
+ str(link_path),
72
+ str(target_path),
73
+ ],
74
+ shell=True, # Required for mklink
75
+ capture_output=True,
76
+ text=True,
77
+ )
78
+
79
+ if result.returncode != 0:
80
+ raise CDEnvironmentError(
81
+ f"Failed to create {name} junction:\n"
82
+ f" Command: mklink /J {link_path} {target_path}\n"
83
+ f" Error: {result.stderr}\n"
84
+ f" Note: On Windows, you may need Administrator privileges or Developer Mode enabled"
85
+ )
86
+
87
+
88
+ def is_safe_to_delete(path: Path, safe_files: set[str]) -> bool:
89
+ """Check if directory is safe to delete.
90
+
91
+ Safe to delete if:
92
+ - Completely empty
93
+ - Only contains empty subdirectories
94
+ - Only contains placeholder files from safe_files set
95
+
96
+ Args:
97
+ path: Directory path to check
98
+ safe_files: Set of filenames that are safe to delete (e.g., {".gitkeep", ".gitignore"})
99
+
100
+ Returns:
101
+ True if safe to delete, False if contains actual content
102
+ """
103
+ if not path.exists():
104
+ return True # Nonexistent is safe
105
+
106
+ # Get all files recursively
107
+ all_items = list(path.rglob("*"))
108
+ files = [f for f in all_items if f.is_file()]
109
+
110
+ if len(files) == 0:
111
+ return True # Completely empty (or only empty dirs)
112
+
113
+ # Check if files are only placeholders
114
+ for file in files:
115
+ if file.name not in safe_files:
116
+ # Has actual content
117
+ return False
118
+
119
+ return True # Only placeholder files
@@ -0,0 +1,258 @@
1
+ """System detector for Python, CUDA, and PyTorch detection."""
2
+ import json
3
+ import platform
4
+ import re
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from ..logging.logging_config import get_logger
9
+ from ..models.shared import SystemInfo
10
+ from .common import run_command
11
+
12
+
13
+ class SystemDetector:
14
+ """Detects system-level dependencies like Python, CUDA, and PyTorch."""
15
+
16
+ def __init__(self, comfyui_path: Path, python_hint: Path | None = None):
17
+ self.logger = get_logger(__name__)
18
+ self.comfyui_path = Path(comfyui_path).resolve()
19
+ # Don't resolve the Python hint - keep the original path the user provided
20
+ # This preserves venv paths instead of following symlinks
21
+ self.python_hint = Path(python_hint) if python_hint else None
22
+
23
+ # Log the python hint for debugging
24
+ if self.python_hint:
25
+ self.logger.info(f"System detector initialized with python hint: {self.python_hint}")
26
+ # Remove debug print statement
27
+ else:
28
+ self.logger.info("System detector initialized without python hint")
29
+
30
+
31
+ def detect_all(self) -> SystemInfo:
32
+ """Detect all system information and return typed result.
33
+
34
+ Returns:
35
+ SystemInfo: Structured system information
36
+ """
37
+ self.logger.info("Starting system detection...")
38
+
39
+ # Find Python executable
40
+ python_exe = self._find_python_executable()
41
+
42
+ # Detect Python version
43
+ python_info = self._detect_python_version(python_exe)
44
+
45
+ # Detect CUDA version
46
+ cuda_version = self._detect_cuda_version()
47
+
48
+ # Detect PyTorch version
49
+ pytorch_info = self._detect_pytorch_version(python_exe)
50
+
51
+ # Create SystemInfo object
52
+ system_info = SystemInfo(
53
+ python_executable=python_exe,
54
+ python_version=python_info['python_version'],
55
+ python_major_minor=python_info.get('python_major_minor'),
56
+ cuda_version=cuda_version,
57
+ torch_version=pytorch_info.get('torch') if pytorch_info else None,
58
+ cuda_torch_version=pytorch_info.get('cuda_torch_version') if pytorch_info else None,
59
+ pytorch_info=pytorch_info,
60
+ platform=platform.platform(),
61
+ architecture=platform.machine()
62
+ )
63
+
64
+ return system_info
65
+
66
+ def _find_python_executable(self) -> Path:
67
+ """
68
+ Find the Python executable and virtual environment used by ComfyUI.
69
+
70
+ Args:
71
+ comfyui_path: Path to ComfyUI directory
72
+ python_hint: Direct path to Python executable (if provided by user)
73
+
74
+ Returns:
75
+ Path to python_executable
76
+ """
77
+ # 1. If user provided python path, validate and use it
78
+ if self.python_hint and self.python_hint.exists():
79
+ self.logger.info(f"Validating user-provided Python executable: {self.python_hint}")
80
+ # For user-provided path, be more lenient - just check it's a valid Python
81
+ try:
82
+ result = run_command(
83
+ [str(self.python_hint), "-c", "import sys; print(sys.executable)"],
84
+ timeout=5
85
+ )
86
+ if result.returncode == 0:
87
+ self.logger.info(f"User-provided Python executable is valid: {self.python_hint}")
88
+ return self.python_hint
89
+ else:
90
+ self.logger.warning(f"User-provided Python executable failed validation: {self.python_hint}")
91
+ except Exception as e:
92
+ self.logger.warning(f"Error validating user-provided Python: {e}")
93
+
94
+ # 2. Check for virtual environments in standard locations relative to ComfyUI
95
+ # Check common venv locations
96
+ venv_candidates = [
97
+ self.comfyui_path / "venv",
98
+ self.comfyui_path / ".venv",
99
+ self.comfyui_path / "env",
100
+ self.comfyui_path.parent / "venv",
101
+ self.comfyui_path.parent / ".venv",
102
+ ]
103
+
104
+ for venv_path in venv_candidates:
105
+ # Check for Python in different locations based on OS
106
+ if platform.system() == "Windows":
107
+ python_paths = [
108
+ venv_path / "Scripts" / "python.exe",
109
+ venv_path / "python.exe",
110
+ ]
111
+ else:
112
+ python_paths = [
113
+ venv_path / "bin" / "python",
114
+ venv_path / "bin" / "python3",
115
+ ]
116
+
117
+ for python_path in python_paths:
118
+ if python_path.exists():
119
+ self.logger.info(f"Found Python executable: {python_path}")
120
+ self.logger.info(f"Found virtual environment: {venv_path}")
121
+ return python_path
122
+
123
+ # Check if there's a .venv file pointing to a venv
124
+ venv_file = self.comfyui_path / ".venv"
125
+ if venv_file.exists() and venv_file.is_file():
126
+ try:
127
+ venv_path = Path(venv_file.read_text(encoding='utf-8').strip())
128
+ if venv_path.exists():
129
+ if platform.system() == "Windows":
130
+ python_executable = venv_path / "Scripts" / "python.exe"
131
+ else:
132
+ python_executable = venv_path / "bin" / "python"
133
+ if python_executable.exists():
134
+ self.logger.info(f"Found Python executable via .venv file: {python_executable}")
135
+ self.logger.info(f"Found virtual environment: {venv_path}")
136
+ return python_executable
137
+ except Exception:
138
+ pass
139
+
140
+ # If no venv found, check if ComfyUI can run with system Python
141
+ self.logger.warning("No virtual environment found, checking system Python...")
142
+
143
+ # Try to run ComfyUI's main.py with --help to see if it works
144
+ try:
145
+ result = run_command(
146
+ [sys.executable, str(self.comfyui_path / "main.py"), "--help"],
147
+ timeout=5
148
+ )
149
+ if result.returncode == 0:
150
+ python_executable = Path(sys.executable)
151
+ self.logger.info(f"ComfyUI appears to work with system Python: {sys.executable}")
152
+ return python_executable
153
+ except Exception:
154
+ pass
155
+
156
+ self.logger.warning("Could not determine Python executable for ComfyUI")
157
+ return Path(sys.executable)
158
+
159
+ def _run_python_command(self, code: str, python_executable: Path) -> str | None:
160
+ """Run Python code in the ComfyUI environment and return output."""
161
+ try:
162
+ result = run_command(
163
+ [str(python_executable), "-c", code],
164
+ cwd=self.comfyui_path,
165
+ timeout=10
166
+ )
167
+ if result.returncode == 0:
168
+ return result.stdout.strip()
169
+ else:
170
+ self.logger.error(f"Error running Python command: {result.stderr}")
171
+ return None
172
+ except Exception as e:
173
+ self.logger.error(f"Exception running Python command: {e}")
174
+ return None
175
+
176
+
177
+ def _detect_python_version(self, python_executable: Path) -> dict[str, str]:
178
+ """Detect the Python version being used by ComfyUI."""
179
+ code = "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}')"
180
+ python_version = self._run_python_command(code, python_executable)
181
+
182
+ assert python_version is not None
183
+
184
+ major_minor = '.'.join(python_version.split('.')[:2])
185
+ self.logger.info(f"Python version: {python_version}")
186
+
187
+ return {
188
+ 'python_version': python_version,
189
+ 'python_major_minor': major_minor
190
+ }
191
+
192
+ def _detect_cuda_version(self) -> str | None:
193
+ """Detect CUDA version using nvidia-smi."""
194
+ try:
195
+ result = run_command(['nvidia-smi'])
196
+ if result.returncode == 0:
197
+ # Parse CUDA version from nvidia-smi output
198
+ match = re.search(r'CUDA Version:\s*(\d+\.\d+)', result.stdout)
199
+ if match:
200
+ cuda_version = match.group(1)
201
+ self.logger.info(f"CUDA version: {cuda_version}")
202
+ return cuda_version
203
+ except Exception as e:
204
+ self.logger.debug(f"Could not detect CUDA: {e}")
205
+
206
+ self.logger.info("No CUDA detected (CPU-only mode)")
207
+ return None
208
+
209
+ def _detect_pytorch_version(self, python_executable: Path) -> dict[str, str] | None:
210
+ """Detect PyTorch and related library versions in ComfyUI environment."""
211
+ # Log which Python we're checking for PyTorch
212
+ self.logger.info(f"Checking for PyTorch using Python: {python_executable}")
213
+
214
+ # Check if PyTorch is installed in the ComfyUI environment
215
+ code = """
216
+ import json
217
+ try:
218
+ import torch
219
+ info = {
220
+ 'torch': torch.__version__,
221
+ 'cuda_available': torch.cuda.is_available(),
222
+ 'cuda_torch_version': torch.version.cuda if torch.cuda.is_available() else None
223
+ }
224
+
225
+ # Try to detect torchvision and torchaudio
226
+ try:
227
+ import torchvision
228
+ info['torchvision'] = torchvision.__version__
229
+ except ImportError:
230
+ pass
231
+
232
+ try:
233
+ import torchaudio
234
+ info['torchaudio'] = torchaudio.__version__
235
+ except ImportError:
236
+ pass
237
+
238
+ print(json.dumps(info))
239
+ except ImportError:
240
+ print(json.dumps({'error': 'PyTorch not installed'}))
241
+ """
242
+
243
+ output = self._run_python_command(code, python_executable)
244
+ if output:
245
+ try:
246
+ pytorch_info = json.loads(output)
247
+ if 'error' not in pytorch_info:
248
+ self.logger.info(f"PyTorch version: {pytorch_info.get('torch')}")
249
+ if pytorch_info.get('cuda_torch_version'):
250
+ self.logger.info(f"PyTorch CUDA: {pytorch_info.get('cuda_torch_version')}")
251
+ else:
252
+ self.logger.warning("PyTorch not found in ComfyUI environment")
253
+ return None
254
+ return pytorch_info
255
+ except json.JSONDecodeError:
256
+ self.logger.warning("Could not parse PyTorch information")
257
+
258
+ return None
@@ -0,0 +1,28 @@
1
+ """UUID detection utilities for subgraph support."""
2
+ import re
3
+
4
+ # RFC 4122 UUID format: 8-4-4-4-12 hex digits
5
+ UUID_PATTERN = re.compile(
6
+ r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$',
7
+ re.IGNORECASE
8
+ )
9
+
10
+
11
+ def is_uuid(value: str) -> bool:
12
+ """Check if string is a valid UUID (subgraph reference).
13
+
14
+ Args:
15
+ value: String to check
16
+
17
+ Returns:
18
+ True if value matches UUID format
19
+
20
+ Examples:
21
+ >>> is_uuid("0a58ac1f-cb15-4e01-aab3-26292addb965")
22
+ True
23
+ >>> is_uuid("CheckpointLoaderSimple")
24
+ False
25
+ >>> is_uuid("not-a-valid-uuid")
26
+ False
27
+ """
28
+ return bool(UUID_PATTERN.match(value))