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,293 @@
1
+ """models/environment.py - Environment models for ComfyDock."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from enum import Enum
5
+ from pathlib import Path
6
+ from typing import List, TYPE_CHECKING
7
+
8
+ from .workflow import DetailedWorkflowStatus
9
+
10
+ if TYPE_CHECKING:
11
+ from .manifest import ManifestModel
12
+
13
+
14
+ @dataclass
15
+ class PackageSyncStatus:
16
+ """Status of package synchronization."""
17
+
18
+ in_sync: bool
19
+ message: str
20
+ details: str | None = None
21
+
22
+
23
+ @dataclass
24
+ class GitStatus:
25
+ """Encapsulated git status information."""
26
+
27
+ has_changes: bool
28
+ current_branch: str | None = None # None = detached HEAD
29
+ has_other_changes: bool = False # Changes beyond workflows/pyproject
30
+ # diff: str
31
+ workflow_changes: dict[str, str] = field(default_factory=dict)
32
+
33
+ # Git change details (populated by parser if needed)
34
+ nodes_added: list[dict] = field(default_factory=list) # {"name": str, "is_development": bool}
35
+ nodes_removed: list[dict] = field(default_factory=list) # {"name": str, "is_development": bool}
36
+ dependencies_added: list[dict] = field(default_factory=list)
37
+ dependencies_removed: list[dict] = field(default_factory=list)
38
+ dependencies_updated: list[dict] = field(default_factory=list)
39
+ constraints_added: list[str] = field(default_factory=list)
40
+ constraints_removed: list[str] = field(default_factory=list)
41
+
42
+ @dataclass
43
+ class GitInfo:
44
+ commit: str | None = None
45
+ branch: str | None = None
46
+ tag: str | None = None
47
+ is_dirty: bool = False
48
+ remote_url: str | None = None
49
+ github_owner: str | None = None
50
+ github_repo: str | None = None
51
+
52
+ @dataclass
53
+ class EnvironmentComparison:
54
+ """Comparison between current and expected environment states."""
55
+
56
+ missing_nodes: list[str] = field(default_factory=list)
57
+ extra_nodes: list[str] = field(default_factory=list)
58
+ version_mismatches: list[dict] = field(
59
+ default_factory=list
60
+ ) # {name, expected, actual}
61
+ packages_in_sync: bool = True
62
+ package_sync_message: str = ""
63
+ potential_dev_rename: bool = False
64
+
65
+ @property
66
+ def is_synced(self) -> bool:
67
+ """Check if environment is fully synced."""
68
+ return (
69
+ not self.missing_nodes
70
+ and not self.extra_nodes
71
+ and not self.version_mismatches
72
+ and self.packages_in_sync
73
+ )
74
+
75
+ @dataclass
76
+ class NodeState:
77
+ """State of an installed custom node."""
78
+
79
+ name: str
80
+ path: Path
81
+ disabled: bool = False
82
+ git_commit: str | None = None
83
+ git_branch: str | None = None
84
+ version: str | None = None # From git tag or pyproject
85
+ is_dirty: bool = False
86
+ source: str | None = None # 'registry', 'git', 'development', etc.
87
+
88
+
89
+ @dataclass
90
+ class EnvironmentState:
91
+ """Current state of an environment."""
92
+
93
+ custom_nodes: dict[str, NodeState] # name -> state
94
+ packages: dict[str, str] | None # name -> version
95
+ python_version: str | None
96
+
97
+
98
+ @dataclass
99
+ class MissingModelInfo:
100
+ """Information about a model that's in pyproject but not in local index."""
101
+ model: "ManifestModel" # From global models table
102
+ workflow_names: list[str] # Which workflows need it
103
+ criticality: str # "required", "flexible", "optional" (worst case across workflows)
104
+ can_download: bool # Has sources available
105
+
106
+ @property
107
+ def is_required(self) -> bool:
108
+ return self.criticality == "required"
109
+
110
+
111
+ # === Semantic Value Objects ===
112
+
113
+
114
+ class UserAction(Enum):
115
+ """Recommended user actions."""
116
+
117
+ SYNC_REQUIRED = "sync"
118
+ COMMIT_REQUIRED = "commit"
119
+ NO_ACTION_NEEDED = "none"
120
+
121
+
122
+ @dataclass
123
+ class ChangesSummary:
124
+ """Summary of changes with semantic meaning."""
125
+
126
+ primary_changes: List[str] = field(default_factory=list)
127
+ secondary_changes: List[str] = field(default_factory=list)
128
+ has_breaking_changes: bool = False
129
+
130
+ def get_headline(self) -> str:
131
+ """Get a headline summary of changes."""
132
+ if not self.primary_changes and not self.secondary_changes:
133
+ return "No changes"
134
+
135
+ if self.has_breaking_changes:
136
+ return "Breaking changes detected"
137
+
138
+ if len(self.primary_changes) == 1 and not self.secondary_changes:
139
+ return self.primary_changes[0]
140
+
141
+ total = len(self.primary_changes) + len(self.secondary_changes)
142
+ return f"{total} changes"
143
+
144
+ def get_commit_message(self) -> str:
145
+ """Generate a commit message from changes."""
146
+ parts = self.primary_changes + self.secondary_changes
147
+ if not parts:
148
+ return "Update environment configuration"
149
+ return "; ".join(parts)
150
+
151
+
152
+
153
+
154
+ @dataclass
155
+ class EnvironmentStatus:
156
+ """Complete environment status including comparison and git/workflow state."""
157
+
158
+ comparison: EnvironmentComparison
159
+ git: GitStatus
160
+ workflow: DetailedWorkflowStatus
161
+ missing_models: list[MissingModelInfo] = field(default_factory=list)
162
+
163
+ @classmethod
164
+ def create(
165
+ cls,
166
+ comparison: EnvironmentComparison,
167
+ git_status: GitStatus,
168
+ workflow_status: DetailedWorkflowStatus,
169
+ missing_models: list[MissingModelInfo] | None = None,
170
+ ) -> "EnvironmentStatus":
171
+ """Factory method to create EnvironmentStatus from components."""
172
+ return cls(
173
+ comparison=comparison,
174
+ git=git_status,
175
+ workflow=workflow_status,
176
+ missing_models=missing_models or []
177
+ )
178
+
179
+ @property
180
+ def is_synced(self) -> bool:
181
+ """Check if environment is fully synced (nodes, packages, workflows, and models)."""
182
+ return (
183
+ self.comparison.is_synced and
184
+ self.workflow.sync_status.is_synced and
185
+ not self.missing_models
186
+ )
187
+
188
+ # === Semantic Methods ===
189
+
190
+ def get_changes_summary(self) -> ChangesSummary:
191
+ """Analyze and categorize all changes."""
192
+ primary_changes = []
193
+ secondary_changes = []
194
+
195
+ # Node changes (most specific)
196
+ if self.git.nodes_added and self.git.nodes_removed:
197
+ primary_changes.append(
198
+ f"Update nodes: +{len(self.git.nodes_added)}, -{len(self.git.nodes_removed)}"
199
+ )
200
+ elif self.git.nodes_added:
201
+ if len(self.git.nodes_added) == 1:
202
+ primary_changes.append(f"Add {self.git.nodes_added[0]['name']}")
203
+ else:
204
+ primary_changes.append(f"Add {len(self.git.nodes_added)} nodes")
205
+ elif self.git.nodes_removed:
206
+ if len(self.git.nodes_removed) == 1:
207
+ primary_changes.append(f"Remove {self.git.nodes_removed[0]['name']}")
208
+ else:
209
+ primary_changes.append(f"Remove {len(self.git.nodes_removed)} nodes")
210
+
211
+ # Dependency changes
212
+ if (
213
+ self.git.dependencies_added
214
+ or self.git.dependencies_removed
215
+ or self.git.dependencies_updated
216
+ ):
217
+ dep_count = (
218
+ len(self.git.dependencies_added)
219
+ + len(self.git.dependencies_removed)
220
+ + len(self.git.dependencies_updated)
221
+ )
222
+ secondary_changes.append(f"Update {dep_count} dependencies")
223
+
224
+ # Constraint changes
225
+ if self.git.constraints_added or self.git.constraints_removed:
226
+ secondary_changes.append("Update constraints")
227
+
228
+ # No more workflow tracking changes - all workflows are automatically managed
229
+
230
+ # Workflow file changes
231
+ if self.git.workflow_changes:
232
+ workflow_count = len(self.git.workflow_changes)
233
+ if workflow_count == 1:
234
+ workflow_name, workflow_status = list(
235
+ self.git.workflow_changes.items()
236
+ )[0]
237
+ if workflow_status == "modified":
238
+ primary_changes.append(f"Update {workflow_name}")
239
+ elif workflow_status == "added":
240
+ primary_changes.append(f"Add {workflow_name}")
241
+ elif workflow_status == "deleted":
242
+ primary_changes.append(f"Remove {workflow_name}")
243
+ else:
244
+ primary_changes.append(f"Update {workflow_count} workflows")
245
+
246
+ # Detect breaking changes
247
+ has_breaking = bool(
248
+ self.git.nodes_removed
249
+ or self.git.dependencies_removed
250
+ or self.git.constraints_removed
251
+ )
252
+
253
+ return ChangesSummary(
254
+ primary_changes=primary_changes,
255
+ secondary_changes=secondary_changes,
256
+ has_breaking_changes=has_breaking,
257
+ )
258
+
259
+ def get_recommended_action(self) -> UserAction:
260
+ """Determine what the user should do next."""
261
+ if not self.is_synced:
262
+ return UserAction.SYNC_REQUIRED
263
+ elif self.git.has_changes:
264
+ return UserAction.COMMIT_REQUIRED
265
+ else:
266
+ return UserAction.NO_ACTION_NEEDED
267
+
268
+ def generate_commit_message(self) -> str:
269
+ """Generate a semantic commit message."""
270
+ summary = self.get_changes_summary()
271
+ return summary.get_commit_message()
272
+
273
+ def get_sync_preview(self) -> dict:
274
+ """Get preview of what sync operation will do.
275
+
276
+ Note: WorkflowSyncStatus is from ComfyUI's perspective:
277
+ - new: in ComfyUI but not .cec → will be REMOVED
278
+ - deleted: in .cec but not ComfyUI → will be ADDED
279
+ - modified: differs between ComfyUI and .cec → will be UPDATED
280
+ """
281
+ return {
282
+ 'nodes_to_install': self.comparison.missing_nodes,
283
+ 'nodes_to_remove': self.comparison.extra_nodes,
284
+ 'nodes_to_update': self.comparison.version_mismatches,
285
+ 'packages_to_sync': not self.comparison.packages_in_sync,
286
+ 'workflows_to_add': self.workflow.sync_status.deleted, # Deleted from ComfyUI, will restore
287
+ 'workflows_to_update': self.workflow.sync_status.modified, # Modified, will sync
288
+ 'workflows_to_remove': self.workflow.sync_status.new, # New in ComfyUI, will remove
289
+ 'models_missing': self.missing_models,
290
+ 'models_downloadable': [m for m in self.missing_models if m.can_download],
291
+ 'models_unavailable': [m for m in self.missing_models if not m.can_download],
292
+ 'models_required': [m for m in self.missing_models if m.is_required],
293
+ }
@@ -0,0 +1,378 @@
1
+ # models/exceptions.py
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Literal
5
+
6
+
7
+ class ComfyDockError(Exception):
8
+ """Base exception for ComfyDock errors."""
9
+ pass
10
+
11
+ # ====================================================
12
+ # Workspace exceptions
13
+ # ====================================================
14
+
15
+ class CDWorkspaceNotFoundError(ComfyDockError):
16
+ """Workspace doesn't exist."""
17
+ pass
18
+
19
+ class CDWorkspaceExistsError(ComfyDockError):
20
+ """Workspace already exists."""
21
+ pass
22
+
23
+ class CDWorkspaceError(ComfyDockError):
24
+ """Workspace-related errors."""
25
+ pass
26
+
27
+ # ===================================================
28
+ # Environment exceptions
29
+ # ===================================================
30
+
31
+ class CDEnvironmentError(ComfyDockError):
32
+ """Environment-related errors."""
33
+ pass
34
+
35
+ class CDEnvironmentNotFoundError(ComfyDockError):
36
+ """Environment doesn't exist."""
37
+ pass
38
+
39
+ class CDEnvironmentExistsError(ComfyDockError):
40
+ """Environment already exists."""
41
+ pass
42
+
43
+ # ===================================================
44
+ # Resolution exceptions
45
+ # ==================================================
46
+
47
+ class CDResolutionError(ComfyDockError):
48
+ """Resolution errors."""
49
+ pass
50
+
51
+ # ===================================================
52
+ # Node exceptions
53
+ # ===================================================
54
+
55
+ class CDNodeNotFoundError(ComfyDockError):
56
+ """Raised when Node not found."""
57
+ pass
58
+
59
+ @dataclass
60
+ class NodeAction:
61
+ """Represents a possible action to resolve an error."""
62
+ action_type: Literal[
63
+ 'remove_node',
64
+ 'add_node_dev',
65
+ 'add_node_force',
66
+ 'add_node_version',
67
+ 'rename_directory',
68
+ 'update_node',
69
+ 'add_constraint',
70
+ 'skip_node'
71
+ ]
72
+
73
+ # Parameters needed for the action
74
+ node_identifier: str | None = None
75
+ node_name: str | None = None
76
+ directory_name: str | None = None
77
+ new_name: str | None = None
78
+ package_name: str | None = None # For add_constraint action
79
+
80
+ # Human-readable description (client-agnostic)
81
+ description: str = ""
82
+
83
+
84
+ @dataclass
85
+ class NodeConflictContext:
86
+ """Context about what conflicted and why."""
87
+ conflict_type: Literal[
88
+ 'already_tracked',
89
+ 'directory_exists_non_git',
90
+ 'directory_exists_no_remote',
91
+ 'same_repo_exists',
92
+ 'different_repo_exists',
93
+ 'dev_node_replacement',
94
+ 'user_cancelled'
95
+ ]
96
+
97
+ node_name: str
98
+ identifier: str | None = None
99
+ existing_identifier: str | None = None
100
+ filesystem_path: str | None = None
101
+ local_remote_url: str | None = None
102
+ expected_remote_url: str | None = None
103
+ is_development: bool = False
104
+
105
+ # Suggested actions
106
+ suggested_actions: list[NodeAction] = field(default_factory=list)
107
+
108
+
109
+ class CDNodeConflictError(ComfyDockError):
110
+ """Raised when Node has conflicts with enhanced context."""
111
+
112
+ def __init__(self, message: str, context: NodeConflictContext | None = None):
113
+ super().__init__(message)
114
+ self.context = context
115
+
116
+ def get_actions(self) -> list[NodeAction]:
117
+ """Get suggested actions for resolving this conflict."""
118
+ return self.context.suggested_actions if self.context else []
119
+
120
+
121
+ @dataclass
122
+ class DependencyConflictContext:
123
+ """Context about dependency conflicts during node installation."""
124
+
125
+ # The node being added
126
+ node_name: str
127
+
128
+ # Parsed conflict information
129
+ conflicting_packages: list[tuple[str, str]] # Package pairs that conflict
130
+ conflict_descriptions: list[str] # Simplified conflict messages
131
+
132
+ # Raw UV stderr for verbose mode
133
+ raw_stderr: str = ""
134
+
135
+ # Suggested resolutions
136
+ suggested_actions: list[NodeAction] = field(default_factory=list)
137
+
138
+
139
+ class CDDependencyConflictError(ComfyDockError):
140
+ """Raised when dependency resolution fails during node installation."""
141
+
142
+ def __init__(self, message: str, context: DependencyConflictContext | None = None):
143
+ super().__init__(message)
144
+ self.context = context
145
+
146
+ def get_actions(self) -> list[NodeAction]:
147
+ """Get suggested actions for resolving this conflict."""
148
+ return self.context.suggested_actions if self.context else []
149
+
150
+ # ===================================================
151
+ # Registry exceptions
152
+ # ===================================================
153
+
154
+ class CDRegistryError(ComfyDockError):
155
+ """Base class for registry errors."""
156
+ pass
157
+
158
+ class CDRegistryAuthError(CDRegistryError):
159
+ """Authentication/authorization errors with registry."""
160
+ pass
161
+
162
+ class CDRegistryServerError(CDRegistryError):
163
+ """Registry server errors (5xx)."""
164
+ pass
165
+
166
+ class CDRegistryConnectionError(CDRegistryError):
167
+ """Network/connection errors to registry."""
168
+ pass
169
+
170
+ class CDRegistryDataError(ComfyDockError):
171
+ """Registry data is not available or cannot be loaded.
172
+
173
+ This error indicates that registry node mappings are missing or corrupted.
174
+ Recovery typically involves downloading or updating the registry data.
175
+ """
176
+
177
+ def __init__(
178
+ self,
179
+ message: str,
180
+ cache_path: str | None = None,
181
+ can_retry: bool = True
182
+ ):
183
+ super().__init__(message)
184
+ self.cache_path = cache_path
185
+ self.can_retry = can_retry
186
+
187
+ # ===================================================
188
+ # Pyproject exceptions
189
+ # ===================================================
190
+
191
+ class CDPyprojectError(ComfyDockError):
192
+ """Errors related to pyproject.toml operations."""
193
+ pass
194
+
195
+ class CDPyprojectNotFoundError(CDPyprojectError):
196
+ """pyproject.toml file not found."""
197
+ pass
198
+
199
+ class CDPyprojectInvalidError(CDPyprojectError):
200
+ """pyproject.toml file is invalid or corrupted."""
201
+ pass
202
+
203
+ # ===================================================
204
+ # Dependency exceptions
205
+ # ===================================================
206
+
207
+ class CDDependencyError(ComfyDockError):
208
+ """Dependency-related errors."""
209
+ pass
210
+
211
+ class CDPackageSyncError(CDDependencyError):
212
+ """Package synchronization errors."""
213
+ pass
214
+
215
+ # ===================================================
216
+ # Index exceptions
217
+ # ===================================================
218
+
219
+ class CDIndexError(ComfyDockError):
220
+ """Index configuration errors."""
221
+ pass
222
+
223
+ # ===================================================
224
+ # Process/Command exceptions
225
+ # ===================================================
226
+
227
+ class CDProcessError(ComfyDockError):
228
+ """Raised when subprocess command execution fails."""
229
+
230
+ def __init__(
231
+ self,
232
+ message: str,
233
+ command: list[str] | None = None,
234
+ stderr: str | None = None,
235
+ stdout: str | None = None,
236
+ returncode: int | None = None,
237
+ ):
238
+ super().__init__(message)
239
+ self.command = command
240
+ self.stderr = stderr
241
+ self.stdout = stdout
242
+ self.returncode = returncode
243
+
244
+
245
+ # ===================================================
246
+ # UV exceptions
247
+ # ==================================================
248
+
249
+ class UVNotInstalledError(ComfyDockError):
250
+ """Raised when UV is not installed."""
251
+ pass
252
+
253
+
254
+ class UVCommandError(ComfyDockError):
255
+ """Raised when UV command execution fails."""
256
+
257
+ def __init__(
258
+ self,
259
+ message: str,
260
+ command: list[str] | None = None,
261
+ stderr: str | None = None,
262
+ stdout: str | None = None,
263
+ returncode: int | None = None,
264
+ ):
265
+ super().__init__(message)
266
+ self.command = command
267
+ self.stderr = stderr
268
+ self.stdout = stdout
269
+ self.returncode = returncode
270
+
271
+ def __str__(self) -> str:
272
+ """Include stderr/stdout in string representation for better error messages."""
273
+ parts = [super().__str__()]
274
+
275
+ if self.stderr and self.stderr.strip():
276
+ parts.append(f"\nStderr: {self.stderr.strip()}")
277
+
278
+ if self.stdout and self.stdout.strip():
279
+ parts.append(f"\nStdout: {self.stdout.strip()}")
280
+
281
+ return "".join(parts)
282
+
283
+
284
+ # ===================================================
285
+ # Export/Import exceptions
286
+ # ===================================================
287
+
288
+ @dataclass
289
+ class ExportErrorContext:
290
+ """Context about an export failure."""
291
+ uncommitted_workflows: list[str] = field(default_factory=list)
292
+ uncommitted_git_changes: bool = False
293
+ has_unresolved_issues: bool = False
294
+
295
+
296
+ class CDExportError(ComfyDockError):
297
+ """Export operation failed with detailed context."""
298
+
299
+ def __init__(self, message: str, context: ExportErrorContext | None = None):
300
+ super().__init__(message)
301
+ self.context = context
302
+
303
+ @property
304
+ def uncommitted_workflows(self) -> list[str] | None:
305
+ """Get list of uncommitted workflows for backward compatibility."""
306
+ return self.context.uncommitted_workflows if self.context else None
307
+
308
+
309
+ # ===================================================
310
+ # Model Download exceptions
311
+ # ===================================================
312
+
313
+ @dataclass
314
+ class DownloadErrorContext:
315
+ """Detailed context about a download failure."""
316
+ provider: str # 'civitai', 'huggingface', 'custom'
317
+ error_category: str # 'auth_missing', 'auth_invalid', 'forbidden', 'not_found', 'network', 'server', 'unknown'
318
+ http_status: int | None
319
+ url: str
320
+ has_configured_auth: bool # Was auth configured (even if invalid)?
321
+ raw_error: str # Original error message for debugging
322
+
323
+ def get_user_message(self) -> str:
324
+ """Generate user-friendly error message."""
325
+ if self.provider == "civitai":
326
+ if self.error_category == "auth_missing":
327
+ return (
328
+ f"CivitAI model requires authentication (HTTP {self.http_status}). "
329
+ "No API key found. Get your key from https://civitai.com/user/account "
330
+ "and add it with: comfygit config --civitai-key <your-key>"
331
+ )
332
+ elif self.error_category == "auth_invalid":
333
+ return (
334
+ f"CivitAI authentication failed (HTTP {self.http_status}). "
335
+ "Your API key may be invalid or expired. "
336
+ "Update it with: comfygit config --civitai-key <your-key>"
337
+ )
338
+ elif self.error_category == "forbidden":
339
+ return (
340
+ f"CivitAI access forbidden (HTTP {self.http_status}). "
341
+ "This model may require special permissions or may not be publicly available."
342
+ )
343
+ elif self.error_category == "not_found":
344
+ return f"CivitAI model not found (HTTP {self.http_status}). The URL may be incorrect or the model was removed."
345
+
346
+ elif self.provider == "huggingface":
347
+ if self.error_category in ("auth_missing", "auth_invalid"):
348
+ return (
349
+ f"HuggingFace model requires authentication (HTTP {self.http_status}). "
350
+ "Set the HF_TOKEN environment variable with your HuggingFace token. "
351
+ "Get your token from: https://huggingface.co/settings/tokens"
352
+ )
353
+ elif self.error_category == "not_found":
354
+ return f"HuggingFace model not found (HTTP {self.http_status}). Check the URL is correct."
355
+
356
+ # Generic provider or fallback
357
+ if self.error_category == "network":
358
+ return f"Network error downloading from {self.provider}: {self.raw_error}"
359
+ elif self.error_category == "server":
360
+ return f"Server error from {self.provider} (HTTP {self.http_status}). Try again later."
361
+ elif self.http_status:
362
+ return f"Download failed from {self.provider} (HTTP {self.http_status}): {self.raw_error}"
363
+ else:
364
+ return f"Download failed from {self.provider}: {self.raw_error}"
365
+
366
+
367
+ class CDModelDownloadError(ComfyDockError):
368
+ """Model download error with provider-specific context."""
369
+
370
+ def __init__(self, message: str, context: DownloadErrorContext | None = None):
371
+ super().__init__(message)
372
+ self.context = context
373
+
374
+ def get_user_message(self) -> str:
375
+ """Get user-friendly error message."""
376
+ if self.context:
377
+ return self.context.get_user_message()
378
+ return str(self)