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,161 @@
|
|
|
1
|
+
"""Manages dynamic registry data fetching and caching."""
|
|
2
|
+
import json
|
|
3
|
+
import time
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional
|
|
6
|
+
from urllib.request import urlopen, Request
|
|
7
|
+
from urllib.error import URLError
|
|
8
|
+
import socket
|
|
9
|
+
|
|
10
|
+
from ..constants import GITHUB_NODE_MAPPINGS_URL, MAX_REGISTRY_DATA_AGE_HOURS
|
|
11
|
+
from ..logging.logging_config import get_logger
|
|
12
|
+
|
|
13
|
+
logger = get_logger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class RegistryDataManager:
|
|
17
|
+
"""Manages fetching and caching of registry node mappings."""
|
|
18
|
+
|
|
19
|
+
MAX_AGE_HOURS = MAX_REGISTRY_DATA_AGE_HOURS
|
|
20
|
+
FETCH_TIMEOUT = 10 # seconds
|
|
21
|
+
|
|
22
|
+
def __init__(self, cache_dir: Path):
|
|
23
|
+
"""Initialize with cache directory.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
cache_dir: Directory to store cached registry data
|
|
27
|
+
"""
|
|
28
|
+
self.cache_dir = cache_dir
|
|
29
|
+
self.registry_dir = cache_dir / "registry"
|
|
30
|
+
self.custom_nodes_dir = cache_dir / "custom_nodes"
|
|
31
|
+
self.mappings_file = self.custom_nodes_dir / "node_mappings.json"
|
|
32
|
+
self.metadata_file = self.registry_dir / "metadata.json"
|
|
33
|
+
|
|
34
|
+
# Ensure directories exist
|
|
35
|
+
self.registry_dir.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
self.custom_nodes_dir.mkdir(parents=True, exist_ok=True)
|
|
37
|
+
|
|
38
|
+
def get_mappings_path(self) -> Path:
|
|
39
|
+
"""Get path to node mappings file, fetching if needed.
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
Path to node_mappings.json (may be stale if fetch fails)
|
|
43
|
+
"""
|
|
44
|
+
# If no file exists, we MUST fetch
|
|
45
|
+
if not self.mappings_file.exists():
|
|
46
|
+
logger.info("No cached registry data found, fetching...")
|
|
47
|
+
if self._fetch_mappings():
|
|
48
|
+
logger.info("Successfully fetched registry data")
|
|
49
|
+
else:
|
|
50
|
+
logger.error("Failed to fetch registry data - no mappings available")
|
|
51
|
+
return self.mappings_file # Return path even if doesn't exist
|
|
52
|
+
|
|
53
|
+
# If file exists but is stale, try to update (non-blocking)
|
|
54
|
+
elif self._is_stale():
|
|
55
|
+
logger.debug("Registry data is stale, attempting refresh...")
|
|
56
|
+
if self._fetch_mappings():
|
|
57
|
+
logger.info("Updated registry data")
|
|
58
|
+
else:
|
|
59
|
+
logger.warning("Using stale registry data (update failed)")
|
|
60
|
+
|
|
61
|
+
return self.mappings_file
|
|
62
|
+
|
|
63
|
+
def _is_stale(self) -> bool:
|
|
64
|
+
"""Check if cached data is older than MAX_AGE_HOURS."""
|
|
65
|
+
if not self.mappings_file.exists():
|
|
66
|
+
return True
|
|
67
|
+
|
|
68
|
+
age_seconds = time.time() - self.mappings_file.stat().st_mtime
|
|
69
|
+
age_hours = age_seconds / 3600
|
|
70
|
+
return age_hours > self.MAX_AGE_HOURS
|
|
71
|
+
|
|
72
|
+
def _fetch_mappings(self) -> bool:
|
|
73
|
+
"""Fetch latest mappings from GitHub.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
True if successful, False otherwise
|
|
77
|
+
"""
|
|
78
|
+
try:
|
|
79
|
+
# Create request with timeout
|
|
80
|
+
req = Request(GITHUB_NODE_MAPPINGS_URL, headers={
|
|
81
|
+
'User-Agent': 'ComfyDock/1.0',
|
|
82
|
+
'Accept': 'application/json'
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
# Download with timeout
|
|
86
|
+
with urlopen(req, timeout=self.FETCH_TIMEOUT) as response:
|
|
87
|
+
data = response.read()
|
|
88
|
+
|
|
89
|
+
# Parse to validate JSON
|
|
90
|
+
mappings = json.loads(data)
|
|
91
|
+
|
|
92
|
+
# Write atomically (temp file + rename)
|
|
93
|
+
temp_file = self.mappings_file.with_suffix('.tmp')
|
|
94
|
+
temp_file.write_bytes(data)
|
|
95
|
+
temp_file.rename(self.mappings_file)
|
|
96
|
+
|
|
97
|
+
# Update metadata
|
|
98
|
+
self._write_metadata({
|
|
99
|
+
'updated_at': time.time(),
|
|
100
|
+
'version': mappings.get('version', 'unknown'),
|
|
101
|
+
'stats': mappings.get('stats', {})
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
return True
|
|
105
|
+
|
|
106
|
+
except (URLError, socket.timeout) as e:
|
|
107
|
+
logger.debug(f"Network error fetching registry data: {e}")
|
|
108
|
+
return False
|
|
109
|
+
except json.JSONDecodeError as e:
|
|
110
|
+
logger.error(f"Invalid JSON in registry response: {e}")
|
|
111
|
+
return False
|
|
112
|
+
except Exception as e:
|
|
113
|
+
logger.error(f"Unexpected error fetching registry data: {e}")
|
|
114
|
+
return False
|
|
115
|
+
|
|
116
|
+
def _write_metadata(self, metadata: dict) -> None:
|
|
117
|
+
"""Write metadata about the cached data."""
|
|
118
|
+
try:
|
|
119
|
+
with open(self.metadata_file, 'w', encoding='utf-8') as f:
|
|
120
|
+
json.dump(metadata, f, indent=2)
|
|
121
|
+
except Exception as e:
|
|
122
|
+
logger.debug(f"Failed to write metadata: {e}")
|
|
123
|
+
|
|
124
|
+
def force_update(self) -> bool:
|
|
125
|
+
"""Force fetch latest mappings.
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
True if successful
|
|
129
|
+
"""
|
|
130
|
+
logger.info("Force updating registry data...")
|
|
131
|
+
return self._fetch_mappings()
|
|
132
|
+
|
|
133
|
+
def get_cache_info(self) -> dict:
|
|
134
|
+
"""Get information about cached data.
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
Dict with cache status and metadata
|
|
138
|
+
"""
|
|
139
|
+
info = {
|
|
140
|
+
'exists': self.mappings_file.exists(),
|
|
141
|
+
'path': str(self.mappings_file),
|
|
142
|
+
'stale': False,
|
|
143
|
+
'age_hours': None,
|
|
144
|
+
'version': None
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if self.mappings_file.exists():
|
|
148
|
+
age_seconds = time.time() - self.mappings_file.stat().st_mtime
|
|
149
|
+
age_hours = age_seconds / 3600
|
|
150
|
+
info['age_hours'] = round(age_hours, 1)
|
|
151
|
+
info['stale'] = age_hours > self.MAX_AGE_HOURS
|
|
152
|
+
|
|
153
|
+
if self.metadata_file.exists():
|
|
154
|
+
try:
|
|
155
|
+
with open(self.metadata_file, encoding='utf-8') as f:
|
|
156
|
+
metadata = json.load(f)
|
|
157
|
+
info['version'] = metadata.get('version')
|
|
158
|
+
except:
|
|
159
|
+
pass
|
|
160
|
+
|
|
161
|
+
return info
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Auto resolution strategies for workflow dependencies."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
from comfygit_core.models.protocols import ModelResolutionStrategy, NodeResolutionStrategy
|
|
7
|
+
|
|
8
|
+
from ..models.workflow import ModelResolutionContext, NodeResolutionContext, ResolvedModel, WorkflowNodeWidgetRef
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from ..models.workflow import ResolvedNodePackage
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AutoNodeStrategy(NodeResolutionStrategy):
|
|
15
|
+
"""Automatic node resolution - makes best effort choices without user input."""
|
|
16
|
+
|
|
17
|
+
def resolve_unknown_node(
|
|
18
|
+
self,
|
|
19
|
+
node_type: str,
|
|
20
|
+
possible: list[ResolvedNodePackage],
|
|
21
|
+
context: "NodeResolutionContext"
|
|
22
|
+
) -> ResolvedNodePackage | None:
|
|
23
|
+
"""Pick the top suggestion by confidence, or first if tied.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
node_type: The unknown node type
|
|
27
|
+
possible: List of possible package matches
|
|
28
|
+
context: Resolution context (unused in auto mode)
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
ResolvedNodePackage or None if no candidates
|
|
32
|
+
"""
|
|
33
|
+
if not possible:
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
# Sort by confidence descending, then just pick first
|
|
37
|
+
sorted_suggestions = sorted(
|
|
38
|
+
possible, key=lambda s: s.match_confidence, reverse=True
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
return sorted_suggestions[0]
|
|
42
|
+
|
|
43
|
+
def confirm_node_install(self, package: ResolvedNodePackage) -> bool:
|
|
44
|
+
"""Always confirm - we're making automated choices."""
|
|
45
|
+
return True
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class AutoModelStrategy(ModelResolutionStrategy):
|
|
49
|
+
"""Automatic model resolution - makes simple naive choices."""
|
|
50
|
+
|
|
51
|
+
def resolve_model(
|
|
52
|
+
self,
|
|
53
|
+
reference: WorkflowNodeWidgetRef,
|
|
54
|
+
candidates: list[ResolvedModel],
|
|
55
|
+
context: ModelResolutionContext,
|
|
56
|
+
) -> ResolvedModel | None:
|
|
57
|
+
"""Pick the first candidate, or skip if none available.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
reference: The model reference from workflow
|
|
61
|
+
candidates: List of potential matches (may be empty for missing models)
|
|
62
|
+
context: Resolution context with search function and workflow info
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
First candidate as ResolvedModel, or None to skip
|
|
66
|
+
"""
|
|
67
|
+
if not candidates:
|
|
68
|
+
# No candidates - skip (don't mark as optional)
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
# Return first candidate (already a ResolvedModel)
|
|
72
|
+
return candidates[0]
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Confirmation strategies for user interaction."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ConfirmationStrategy(ABC):
|
|
7
|
+
"""Strategy for confirming user actions."""
|
|
8
|
+
|
|
9
|
+
@abstractmethod
|
|
10
|
+
def confirm_update(self, node_name: str, current_version: str, new_version: str) -> bool:
|
|
11
|
+
"""Ask user to confirm a node update.
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
node_name: Name of the node
|
|
15
|
+
current_version: Current version
|
|
16
|
+
new_version: New version
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
True if user confirms, False otherwise
|
|
20
|
+
"""
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
def confirm_replace_dev_node(self, node_name: str, current_version: str, new_version: str) -> bool:
|
|
24
|
+
"""Ask user to confirm replacing a development node.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
node_name: Name of the development node
|
|
28
|
+
current_version: Current version ('dev')
|
|
29
|
+
new_version: New version to install
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
True if user confirms, False otherwise
|
|
33
|
+
"""
|
|
34
|
+
# Default: use confirm_update for backward compatibility
|
|
35
|
+
return self.confirm_update(node_name, current_version, new_version)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class AutoConfirmStrategy(ConfirmationStrategy):
|
|
39
|
+
"""Always confirm without asking."""
|
|
40
|
+
|
|
41
|
+
def confirm_update(self, node_name: str, current_version: str, new_version: str) -> bool:
|
|
42
|
+
return True
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class InteractiveConfirmStrategy(ConfirmationStrategy):
|
|
46
|
+
"""Ask user interactively via CLI."""
|
|
47
|
+
|
|
48
|
+
def confirm_update(self, node_name: str, current_version: str, new_version: str) -> bool:
|
|
49
|
+
response = input(
|
|
50
|
+
f"Update '{node_name}' from {current_version} → {new_version}? (Y/n): "
|
|
51
|
+
)
|
|
52
|
+
return response.lower() != 'n'
|
|
53
|
+
|
|
54
|
+
def confirm_replace_dev_node(self, node_name: str, current_version: str, new_version: str) -> bool:
|
|
55
|
+
"""Ask user to confirm replacing a development node.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
node_name: Name of the development node
|
|
59
|
+
current_version: Current version ('dev')
|
|
60
|
+
new_version: New version to install
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
True if user confirms, False otherwise
|
|
64
|
+
"""
|
|
65
|
+
print(f"⚠️ '{node_name}' is a development node (local changes may exist)")
|
|
66
|
+
response = input(
|
|
67
|
+
f"Replace with registry version {new_version}? This will delete local changes. (y/N): "
|
|
68
|
+
)
|
|
69
|
+
return response.lower() == 'y'
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from ..logging.logging_config import get_logger
|
|
4
|
+
from .common import run_command
|
|
5
|
+
from .git import git_clone
|
|
6
|
+
|
|
7
|
+
logger = get_logger(__name__)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def validate_comfyui_installation(comfyui_path: Path) -> bool:
|
|
11
|
+
"""Check if a directory contains a valid ComfyUI installation.
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
comfyui_path: Path to check
|
|
15
|
+
|
|
16
|
+
Returns:
|
|
17
|
+
True if valid ComfyUI installation, False otherwise
|
|
18
|
+
"""
|
|
19
|
+
# Check for essential ComfyUI files
|
|
20
|
+
required_files = ["main.py", "nodes.py", "folder_paths.py"]
|
|
21
|
+
|
|
22
|
+
for file in required_files:
|
|
23
|
+
if not (comfyui_path / file).exists():
|
|
24
|
+
return False
|
|
25
|
+
|
|
26
|
+
# Check for essential directories
|
|
27
|
+
required_dirs = ["comfy", "models"]
|
|
28
|
+
|
|
29
|
+
for dir_name in required_dirs:
|
|
30
|
+
if not (comfyui_path / dir_name).is_dir():
|
|
31
|
+
return False
|
|
32
|
+
|
|
33
|
+
return True
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_comfyui_version(comfyui_path: Path) -> str:
|
|
37
|
+
"""Detect ComfyUI version from git tags."""
|
|
38
|
+
comfyui_version = "unknown"
|
|
39
|
+
try:
|
|
40
|
+
git_dir = comfyui_path / ".git"
|
|
41
|
+
if git_dir.exists():
|
|
42
|
+
result = run_command(
|
|
43
|
+
["git", "describe", "--tags", "--always"], cwd=comfyui_path
|
|
44
|
+
)
|
|
45
|
+
if result.returncode == 0:
|
|
46
|
+
comfyui_version = result.stdout.strip()
|
|
47
|
+
except Exception as e:
|
|
48
|
+
logger.debug(f"Could not detect ComfyUI version from {comfyui_path}: {e}")
|
|
49
|
+
|
|
50
|
+
return comfyui_version
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def resolve_comfyui_version(
|
|
54
|
+
version_spec: str | None,
|
|
55
|
+
github_client
|
|
56
|
+
) -> tuple[str, str, str | None]:
|
|
57
|
+
"""Resolve version specification to concrete version.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
version_spec: User input ("latest", "v0.3.20", "abc123", "main", None)
|
|
61
|
+
github_client: GitHub client for API calls
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
Tuple of (version_to_clone, version_type, commit_sha)
|
|
65
|
+
- version_to_clone: What to pass to git clone
|
|
66
|
+
- version_type: "release" | "commit" | "branch"
|
|
67
|
+
- commit_sha: Actual commit SHA (None if not yet cloned)
|
|
68
|
+
|
|
69
|
+
Examples:
|
|
70
|
+
None → ("v0.3.20", "release", None) # Latest release
|
|
71
|
+
"latest" → ("v0.3.20", "release", None)
|
|
72
|
+
"v0.3.15" → ("v0.3.15", "release", None)
|
|
73
|
+
"abc123" → ("abc123", "commit", None)
|
|
74
|
+
"master" → ("master", "branch", None)
|
|
75
|
+
"""
|
|
76
|
+
COMFYUI_REPO = "https://github.com/comfyanonymous/ComfyUI.git"
|
|
77
|
+
|
|
78
|
+
# Handle None or "latest" - fetch latest release
|
|
79
|
+
if version_spec is None or version_spec == "latest":
|
|
80
|
+
repo_info = github_client.get_repository_info(COMFYUI_REPO)
|
|
81
|
+
if repo_info and repo_info.latest_release:
|
|
82
|
+
return (repo_info.latest_release, "release", None)
|
|
83
|
+
else:
|
|
84
|
+
logger.warning("No releases found, falling back to master branch")
|
|
85
|
+
return ("master", "branch", None)
|
|
86
|
+
|
|
87
|
+
# Handle release tags (starts with 'v')
|
|
88
|
+
if version_spec.startswith('v'):
|
|
89
|
+
# Validate release exists
|
|
90
|
+
if github_client.validate_version_exists(COMFYUI_REPO, version_spec):
|
|
91
|
+
return (version_spec, "release", None)
|
|
92
|
+
else:
|
|
93
|
+
logger.warning(f"Release {version_spec} not found on GitHub")
|
|
94
|
+
raise ValueError(f"ComfyUI release {version_spec} does not exist")
|
|
95
|
+
|
|
96
|
+
# Handle branch alias (ComfyUI only has master branch)
|
|
97
|
+
if version_spec == "master":
|
|
98
|
+
return (version_spec, "branch", None)
|
|
99
|
+
|
|
100
|
+
# Assume commit hash
|
|
101
|
+
return (version_spec, "commit", None)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def clone_comfyui(target_path: Path, version: str | None = None) -> str | None:
|
|
105
|
+
"""Clone ComfyUI repository to a target path.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
target_path: Where to clone ComfyUI
|
|
109
|
+
version: Optional specific version/tag/commit to checkout
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
ComfyUI version string (commit hash or tag)
|
|
113
|
+
|
|
114
|
+
Raises:
|
|
115
|
+
RuntimeError: If cloning fails
|
|
116
|
+
"""
|
|
117
|
+
# Clone the repository with shallow clone for speed
|
|
118
|
+
git_clone(
|
|
119
|
+
"https://github.com/comfyanonymous/ComfyUI.git",
|
|
120
|
+
target_path,
|
|
121
|
+
depth=1,
|
|
122
|
+
ref=version,
|
|
123
|
+
timeout=60,
|
|
124
|
+
)
|
|
125
|
+
return get_comfyui_version(target_path)
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Common utilities for ComfyUI Environment Capture."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import subprocess
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from ..logging.logging_config import get_logger
|
|
8
|
+
from ..models.exceptions import CDProcessError
|
|
9
|
+
|
|
10
|
+
logger = get_logger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def run_command(
|
|
14
|
+
cmd: list[str],
|
|
15
|
+
cwd: Path | None = None,
|
|
16
|
+
timeout: int | None = 30,
|
|
17
|
+
capture_output: bool = True,
|
|
18
|
+
text: bool = True,
|
|
19
|
+
check: bool = False,
|
|
20
|
+
env: dict | None = None
|
|
21
|
+
) -> subprocess.CompletedProcess:
|
|
22
|
+
"""Run a subprocess command with proper error handling.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
cmd: Command and arguments as a list
|
|
26
|
+
cwd: Working directory for the command
|
|
27
|
+
timeout: Command timeout in seconds
|
|
28
|
+
capture_output: Whether to capture stdout/stderr
|
|
29
|
+
text: Whether to decode output as text
|
|
30
|
+
check: Whether to raise exception on non-zero exit code
|
|
31
|
+
env: Environment variables to pass to subprocess
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
CompletedProcess instance
|
|
35
|
+
|
|
36
|
+
Raises:
|
|
37
|
+
CDProcessError: If command fails and check=True
|
|
38
|
+
subprocess.TimeoutExpired: If command times out
|
|
39
|
+
"""
|
|
40
|
+
try:
|
|
41
|
+
logger.debug(f"Running command: {' '.join(cmd)}")
|
|
42
|
+
if cwd:
|
|
43
|
+
logger.debug(f"Working directory: {cwd}")
|
|
44
|
+
|
|
45
|
+
result = subprocess.run(
|
|
46
|
+
cmd,
|
|
47
|
+
cwd=str(cwd) if cwd else None,
|
|
48
|
+
check=check,
|
|
49
|
+
timeout=timeout,
|
|
50
|
+
capture_output=capture_output,
|
|
51
|
+
text=text,
|
|
52
|
+
env=env
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
return result
|
|
56
|
+
|
|
57
|
+
except subprocess.CalledProcessError as e:
|
|
58
|
+
# Transform CalledProcessError into our custom exception
|
|
59
|
+
error_msg = f"Command failed with exit code {e.returncode}: {' '.join(cmd)}"
|
|
60
|
+
if e.stderr:
|
|
61
|
+
error_msg += f"\nStderr: {e.stderr}"
|
|
62
|
+
logger.error(error_msg)
|
|
63
|
+
raise CDProcessError(
|
|
64
|
+
message=error_msg,
|
|
65
|
+
command=cmd,
|
|
66
|
+
stderr=e.stderr,
|
|
67
|
+
stdout=e.stdout,
|
|
68
|
+
returncode=e.returncode
|
|
69
|
+
)
|
|
70
|
+
except subprocess.TimeoutExpired:
|
|
71
|
+
error_msg = f"Command timed out after {timeout}s: {' '.join(cmd)}"
|
|
72
|
+
logger.error(error_msg)
|
|
73
|
+
# Let TimeoutExpired propagate - it's specific and useful
|
|
74
|
+
raise
|
|
75
|
+
except Exception as e:
|
|
76
|
+
error_msg = f"Error running command {' '.join(cmd)}: {e}"
|
|
77
|
+
logger.error(error_msg)
|
|
78
|
+
raise
|
|
79
|
+
|
|
80
|
+
def format_size(size_bytes: int) -> str:
|
|
81
|
+
"""Format a size in bytes as human-readable string.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
size_bytes: Size in bytes
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
Human-readable size string (e.g., "1.5 MB")
|
|
88
|
+
"""
|
|
89
|
+
if size_bytes == 0:
|
|
90
|
+
return "0 B"
|
|
91
|
+
|
|
92
|
+
size_names = ["B", "KB", "MB", "GB", "TB"]
|
|
93
|
+
size_index = 0
|
|
94
|
+
size = float(size_bytes)
|
|
95
|
+
|
|
96
|
+
while size >= 1024.0 and size_index < len(size_names) - 1:
|
|
97
|
+
size /= 1024.0
|
|
98
|
+
size_index += 1
|
|
99
|
+
|
|
100
|
+
if size_index == 0:
|
|
101
|
+
return f"{int(size)} {size_names[size_index]}"
|
|
102
|
+
else:
|
|
103
|
+
return f"{size:.1f} {size_names[size_index]}"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def log_pyproject_content(pyproject_path: Path, context: str = "") -> None:
|
|
107
|
+
"""Log pyproject.toml content in a nicely formatted way.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
pyproject_path: Path to pyproject.toml file
|
|
111
|
+
context: Optional context string for the log message
|
|
112
|
+
"""
|
|
113
|
+
try:
|
|
114
|
+
content = pyproject_path.read_text(encoding='utf-8')
|
|
115
|
+
# Add indentation to each line for nice formatting
|
|
116
|
+
indented_lines = ['' + line for line in content.split('\n')]
|
|
117
|
+
formatted_content = '\n'.join(indented_lines)
|
|
118
|
+
|
|
119
|
+
# Create the log message with separator lines
|
|
120
|
+
separator = '-' * 60
|
|
121
|
+
if context:
|
|
122
|
+
msg = f"{context}:\n{separator}\n{formatted_content}\n{separator}"
|
|
123
|
+
else:
|
|
124
|
+
msg = f"pyproject.toml content:\n{separator}\n{formatted_content}\n{separator}"
|
|
125
|
+
|
|
126
|
+
# Log as a single INFO message (change to DEBUG if too verbose)
|
|
127
|
+
logger.info(msg)
|
|
128
|
+
except Exception as e:
|
|
129
|
+
logger.debug(f"Could not log pyproject.toml: {e}")
|
|
130
|
+
|
|
131
|
+
def log_requirements_content(requirements_file: Path, show_all: bool = True) -> None:
|
|
132
|
+
"""Log the compiled requirements file content.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
requirements_file: Path to the compiled requirements file
|
|
136
|
+
show_all: If True, show all lines, otherwise show a summary
|
|
137
|
+
"""
|
|
138
|
+
try:
|
|
139
|
+
content = requirements_file.read_text(encoding='utf-8')
|
|
140
|
+
lines = content.split('\n')
|
|
141
|
+
|
|
142
|
+
# Count non-comment, non-empty lines
|
|
143
|
+
package_lines = [l for l in lines if l.strip() and not l.strip().startswith('#')]
|
|
144
|
+
|
|
145
|
+
#
|
|
146
|
+
separator = '=' * 60
|
|
147
|
+
msg_lines = [
|
|
148
|
+
f"Compiled requirements file ({len(package_lines)} packages):",
|
|
149
|
+
separator
|
|
150
|
+
]
|
|
151
|
+
|
|
152
|
+
# Show first 10 and last 5 packages if there are many
|
|
153
|
+
if len(lines) > 50 and not show_all:
|
|
154
|
+
msg_lines.extend(lines[:30])
|
|
155
|
+
msg_lines.append(f"... ({len(lines) - 35} more lines) ...")
|
|
156
|
+
msg_lines.extend(lines[-5:])
|
|
157
|
+
else:
|
|
158
|
+
msg_lines.extend(lines)
|
|
159
|
+
|
|
160
|
+
msg_lines.append(separator)
|
|
161
|
+
|
|
162
|
+
logger.info('\n'.join(msg_lines))
|
|
163
|
+
except Exception as e:
|
|
164
|
+
logger.debug(f"Could not log requirements file: {e}")
|