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,252 @@
1
+ """ModelDownloadManager - Handle model downloads from various sources."""
2
+
3
+ import shutil
4
+ from pathlib import Path
5
+ from urllib.parse import urlparse
6
+
7
+
8
+ from ..logging.logging_config import get_logger
9
+ from ..models.exceptions import ComfyDockError
10
+ from typing import TYPE_CHECKING
11
+
12
+ if TYPE_CHECKING:
13
+ from ..repositories.model_repository import ModelRepository
14
+ from ..models.shared import ModelWithLocation
15
+
16
+
17
+ logger = get_logger(__name__)
18
+
19
+
20
+ class ModelDownloadManager:
21
+ """Handle model downloads from various sources."""
22
+
23
+ def __init__(self, model_repository: ModelRepository, cache_dir: Path):
24
+ """Initialize ModelDownloadManager.
25
+
26
+ Args:
27
+ model_manager: ModelManager instance for indexing
28
+ cache_dir: Directory to store downloaded models (defaults to workspace/models)
29
+ """
30
+ self.model_repository = model_repository
31
+ self.cache_dir = cache_dir / "models"
32
+ self.cache_dir.mkdir(parents=True, exist_ok=True)
33
+
34
+ def download_from_url(self, url: str, filename: str | None = None) -> ModelWithLocation:
35
+ """Download model from URL and add to index.
36
+
37
+ Args:
38
+ url: Source URL to download from
39
+ filename: Override filename (extracted from URL if not provided)
40
+
41
+ Returns:
42
+ ModelWithLocation entry for the downloaded model
43
+
44
+ Raises:
45
+ ComfyDockError: If download or indexing fails
46
+ """
47
+ # Check if we already have this URL
48
+ existing = self.model_repository.find_by_source_url(url)
49
+ if existing:
50
+ logger.info(f"Model already indexed from {url}")
51
+ return existing
52
+
53
+ # Parse URL for metadata
54
+ source_type, metadata = self._parse_source_url(url)
55
+
56
+ # Determine filename
57
+ if not filename:
58
+ filename = self._extract_filename_from_url(url)
59
+
60
+ target_path = self.cache_dir / filename
61
+
62
+ # Download if not exists
63
+ if not target_path.exists():
64
+ logger.info(f"Downloading model from {url}")
65
+ self._download_file(url, target_path)
66
+ else:
67
+ logger.info(f"Model file already exists: {target_path}")
68
+
69
+ # Create model info and add to index
70
+ model_info = self.model_repository._create_model_info(target_path)
71
+
72
+ # Add to index with source tracking
73
+ self.model_repository.add_model(model_info, target_path, "downloads")
74
+ self.model_repository.add_source(
75
+ model_info.short_hash, source_type, url, metadata
76
+ )
77
+
78
+ # Return the indexed model
79
+ indexed_models = self.model_repository.find_model_by_hash(model_info.short_hash)
80
+ if not indexed_models:
81
+ raise ComfyDockError(f"Failed to index downloaded model: {filename}")
82
+
83
+ logger.info(f"Successfully downloaded and indexed: {filename}")
84
+ return indexed_models[0]
85
+
86
+ def _download_file(self, url: str, target_path: Path) -> None:
87
+ """Download file from URL to target path.
88
+
89
+ Args:
90
+ url: URL to download
91
+ target_path: Path to save file
92
+
93
+ Raises:
94
+ ComfyDockError: If download fails
95
+ """
96
+ temp_path = None
97
+ try:
98
+ from ..utils.download import download_file
99
+
100
+ # Download to temporary file
101
+ temp_path = download_file(url)
102
+
103
+ # Move to target location
104
+ target_path.parent.mkdir(parents=True, exist_ok=True)
105
+ shutil.move(temp_path, target_path)
106
+
107
+ except Exception as e:
108
+ # Clean up temp file if it exists
109
+ if temp_path and temp_path.exists():
110
+ temp_path.unlink()
111
+ raise ComfyDockError(f"Failed to download {url}: {e}") from e
112
+
113
+ def _parse_source_url(self, url: str) -> tuple[str, dict]:
114
+ """Parse URL to determine source type and extract metadata.
115
+
116
+ Args:
117
+ url: Source URL
118
+
119
+ Returns:
120
+ Tuple of (source_type, metadata_dict)
121
+ """
122
+ parsed = urlparse(url.lower())
123
+ metadata = {'original_url': url}
124
+
125
+ if 'civitai.com' in parsed.netloc:
126
+ source_type = 'civitai'
127
+ # Extract model ID from URL if possible
128
+ if '/models/' in url:
129
+ parts = url.split('/models/')
130
+ if len(parts) > 1:
131
+ model_id = parts[1].split('/')[0]
132
+ metadata['model_id'] = model_id
133
+ elif 'huggingface.co' in parsed.netloc:
134
+ source_type = 'huggingface'
135
+ # Extract repository path
136
+ path_parts = parsed.path.strip('/').split('/')
137
+ if len(path_parts) >= 2:
138
+ metadata['repository'] = f"{path_parts[0]}/{path_parts[1]}"
139
+ else:
140
+ source_type = 'url'
141
+ metadata['domain'] = parsed.netloc
142
+
143
+ return source_type, metadata
144
+
145
+ def _extract_filename_from_url(self, url: str) -> str:
146
+ """Extract filename from URL.
147
+
148
+ Args:
149
+ url: Source URL
150
+
151
+ Returns:
152
+ Extracted filename
153
+ """
154
+ parsed = urlparse(url)
155
+
156
+ # Try to get filename from path
157
+ path = parsed.path
158
+ if path:
159
+ filename = Path(path).name
160
+ if filename and '.' in filename:
161
+ return filename
162
+
163
+ # Try to get from query parameters (common for download APIs)
164
+ if parsed.query:
165
+ for param in parsed.query.split('&'):
166
+ if '=' in param:
167
+ key, value = param.split('=', 1)
168
+ if key.lower() in ['filename', 'name', 'file']:
169
+ return value
170
+
171
+ # Fall back to generic name with extension guessing
172
+ if 'civitai' in url.lower():
173
+ return "civitai_model.safetensors"
174
+ elif 'huggingface' in url.lower():
175
+ return "huggingface_model.safetensors"
176
+ else:
177
+ return "downloaded_model.safetensors"
178
+
179
+ def get_download_info(self, url: str) -> dict:
180
+ """Get information about what would be downloaded without downloading.
181
+
182
+ Args:
183
+ url: Source URL
184
+
185
+ Returns:
186
+ Dictionary with download information
187
+ """
188
+ source_type, metadata = self._parse_source_url(url)
189
+ filename = self._extract_filename_from_url(url)
190
+ target_path = self.cache_dir / filename
191
+
192
+ # Check if already exists
193
+ existing = self.model_repository.find_by_source_url(url)
194
+
195
+ return {
196
+ 'url': url,
197
+ 'source_type': source_type,
198
+ 'filename': filename,
199
+ 'target_path': str(target_path),
200
+ 'already_downloaded': target_path.exists(),
201
+ 'already_indexed': existing is not None,
202
+ 'existing_model': existing,
203
+ 'metadata': metadata
204
+ }
205
+
206
+ def bulk_download(self, urls: list[str]) -> dict[str, ModelWithLocation | Exception]:
207
+ """Download multiple models from URLs.
208
+
209
+ Args:
210
+ urls: List of URLs to download
211
+
212
+ Returns:
213
+ Dictionary mapping URLs to ModelIndex or Exception
214
+ """
215
+ results = {}
216
+
217
+ for url in urls:
218
+ try:
219
+ model = self.download_from_url(url)
220
+ results[url] = model
221
+ logger.info(f"✓ Downloaded: {url}")
222
+ except Exception as e:
223
+ results[url] = e
224
+ logger.error(f"✗ Failed to download {url}: {e}")
225
+
226
+ return results
227
+
228
+ def redownload_from_sources(self, model_hash: str) -> ModelWithLocation | None:
229
+ """Attempt to redownload a model from its known sources.
230
+
231
+ Args:
232
+ model_hash: Hash of model to redownload
233
+
234
+ Returns:
235
+ ModelIndex if successful, None if no sources or download failed
236
+ """
237
+ sources = self.model_repository.get_sources(model_hash)
238
+
239
+ if not sources:
240
+ logger.warning(f"No sources found for model {model_hash[:8]}...")
241
+ return None
242
+
243
+ for source in sources:
244
+ try:
245
+ logger.info(f"Attempting redownload from {source['type']}: {source['url']}")
246
+ return self.download_from_url(source['url'])
247
+ except Exception as e:
248
+ logger.warning(f"Failed to redownload from {source['url']}: {e}")
249
+ continue
250
+
251
+ logger.error(f"All redownload attempts failed for {model_hash[:8]}...")
252
+ return None
@@ -0,0 +1,166 @@
1
+ """ModelSymlinkManager - Creates and manages symlink from ComfyUI/models to global models directory."""
2
+ from __future__ import annotations
3
+
4
+ import shutil
5
+ from pathlib import Path
6
+
7
+ from ..logging.logging_config import get_logger
8
+ from ..models.exceptions import CDEnvironmentError
9
+ from ..utils.symlink_utils import (
10
+ is_link,
11
+ create_platform_link,
12
+ is_safe_to_delete,
13
+ )
14
+
15
+ logger = get_logger(__name__)
16
+
17
+
18
+ class ModelSymlinkManager:
19
+ """Manages symlink/junction from ComfyUI/models to global models directory."""
20
+
21
+ def __init__(self, comfyui_path: Path, global_models_path: Path):
22
+ """Initialize ModelSymlinkManager.
23
+
24
+ Args:
25
+ comfyui_path: Path to ComfyUI directory
26
+ global_models_path: Path to global models directory
27
+ """
28
+ self.comfyui_path = comfyui_path
29
+ self.global_models_path = global_models_path
30
+ self.models_link_path = comfyui_path / "models"
31
+
32
+ def create_symlink(self) -> None:
33
+ """Create symlink/junction from ComfyUI/models to global models.
34
+
35
+ Raises:
36
+ CDEnvironmentError: If global models directory doesn't exist, or if models/
37
+ exists with actual content
38
+ """
39
+ # Check global models directory exists
40
+ if not self.global_models_path.exists():
41
+ raise CDEnvironmentError(
42
+ f"Global models directory does not exist: {self.global_models_path}\n"
43
+ f"Create it first: mkdir -p {self.global_models_path}"
44
+ )
45
+
46
+ # Handle existing models/ path
47
+ if self.models_link_path.exists():
48
+ if is_link(self.models_link_path):
49
+ # Already a link - check target
50
+ if self._resolve_link() == self.global_models_path.resolve():
51
+ logger.debug("Link already points to correct target")
52
+ return
53
+ else:
54
+ # Wrong target - recreate
55
+ logger.info(
56
+ f"Updating link target: {self._resolve_link()} → {self.global_models_path}"
57
+ )
58
+ self.models_link_path.unlink()
59
+ else:
60
+ # Real directory - check if safe to delete
61
+ safe_files = {".gitkeep", ".gitignore", "Put models here.txt"}
62
+ if is_safe_to_delete(self.models_link_path, safe_files):
63
+ logger.info(
64
+ "Removing ComfyUI default models/ directory (empty or placeholder files only)"
65
+ )
66
+ shutil.rmtree(self.models_link_path)
67
+ else:
68
+ raise CDEnvironmentError(
69
+ f"models/ directory exists with content: {self.models_link_path}\n"
70
+ f"Manual action required:\n"
71
+ f" 1. Backup if needed: mv {self.models_link_path} {self.models_link_path}.backup\n"
72
+ f" 2. Remove: rm -rf {self.models_link_path}\n"
73
+ f" 3. Retry: comfygit sync"
74
+ )
75
+
76
+ # Ensure parent directory (ComfyUI/) exists
77
+ self.comfyui_path.mkdir(parents=True, exist_ok=True)
78
+
79
+ # Create platform-appropriate link
80
+ create_platform_link(self.models_link_path, self.global_models_path, "models")
81
+ logger.info(
82
+ f"Created model link: {self.models_link_path} → {self.global_models_path}"
83
+ )
84
+
85
+ def validate_symlink(self) -> bool:
86
+ """Check if link exists and points to correct target.
87
+
88
+ Returns:
89
+ True if link is valid, False otherwise
90
+ """
91
+ if not self.models_link_path.exists():
92
+ return False
93
+
94
+ if not is_link(self.models_link_path):
95
+ logger.warning(f"models/ is not a link: {self.models_link_path}")
96
+ return False
97
+
98
+ target = self._resolve_link()
99
+ if target != self.global_models_path.resolve():
100
+ logger.warning(
101
+ f"Link points to wrong target:\n"
102
+ f" Expected: {self.global_models_path}\n"
103
+ f" Actual: {target}"
104
+ )
105
+ return False
106
+
107
+ return True
108
+
109
+ def remove_symlink(self) -> None:
110
+ """Remove symlink/junction safely.
111
+
112
+ Raises:
113
+ CDEnvironmentError: If models/ is not a link (prevents accidental data loss)
114
+ """
115
+ if not self.models_link_path.exists():
116
+ return # Nothing to remove
117
+
118
+ if not is_link(self.models_link_path):
119
+ raise CDEnvironmentError(
120
+ f"Cannot remove models/: not a link\n"
121
+ f"Manual deletion required: {self.models_link_path}"
122
+ )
123
+
124
+ try:
125
+ self.models_link_path.unlink()
126
+ logger.info(f"Removed model link: {self.models_link_path}")
127
+ except Exception as e:
128
+ raise CDEnvironmentError(f"Failed to remove link: {e}") from e
129
+
130
+ def get_status(self) -> dict:
131
+ """Get current link status for debugging.
132
+
133
+ Returns:
134
+ Dictionary with status information
135
+ """
136
+ if not self.models_link_path.exists():
137
+ return {
138
+ "exists": False,
139
+ "is_symlink": False,
140
+ "is_valid": False,
141
+ "target": None,
142
+ }
143
+
144
+ is_symlink_or_junction = is_link(self.models_link_path)
145
+ target = self._resolve_link() if is_symlink_or_junction else None
146
+ is_valid = (
147
+ is_symlink_or_junction and target == self.global_models_path.resolve()
148
+ if target
149
+ else False
150
+ )
151
+
152
+ return {
153
+ "exists": True,
154
+ "is_symlink": is_symlink_or_junction,
155
+ "is_valid": is_valid,
156
+ "target": str(target) if target else None,
157
+ }
158
+
159
+ def _resolve_link(self) -> Path:
160
+ """Get symlink target path.
161
+
162
+ Returns:
163
+ Resolved path of symlink target
164
+ """
165
+ # Use resolve() which works for both symlinks and junctions
166
+ return self.models_link_path.resolve()