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,436 @@
1
+ """UserContentSymlinkManager - Manages per-environment input/output directories."""
2
+ from __future__ import annotations
3
+
4
+ import shutil
5
+ from pathlib import Path
6
+ from typing import Tuple
7
+
8
+ from ..logging.logging_config import get_logger
9
+ from ..models.exceptions import CDEnvironmentError
10
+ from ..utils.symlink_utils import (
11
+ is_link,
12
+ create_platform_link,
13
+ is_safe_to_delete,
14
+ )
15
+
16
+ logger = get_logger(__name__)
17
+
18
+
19
+ class UserContentSymlinkManager:
20
+ """Manages per-environment user content directories (input/output).
21
+
22
+ Creates symlinks from ComfyUI/input and ComfyUI/output to workspace-level
23
+ directories that persist when environments are deleted.
24
+
25
+ Architecture:
26
+ - Input: ComfyUI/input/ → workspace/input/{env_name}/
27
+ - Output: ComfyUI/output/ → workspace/output/{env_name}/
28
+
29
+ Unlike models (which are shared across environments), input/output are
30
+ isolated per-environment to prevent cross-contamination of user data.
31
+ """
32
+
33
+ def __init__(
34
+ self,
35
+ comfyui_path: Path,
36
+ env_name: str,
37
+ workspace_input_base: Path,
38
+ workspace_output_base: Path,
39
+ ):
40
+ """Initialize UserContentSymlinkManager.
41
+
42
+ Args:
43
+ comfyui_path: Path to ComfyUI directory
44
+ env_name: Environment name (for subdirectory creation)
45
+ workspace_input_base: Base workspace input directory (workspace/input/)
46
+ workspace_output_base: Base workspace output directory (workspace/output/)
47
+ """
48
+ self.comfyui_path = comfyui_path
49
+ self.env_name = env_name
50
+
51
+ # Workspace targets (per-environment subdirectories)
52
+ self.input_target = workspace_input_base / env_name
53
+ self.output_target = workspace_output_base / env_name
54
+
55
+ # ComfyUI symlinks
56
+ self.input_link = comfyui_path / "input"
57
+ self.output_link = comfyui_path / "output"
58
+
59
+ # Safe files that can be deleted without warning
60
+ self.safe_files = {".gitkeep", ".gitignore", "Put files here.txt"}
61
+
62
+ def create_directories(self) -> None:
63
+ """Create workspace subdirectories for this environment.
64
+
65
+ Creates:
66
+ - workspace/input/{env_name}/
67
+ - workspace/output/{env_name}/
68
+
69
+ Safe to call multiple times (idempotent).
70
+ """
71
+ self.input_target.mkdir(parents=True, exist_ok=True)
72
+ self.output_target.mkdir(parents=True, exist_ok=True)
73
+ logger.debug(
74
+ f"Created workspace directories for '{self.env_name}': "
75
+ f"input={self.input_target}, output={self.output_target}"
76
+ )
77
+
78
+ def create_symlinks(self) -> None:
79
+ """Create input and output symlinks.
80
+
81
+ Raises:
82
+ CDEnvironmentError: If workspace directories don't exist or if
83
+ ComfyUI directories exist with actual content
84
+ """
85
+ # Ensure workspace directories exist
86
+ if not self.input_target.exists():
87
+ raise CDEnvironmentError(
88
+ f"Workspace input directory for '{self.env_name}' does not exist: {self.input_target}\n"
89
+ f"Call create_directories() first"
90
+ )
91
+
92
+ if not self.output_target.exists():
93
+ raise CDEnvironmentError(
94
+ f"Workspace output directory for '{self.env_name}' does not exist: {self.output_target}\n"
95
+ f"Call create_directories() first"
96
+ )
97
+
98
+ # Create both symlinks
99
+ self._create_single_link(
100
+ self.input_link,
101
+ self.input_target,
102
+ "input"
103
+ )
104
+ self._create_single_link(
105
+ self.output_link,
106
+ self.output_target,
107
+ "output"
108
+ )
109
+
110
+ def _create_single_link(
111
+ self,
112
+ link_path: Path,
113
+ target_path: Path,
114
+ name: str
115
+ ) -> None:
116
+ """Create a single symlink with safety checks.
117
+
118
+ Args:
119
+ link_path: Where to create symlink (e.g., ComfyUI/input/)
120
+ target_path: What symlink should point to (e.g., workspace/input/env1/)
121
+ name: Directory name for logging/errors ("input" or "output")
122
+ """
123
+ # Handle existing path
124
+ if link_path.exists():
125
+ if is_link(link_path):
126
+ # Already a link - check target
127
+ if link_path.resolve() == target_path.resolve():
128
+ logger.debug(f"{name} link already points to correct target")
129
+ return
130
+ else:
131
+ # Wrong target - recreate
132
+ logger.info(
133
+ f"Updating {name} link target: "
134
+ f"{link_path.resolve()} → {target_path}"
135
+ )
136
+ link_path.unlink()
137
+ else:
138
+ # Real directory - check if safe to delete
139
+ if is_safe_to_delete(link_path, self.safe_files):
140
+ logger.info(
141
+ f"Removing ComfyUI default {name}/ directory "
142
+ f"(empty or placeholder files only)"
143
+ )
144
+ shutil.rmtree(link_path)
145
+ else:
146
+ # Has content - needs migration
147
+ raise CDEnvironmentError(
148
+ f"{name}/ directory exists with content: {link_path}\n"
149
+ f"Use migrate_existing_data() to migrate to workspace-level storage"
150
+ )
151
+
152
+ # Ensure parent directory (ComfyUI/) exists
153
+ self.comfyui_path.mkdir(parents=True, exist_ok=True)
154
+
155
+ # Create link
156
+ create_platform_link(link_path, target_path, name)
157
+ logger.info(f"Created {name} link: {link_path} → {target_path}")
158
+
159
+ def migrate_existing_data(self) -> dict[str, int]:
160
+ """Migrate existing input/output directories to workspace-level storage.
161
+
162
+ This handles upgrading environments created before the symlink feature.
163
+ If ComfyUI/input or ComfyUI/output exist as real directories with content,
164
+ moves their contents to workspace-level and creates symlinks.
165
+
166
+ Returns:
167
+ Dict with migration statistics:
168
+ {
169
+ "input_files_moved": int,
170
+ "output_files_moved": int,
171
+ }
172
+
173
+ Raises:
174
+ CDEnvironmentError: If migration fails
175
+ """
176
+ stats = {
177
+ "input_files_moved": 0,
178
+ "output_files_moved": 0,
179
+ }
180
+
181
+ # Ensure workspace directories exist
182
+ self.create_directories()
183
+
184
+ # Migrate input
185
+ if self.input_link.exists() and not is_link(self.input_link):
186
+ if not is_safe_to_delete(self.input_link, self.safe_files):
187
+ logger.info(f"Migrating existing input/ directory to workspace...")
188
+ stats["input_files_moved"] = self._migrate_directory(
189
+ self.input_link,
190
+ self.input_target,
191
+ "input"
192
+ )
193
+ else:
194
+ # Just remove empty/placeholder directory
195
+ shutil.rmtree(self.input_link)
196
+
197
+ # Migrate output
198
+ if self.output_link.exists() and not is_link(self.output_link):
199
+ if not is_safe_to_delete(self.output_link, self.safe_files):
200
+ logger.info(f"Migrating existing output/ directory to workspace...")
201
+ stats["output_files_moved"] = self._migrate_directory(
202
+ self.output_link,
203
+ self.output_target,
204
+ "output"
205
+ )
206
+ else:
207
+ # Just remove empty/placeholder directory
208
+ shutil.rmtree(self.output_link)
209
+
210
+ # Create symlinks after migration
211
+ self.create_symlinks()
212
+
213
+ return stats
214
+
215
+ def _migrate_directory(
216
+ self,
217
+ source_dir: Path,
218
+ target_dir: Path,
219
+ name: str
220
+ ) -> int:
221
+ """Move contents from source to target and create symlink.
222
+
223
+ Args:
224
+ source_dir: Directory with existing content (e.g., ComfyUI/input/)
225
+ target_dir: Workspace directory to move to (e.g., workspace/input/env1/)
226
+ name: Directory name for logging
227
+
228
+ Returns:
229
+ Number of items moved
230
+ """
231
+ # Count items before migration
232
+ items = list(source_dir.iterdir())
233
+ item_count = len(items)
234
+
235
+ if item_count == 0:
236
+ logger.debug(f"No files to migrate in {name}/")
237
+ shutil.rmtree(source_dir)
238
+ return 0
239
+
240
+ logger.info(f"Migrating {item_count} items from {name}/ to workspace...")
241
+
242
+ # Move each item
243
+ for item in items:
244
+ target_path = target_dir / item.name
245
+ if target_path.exists():
246
+ logger.warning(
247
+ f"Target already exists, skipping: {item.name}"
248
+ )
249
+ continue
250
+
251
+ shutil.move(str(item), str(target_path))
252
+ logger.debug(f"Moved: {item.name}")
253
+
254
+ # Remove now-empty source directory
255
+ shutil.rmtree(source_dir)
256
+ logger.info(f"Migration complete: {item_count} items moved to {target_dir}")
257
+
258
+ return item_count
259
+
260
+ def validate_symlinks(self) -> dict[str, bool]:
261
+ """Check if symlinks exist and point to correct targets.
262
+
263
+ Returns:
264
+ Dict with validation results:
265
+ {
266
+ "input": bool, # True if valid
267
+ "output": bool, # True if valid
268
+ }
269
+ """
270
+ return {
271
+ "input": self._validate_single_link(self.input_link, self.input_target, "input"),
272
+ "output": self._validate_single_link(self.output_link, self.output_target, "output"),
273
+ }
274
+
275
+ def _validate_single_link(
276
+ self,
277
+ link_path: Path,
278
+ target_path: Path,
279
+ name: str
280
+ ) -> bool:
281
+ """Validate a single symlink."""
282
+ if not link_path.exists():
283
+ logger.warning(f"{name} link does not exist: {link_path}")
284
+ return False
285
+
286
+ if not is_link(link_path):
287
+ logger.warning(f"{name}/ is not a link: {link_path}")
288
+ return False
289
+
290
+ actual_target = link_path.resolve()
291
+ expected_target = target_path.resolve()
292
+
293
+ if actual_target != expected_target:
294
+ logger.warning(
295
+ f"{name} link points to wrong target:\n"
296
+ f" Expected: {expected_target}\n"
297
+ f" Actual: {actual_target}"
298
+ )
299
+ return False
300
+
301
+ return True
302
+
303
+ def remove_symlinks(self) -> None:
304
+ """Remove input and output symlinks safely.
305
+
306
+ Note: This only removes symlinks, not the workspace data they point to.
307
+ Workspace data (workspace/input/{env_name}/ and workspace/output/{env_name}/)
308
+ is preserved for manual cleanup or deletion via delete_user_data().
309
+
310
+ Raises:
311
+ CDEnvironmentError: If paths exist but are not symlinks
312
+ """
313
+ self._remove_single_link(self.input_link, "input")
314
+ self._remove_single_link(self.output_link, "output")
315
+
316
+ def _remove_single_link(self, link_path: Path, name: str) -> None:
317
+ """Remove a single symlink safely."""
318
+ if not link_path.exists():
319
+ return # Nothing to remove
320
+
321
+ if not is_link(link_path):
322
+ raise CDEnvironmentError(
323
+ f"Cannot remove {name}/: not a link\n"
324
+ f"Manual deletion required: {link_path}"
325
+ )
326
+
327
+ link_path.unlink()
328
+ logger.info(f"Removed {name} link: {link_path}")
329
+
330
+ def get_user_data_size(self) -> dict[str, Tuple[int, int]]:
331
+ """Get size of user content for deletion warnings.
332
+
333
+ Returns:
334
+ Dict with file counts and sizes in bytes:
335
+ {
336
+ "input": (file_count, total_bytes),
337
+ "output": (file_count, total_bytes),
338
+ }
339
+ """
340
+ return {
341
+ "input": self._get_directory_size(self.input_target),
342
+ "output": self._get_directory_size(self.output_target),
343
+ }
344
+
345
+ def _get_directory_size(self, path: Path) -> Tuple[int, int]:
346
+ """Get file count and total size for directory."""
347
+ if not path.exists():
348
+ return (0, 0)
349
+
350
+ total_size = 0
351
+ file_count = 0
352
+
353
+ for item in path.rglob("*"):
354
+ if item.is_file():
355
+ file_count += 1
356
+ total_size += item.stat().st_size
357
+
358
+ return (file_count, total_size)
359
+
360
+ def delete_user_data(self) -> dict[str, int]:
361
+ """Delete workspace user data for this environment.
362
+
363
+ WARNING: This permanently deletes user content (input files and generated outputs).
364
+ Should only be called when user explicitly confirms deletion.
365
+
366
+ Returns:
367
+ Dict with deletion statistics:
368
+ {
369
+ "input_files_deleted": int,
370
+ "output_files_deleted": int,
371
+ }
372
+ """
373
+ stats = {
374
+ "input_files_deleted": 0,
375
+ "output_files_deleted": 0,
376
+ }
377
+
378
+ # Delete input directory
379
+ if self.input_target.exists():
380
+ file_count, _ = self._get_directory_size(self.input_target)
381
+ shutil.rmtree(self.input_target)
382
+ stats["input_files_deleted"] = file_count
383
+ logger.info(f"Deleted {file_count} input files from {self.input_target}")
384
+
385
+ # Delete output directory
386
+ if self.output_target.exists():
387
+ file_count, _ = self._get_directory_size(self.output_target)
388
+ shutil.rmtree(self.output_target)
389
+ stats["output_files_deleted"] = file_count
390
+ logger.info(f"Deleted {file_count} output files from {self.output_target}")
391
+
392
+ return stats
393
+
394
+ def get_status(self) -> dict:
395
+ """Get current symlink status for debugging.
396
+
397
+ Returns:
398
+ Dictionary with detailed status for both input and output
399
+ """
400
+ return {
401
+ "input": self._get_single_status(self.input_link, self.input_target, "input"),
402
+ "output": self._get_single_status(self.output_link, self.output_target, "output"),
403
+ }
404
+
405
+ def _get_single_status(
406
+ self,
407
+ link_path: Path,
408
+ target_path: Path,
409
+ name: str
410
+ ) -> dict:
411
+ """Get status for a single symlink."""
412
+ if not link_path.exists():
413
+ return {
414
+ "exists": False,
415
+ "is_symlink": False,
416
+ "is_valid": False,
417
+ "target": None,
418
+ "expected_target": str(target_path),
419
+ }
420
+
421
+ is_symlink_or_junction = is_link(link_path)
422
+ actual_target = link_path.resolve() if is_symlink_or_junction else None
423
+ is_valid = (
424
+ is_symlink_or_junction
425
+ and actual_target == target_path.resolve()
426
+ if actual_target
427
+ else False
428
+ )
429
+
430
+ return {
431
+ "exists": True,
432
+ "is_symlink": is_symlink_or_junction,
433
+ "is_valid": is_valid,
434
+ "target": str(actual_target) if actual_target else None,
435
+ "expected_target": str(target_path),
436
+ }