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.
- comfygit_core/analyzers/custom_node_scanner.py +109 -0
- comfygit_core/analyzers/git_change_parser.py +156 -0
- comfygit_core/analyzers/model_scanner.py +318 -0
- comfygit_core/analyzers/node_classifier.py +58 -0
- comfygit_core/analyzers/node_git_analyzer.py +77 -0
- comfygit_core/analyzers/status_scanner.py +362 -0
- comfygit_core/analyzers/workflow_dependency_parser.py +143 -0
- comfygit_core/caching/__init__.py +16 -0
- comfygit_core/caching/api_cache.py +210 -0
- comfygit_core/caching/base.py +212 -0
- comfygit_core/caching/comfyui_cache.py +100 -0
- comfygit_core/caching/custom_node_cache.py +320 -0
- comfygit_core/caching/workflow_cache.py +797 -0
- comfygit_core/clients/__init__.py +4 -0
- comfygit_core/clients/civitai_client.py +412 -0
- comfygit_core/clients/github_client.py +349 -0
- comfygit_core/clients/registry_client.py +230 -0
- comfygit_core/configs/comfyui_builtin_nodes.py +1614 -0
- comfygit_core/configs/comfyui_models.py +62 -0
- comfygit_core/configs/model_config.py +151 -0
- comfygit_core/constants.py +82 -0
- comfygit_core/core/environment.py +1635 -0
- comfygit_core/core/workspace.py +898 -0
- comfygit_core/factories/environment_factory.py +419 -0
- comfygit_core/factories/uv_factory.py +61 -0
- comfygit_core/factories/workspace_factory.py +109 -0
- comfygit_core/infrastructure/sqlite_manager.py +156 -0
- comfygit_core/integrations/__init__.py +7 -0
- comfygit_core/integrations/uv_command.py +318 -0
- comfygit_core/logging/logging_config.py +15 -0
- comfygit_core/managers/environment_git_orchestrator.py +316 -0
- comfygit_core/managers/environment_model_manager.py +296 -0
- comfygit_core/managers/export_import_manager.py +116 -0
- comfygit_core/managers/git_manager.py +667 -0
- comfygit_core/managers/model_download_manager.py +252 -0
- comfygit_core/managers/model_symlink_manager.py +166 -0
- comfygit_core/managers/node_manager.py +1378 -0
- comfygit_core/managers/pyproject_manager.py +1321 -0
- comfygit_core/managers/user_content_symlink_manager.py +436 -0
- comfygit_core/managers/uv_project_manager.py +569 -0
- comfygit_core/managers/workflow_manager.py +1944 -0
- comfygit_core/models/civitai.py +432 -0
- comfygit_core/models/commit.py +18 -0
- comfygit_core/models/environment.py +293 -0
- comfygit_core/models/exceptions.py +378 -0
- comfygit_core/models/manifest.py +132 -0
- comfygit_core/models/node_mapping.py +201 -0
- comfygit_core/models/protocols.py +248 -0
- comfygit_core/models/registry.py +63 -0
- comfygit_core/models/shared.py +356 -0
- comfygit_core/models/sync.py +42 -0
- comfygit_core/models/system.py +204 -0
- comfygit_core/models/workflow.py +914 -0
- comfygit_core/models/workspace_config.py +71 -0
- comfygit_core/py.typed +0 -0
- comfygit_core/repositories/migrate_paths.py +49 -0
- comfygit_core/repositories/model_repository.py +958 -0
- comfygit_core/repositories/node_mappings_repository.py +246 -0
- comfygit_core/repositories/workflow_repository.py +57 -0
- comfygit_core/repositories/workspace_config_repository.py +121 -0
- comfygit_core/resolvers/global_node_resolver.py +459 -0
- comfygit_core/resolvers/model_resolver.py +250 -0
- comfygit_core/services/import_analyzer.py +218 -0
- comfygit_core/services/model_downloader.py +422 -0
- comfygit_core/services/node_lookup_service.py +251 -0
- comfygit_core/services/registry_data_manager.py +161 -0
- comfygit_core/strategies/__init__.py +4 -0
- comfygit_core/strategies/auto.py +72 -0
- comfygit_core/strategies/confirmation.py +69 -0
- comfygit_core/utils/comfyui_ops.py +125 -0
- comfygit_core/utils/common.py +164 -0
- comfygit_core/utils/conflict_parser.py +232 -0
- comfygit_core/utils/dependency_parser.py +231 -0
- comfygit_core/utils/download.py +216 -0
- comfygit_core/utils/environment_cleanup.py +111 -0
- comfygit_core/utils/filesystem.py +178 -0
- comfygit_core/utils/git.py +1184 -0
- comfygit_core/utils/input_signature.py +145 -0
- comfygit_core/utils/model_categories.py +52 -0
- comfygit_core/utils/pytorch.py +71 -0
- comfygit_core/utils/requirements.py +211 -0
- comfygit_core/utils/retry.py +242 -0
- comfygit_core/utils/symlink_utils.py +119 -0
- comfygit_core/utils/system_detector.py +258 -0
- comfygit_core/utils/uuid.py +28 -0
- comfygit_core/utils/uv_error_handler.py +158 -0
- comfygit_core/utils/version.py +73 -0
- comfygit_core/utils/workflow_hash.py +90 -0
- comfygit_core/validation/resolution_tester.py +297 -0
- comfygit_core-0.2.0.dist-info/METADATA +939 -0
- comfygit_core-0.2.0.dist-info/RECORD +93 -0
- comfygit_core-0.2.0.dist-info/WHEEL +4 -0
- 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))
|