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,349 @@
|
|
|
1
|
+
"""GitHub API client for repository operations and metadata retrieval."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import urllib.error
|
|
5
|
+
import urllib.request
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from comfygit_core.caching.api_cache import APICacheManager
|
|
10
|
+
from comfygit_core.constants import DEFAULT_GITHUB_URL
|
|
11
|
+
from comfygit_core.logging.logging_config import get_logger
|
|
12
|
+
from comfygit_core.utils.git import parse_github_url
|
|
13
|
+
from comfygit_core.utils.retry import RateLimitManager, RetryConfig
|
|
14
|
+
|
|
15
|
+
logger = get_logger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class GitHubRepoInfo:
|
|
20
|
+
"""Information about a GitHub repository."""
|
|
21
|
+
owner: str
|
|
22
|
+
name: str
|
|
23
|
+
default_branch: str
|
|
24
|
+
description: str | None = None
|
|
25
|
+
latest_release: str | None = None
|
|
26
|
+
clone_url: str | None = None
|
|
27
|
+
latest_commit: str | None = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class GitHubRelease:
|
|
32
|
+
"""Single GitHub release."""
|
|
33
|
+
tag_name: str
|
|
34
|
+
name: str
|
|
35
|
+
published_at: str
|
|
36
|
+
prerelease: bool
|
|
37
|
+
draft: bool
|
|
38
|
+
html_url: str
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class GitHubClient:
|
|
42
|
+
"""Client for interacting with GitHub repositories.
|
|
43
|
+
|
|
44
|
+
Provides repository cloning, metadata retrieval, and release management.
|
|
45
|
+
Designed for custom nodes hosted on GitHub.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
cache_manager: APICacheManager,
|
|
51
|
+
base_url: str = DEFAULT_GITHUB_URL,
|
|
52
|
+
):
|
|
53
|
+
self.base_url = base_url
|
|
54
|
+
self.cache_manager = cache_manager
|
|
55
|
+
self.rate_limiter = RateLimitManager(min_interval=0.05)
|
|
56
|
+
self.retry_config = RetryConfig(
|
|
57
|
+
max_retries=3,
|
|
58
|
+
initial_delay=1.0,
|
|
59
|
+
max_delay=30.0,
|
|
60
|
+
exponential_base=2.0,
|
|
61
|
+
jitter=True,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
def parse_github_url(self, url: str) -> GitHubRepoInfo | None:
|
|
65
|
+
"""Parse a GitHub URL to extract repository information.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
url: GitHub repository URL
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
GitHubRepoInfo or None if invalid URL
|
|
72
|
+
"""
|
|
73
|
+
parsed = parse_github_url(url)
|
|
74
|
+
if not parsed:
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
owner, name, _ = parsed # Ignore commit for basic parsing
|
|
78
|
+
return GitHubRepoInfo(
|
|
79
|
+
owner=owner,
|
|
80
|
+
name=name,
|
|
81
|
+
default_branch="main", # Will be updated by get_repository_info
|
|
82
|
+
clone_url=f"https://github.com/{owner}/{name}.git"
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
def clone_repository(self, repo_url: str, target_path: Path,
|
|
86
|
+
ref: str | None = None) -> bool:
|
|
87
|
+
"""Clone a GitHub repository to a target path.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
repo_url: GitHub repository URL
|
|
91
|
+
target_path: Where to clone the repository
|
|
92
|
+
ref: Optional git ref (branch/tag/commit) to checkout
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
True if successful, False otherwise
|
|
96
|
+
"""
|
|
97
|
+
# TODO: Use git to clone repository
|
|
98
|
+
# TODO: Checkout specific ref if provided
|
|
99
|
+
# TODO: Handle authentication if needed
|
|
100
|
+
return False
|
|
101
|
+
|
|
102
|
+
def get_repository_info(self, repo_url: str, ref: str | None = None) -> GitHubRepoInfo | None:
|
|
103
|
+
"""Get information about a GitHub repository.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
repo_url: GitHub repository URL
|
|
107
|
+
ref: Optional git ref (branch/tag/commit) to resolve
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
Repository information or None if not found
|
|
111
|
+
"""
|
|
112
|
+
parsed = parse_github_url(repo_url)
|
|
113
|
+
if not parsed:
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
owner, name, url_commit = parsed
|
|
117
|
+
|
|
118
|
+
# ref parameter takes precedence over URL-embedded commit
|
|
119
|
+
target_ref = ref or url_commit
|
|
120
|
+
cache_key = f"{owner}/{name}" + (f"@{target_ref}" if target_ref else "")
|
|
121
|
+
|
|
122
|
+
# Try cache first
|
|
123
|
+
cached = self.cache_manager.get("github", cache_key)
|
|
124
|
+
if cached:
|
|
125
|
+
return GitHubRepoInfo(**cached)
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
# Rate limit API calls
|
|
129
|
+
self.rate_limiter.wait_if_needed("github_api")
|
|
130
|
+
|
|
131
|
+
# Get repo metadata
|
|
132
|
+
api_url = f"https://api.github.com/repos/{owner}/{name}"
|
|
133
|
+
with urllib.request.urlopen(api_url) as response:
|
|
134
|
+
repo_data = json.loads(response.read())
|
|
135
|
+
|
|
136
|
+
default_branch = repo_data.get("default_branch", "main")
|
|
137
|
+
|
|
138
|
+
# Resolve target ref to commit SHA
|
|
139
|
+
latest_commit = None
|
|
140
|
+
if target_ref:
|
|
141
|
+
# Ref specified - resolve to commit (works for branches, tags, and commits)
|
|
142
|
+
try:
|
|
143
|
+
commits_url = f"https://api.github.com/repos/{owner}/{name}/commits/{target_ref}"
|
|
144
|
+
with urllib.request.urlopen(commits_url) as response:
|
|
145
|
+
commit_data = json.loads(response.read())
|
|
146
|
+
latest_commit = commit_data.get("sha")
|
|
147
|
+
except urllib.error.HTTPError:
|
|
148
|
+
logger.warning(f"Could not resolve ref '{target_ref}' for {owner}/{name}")
|
|
149
|
+
pass
|
|
150
|
+
else:
|
|
151
|
+
# No ref - get latest commit from default branch
|
|
152
|
+
try:
|
|
153
|
+
commits_url = f"https://api.github.com/repos/{owner}/{name}/commits/{default_branch}"
|
|
154
|
+
with urllib.request.urlopen(commits_url) as response:
|
|
155
|
+
commit_data = json.loads(response.read())
|
|
156
|
+
latest_commit = commit_data.get("sha")
|
|
157
|
+
except urllib.error.HTTPError:
|
|
158
|
+
pass
|
|
159
|
+
|
|
160
|
+
# Get latest release
|
|
161
|
+
latest_release = None
|
|
162
|
+
try:
|
|
163
|
+
releases_url = f"https://api.github.com/repos/{owner}/{name}/releases/latest"
|
|
164
|
+
with urllib.request.urlopen(releases_url) as response:
|
|
165
|
+
release_data = json.loads(response.read())
|
|
166
|
+
latest_release = release_data.get("tag_name")
|
|
167
|
+
except urllib.error.HTTPError:
|
|
168
|
+
# No releases found, that's okay
|
|
169
|
+
pass
|
|
170
|
+
|
|
171
|
+
repo_info = GitHubRepoInfo(
|
|
172
|
+
owner=owner,
|
|
173
|
+
name=name,
|
|
174
|
+
default_branch=default_branch,
|
|
175
|
+
description=repo_data.get("description"),
|
|
176
|
+
latest_release=latest_release,
|
|
177
|
+
clone_url=repo_data.get("clone_url"),
|
|
178
|
+
latest_commit=latest_commit
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
# Cache the result
|
|
182
|
+
self.cache_manager.set("github", cache_key, repo_info.__dict__)
|
|
183
|
+
|
|
184
|
+
return repo_info
|
|
185
|
+
|
|
186
|
+
except (urllib.error.URLError, json.JSONDecodeError) as e:
|
|
187
|
+
logger.warning(f"Failed to get repository info for {repo_url}: {e}")
|
|
188
|
+
return None
|
|
189
|
+
|
|
190
|
+
def list_releases(self, repo_url: str, include_prerelease: bool = False,
|
|
191
|
+
limit: int = 10) -> list[GitHubRelease]:
|
|
192
|
+
"""List releases from GitHub API.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
repo_url: GitHub repository URL
|
|
196
|
+
include_prerelease: Include pre-release versions
|
|
197
|
+
limit: Maximum number of releases to fetch
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
List of GitHubRelease objects, sorted by date (newest first)
|
|
201
|
+
"""
|
|
202
|
+
parsed = parse_github_url(repo_url)
|
|
203
|
+
if not parsed:
|
|
204
|
+
return []
|
|
205
|
+
|
|
206
|
+
owner, name, _ = parsed
|
|
207
|
+
cache_key = f"{owner}/{name}/releases"
|
|
208
|
+
|
|
209
|
+
# Try cache first
|
|
210
|
+
cached = self.cache_manager.get("github", cache_key)
|
|
211
|
+
if cached:
|
|
212
|
+
releases = [GitHubRelease(**r) for r in cached]
|
|
213
|
+
# Filter and limit
|
|
214
|
+
if not include_prerelease:
|
|
215
|
+
releases = [r for r in releases if not r.prerelease]
|
|
216
|
+
return releases[:limit]
|
|
217
|
+
|
|
218
|
+
try:
|
|
219
|
+
# Rate limit API calls
|
|
220
|
+
self.rate_limiter.wait_if_needed("github_api")
|
|
221
|
+
|
|
222
|
+
# Get all releases
|
|
223
|
+
api_url = f"https://api.github.com/repos/{owner}/{name}/releases?per_page={min(limit * 2, 100)}"
|
|
224
|
+
with urllib.request.urlopen(api_url) as response:
|
|
225
|
+
releases_data = json.loads(response.read())
|
|
226
|
+
|
|
227
|
+
# Parse into GitHubRelease objects
|
|
228
|
+
releases = []
|
|
229
|
+
for release_data in releases_data:
|
|
230
|
+
# Skip drafts always
|
|
231
|
+
if release_data.get("draft", False):
|
|
232
|
+
continue
|
|
233
|
+
|
|
234
|
+
release = GitHubRelease(
|
|
235
|
+
tag_name=release_data["tag_name"],
|
|
236
|
+
name=release_data.get("name", release_data["tag_name"]),
|
|
237
|
+
published_at=release_data["published_at"],
|
|
238
|
+
prerelease=release_data.get("prerelease", False),
|
|
239
|
+
draft=release_data.get("draft", False),
|
|
240
|
+
html_url=release_data["html_url"]
|
|
241
|
+
)
|
|
242
|
+
releases.append(release)
|
|
243
|
+
|
|
244
|
+
# Sort by published date (newest first)
|
|
245
|
+
releases.sort(key=lambda r: r.published_at, reverse=True)
|
|
246
|
+
|
|
247
|
+
# Cache all releases
|
|
248
|
+
self.cache_manager.set("github", cache_key, [r.__dict__ for r in releases])
|
|
249
|
+
|
|
250
|
+
# Filter and limit
|
|
251
|
+
if not include_prerelease:
|
|
252
|
+
releases = [r for r in releases if not r.prerelease]
|
|
253
|
+
return releases[:limit]
|
|
254
|
+
|
|
255
|
+
except (urllib.error.URLError, json.JSONDecodeError) as e:
|
|
256
|
+
logger.warning(f"Failed to list releases for {repo_url}: {e}")
|
|
257
|
+
return []
|
|
258
|
+
|
|
259
|
+
def get_release_by_tag(self, repo_url: str, tag: str) -> GitHubRelease | None:
|
|
260
|
+
"""Get specific release by tag name.
|
|
261
|
+
|
|
262
|
+
Args:
|
|
263
|
+
repo_url: GitHub repository URL
|
|
264
|
+
tag: Release tag (e.g., "v0.3.20")
|
|
265
|
+
|
|
266
|
+
Returns:
|
|
267
|
+
GitHubRelease if found, None otherwise
|
|
268
|
+
"""
|
|
269
|
+
parsed = parse_github_url(repo_url)
|
|
270
|
+
if not parsed:
|
|
271
|
+
return None
|
|
272
|
+
|
|
273
|
+
owner, name, _ = parsed
|
|
274
|
+
cache_key = f"{owner}/{name}/release/{tag}"
|
|
275
|
+
|
|
276
|
+
# Try cache first
|
|
277
|
+
cached = self.cache_manager.get("github", cache_key)
|
|
278
|
+
if cached:
|
|
279
|
+
return GitHubRelease(**cached)
|
|
280
|
+
|
|
281
|
+
try:
|
|
282
|
+
# Rate limit API calls
|
|
283
|
+
self.rate_limiter.wait_if_needed("github_api")
|
|
284
|
+
|
|
285
|
+
# Get specific release by tag
|
|
286
|
+
api_url = f"https://api.github.com/repos/{owner}/{name}/releases/tags/{tag}"
|
|
287
|
+
with urllib.request.urlopen(api_url) as response:
|
|
288
|
+
release_data = json.loads(response.read())
|
|
289
|
+
|
|
290
|
+
release = GitHubRelease(
|
|
291
|
+
tag_name=release_data["tag_name"],
|
|
292
|
+
name=release_data.get("name", release_data["tag_name"]),
|
|
293
|
+
published_at=release_data["published_at"],
|
|
294
|
+
prerelease=release_data.get("prerelease", False),
|
|
295
|
+
draft=release_data.get("draft", False),
|
|
296
|
+
html_url=release_data["html_url"]
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
# Cache the result
|
|
300
|
+
self.cache_manager.set("github", cache_key, release.__dict__)
|
|
301
|
+
|
|
302
|
+
return release
|
|
303
|
+
|
|
304
|
+
except urllib.error.HTTPError as e:
|
|
305
|
+
if e.code == 404:
|
|
306
|
+
# Note negative result (don't cache to avoid stale data)
|
|
307
|
+
return None
|
|
308
|
+
logger.warning(f"Failed to get release {tag} for {repo_url}: {e}")
|
|
309
|
+
return None
|
|
310
|
+
except (urllib.error.URLError, json.JSONDecodeError) as e:
|
|
311
|
+
logger.warning(f"Failed to get release {tag} for {repo_url}: {e}")
|
|
312
|
+
return None
|
|
313
|
+
|
|
314
|
+
def validate_version_exists(self, repo_url: str, version: str) -> bool:
|
|
315
|
+
"""Check if a version (tag, commit, or branch) exists.
|
|
316
|
+
|
|
317
|
+
Args:
|
|
318
|
+
repo_url: GitHub repository URL
|
|
319
|
+
version: Tag name, commit SHA, or branch name
|
|
320
|
+
|
|
321
|
+
Returns:
|
|
322
|
+
True if version exists and is accessible
|
|
323
|
+
"""
|
|
324
|
+
# If looks like tag (starts with 'v'), check releases
|
|
325
|
+
if version.startswith('v'):
|
|
326
|
+
release = self.get_release_by_tag(repo_url, version)
|
|
327
|
+
return release is not None
|
|
328
|
+
|
|
329
|
+
# For branches and commits, we could check other APIs
|
|
330
|
+
# but for simplicity, assume they exist
|
|
331
|
+
# (git clone will fail if they don't)
|
|
332
|
+
return True
|
|
333
|
+
|
|
334
|
+
def download_release_asset(self, repo_url: str, asset_name: str,
|
|
335
|
+
target_path: Path) -> bool:
|
|
336
|
+
"""Download a specific release asset from a repository.
|
|
337
|
+
|
|
338
|
+
Args:
|
|
339
|
+
repo_url: GitHub repository URL
|
|
340
|
+
asset_name: Name of the asset to download
|
|
341
|
+
target_path: Where to save the downloaded asset
|
|
342
|
+
|
|
343
|
+
Returns:
|
|
344
|
+
True if successful, False otherwise
|
|
345
|
+
"""
|
|
346
|
+
# TODO: Find release with the asset
|
|
347
|
+
# TODO: Download the asset
|
|
348
|
+
# TODO: Save to target path
|
|
349
|
+
return False
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""Comfy Registry API client for node discovery, validation, and metadata retrieval."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import urllib.error
|
|
5
|
+
import urllib.parse
|
|
6
|
+
import urllib.request
|
|
7
|
+
|
|
8
|
+
from comfygit_core.caching.api_cache import APICacheManager
|
|
9
|
+
from comfygit_core.constants import DEFAULT_REGISTRY_URL
|
|
10
|
+
from comfygit_core.logging.logging_config import get_logger
|
|
11
|
+
from comfygit_core.models.exceptions import (
|
|
12
|
+
CDNodeNotFoundError,
|
|
13
|
+
CDRegistryAuthError,
|
|
14
|
+
CDRegistryConnectionError,
|
|
15
|
+
CDRegistryError,
|
|
16
|
+
CDRegistryServerError,
|
|
17
|
+
)
|
|
18
|
+
from comfygit_core.models.registry import RegistryNodeInfo, RegistryNodeVersion
|
|
19
|
+
from comfygit_core.utils.retry import (
|
|
20
|
+
RateLimitManager,
|
|
21
|
+
RetryConfig,
|
|
22
|
+
retry_on_rate_limit,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
logger = get_logger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ComfyRegistryClient:
|
|
29
|
+
"""Client for interacting with the Comfy Registry API.
|
|
30
|
+
|
|
31
|
+
Provides node discovery, validation, version management, and download URLs.
|
|
32
|
+
Includes intelligent fuzzy matching and caching for optimal performance.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
cache_manager: APICacheManager,
|
|
38
|
+
base_url: str = DEFAULT_REGISTRY_URL,
|
|
39
|
+
):
|
|
40
|
+
self.base_url = base_url
|
|
41
|
+
self.cache_manager = cache_manager
|
|
42
|
+
self.rate_limiter = RateLimitManager(min_interval=0.05)
|
|
43
|
+
self.retry_config = RetryConfig(
|
|
44
|
+
max_retries=3,
|
|
45
|
+
initial_delay=0.5,
|
|
46
|
+
max_delay=30.0,
|
|
47
|
+
exponential_base=2.0,
|
|
48
|
+
jitter=True,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
def get_node(self, node_id: str) -> RegistryNodeInfo | None:
|
|
52
|
+
"""Get exact node by ID from registry.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
node_id: Exact registry node ID
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
NodeInfo if found, None otherwise
|
|
59
|
+
|
|
60
|
+
Raises:
|
|
61
|
+
CDRegistryError: If registry request fails
|
|
62
|
+
"""
|
|
63
|
+
url = f"{self.base_url}/nodes/{node_id}"
|
|
64
|
+
|
|
65
|
+
data = self._make_registry_request(url)
|
|
66
|
+
|
|
67
|
+
if data:
|
|
68
|
+
version = data.get('latest_version', {}).get('version', 'unknown')
|
|
69
|
+
logger.debug(f"Found node '{node_id}' in registry (version: {version})")
|
|
70
|
+
return RegistryNodeInfo.from_api_data(data)
|
|
71
|
+
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
def install_node(
|
|
75
|
+
self, node_id: str, version: str | None
|
|
76
|
+
) -> RegistryNodeVersion | None:
|
|
77
|
+
"""Get the exact node download info by ID from registry.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
node_id: Exact registry node ID
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
NodeInfo if found, None otherwise
|
|
84
|
+
|
|
85
|
+
Raises:
|
|
86
|
+
CDRegistryError: If registry request fails
|
|
87
|
+
"""
|
|
88
|
+
url = f"{self.base_url}/nodes/{node_id}/install"
|
|
89
|
+
|
|
90
|
+
if version:
|
|
91
|
+
url += f"?version={version}"
|
|
92
|
+
|
|
93
|
+
data = self._make_registry_request(url)
|
|
94
|
+
|
|
95
|
+
if data:
|
|
96
|
+
version = data.get('version', 'unknown')
|
|
97
|
+
logger.debug(f"Found install info for node '{node_id}' (version: {version})")
|
|
98
|
+
return RegistryNodeVersion.from_api_data(data)
|
|
99
|
+
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
def search_nodes(
|
|
103
|
+
self, query: str, limit: int = 20
|
|
104
|
+
) -> list[RegistryNodeInfo] | None:
|
|
105
|
+
"""Search for nodes by keyword.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
query: Search term (name, author, description)
|
|
109
|
+
limit: Max results
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
List of matching nodes
|
|
113
|
+
|
|
114
|
+
Raises:
|
|
115
|
+
CDRegistryError: If registry request fails
|
|
116
|
+
"""
|
|
117
|
+
params = {"search": query, "limit": limit}
|
|
118
|
+
url = f"{self.base_url}/nodes/search?{urllib.parse.urlencode(params)}"
|
|
119
|
+
|
|
120
|
+
data = self._make_registry_request(url)
|
|
121
|
+
|
|
122
|
+
if data and "nodes" in data:
|
|
123
|
+
return [
|
|
124
|
+
info
|
|
125
|
+
for n in data["nodes"]
|
|
126
|
+
if (info := RegistryNodeInfo.from_api_data(n)) is not None
|
|
127
|
+
]
|
|
128
|
+
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
def get_node_requirements(self, node_id: str) -> list[str]:
|
|
132
|
+
"""Get requirements for a specific node version.
|
|
133
|
+
|
|
134
|
+
Raises:
|
|
135
|
+
CDNodeNotFoundError: If node doesn't exist
|
|
136
|
+
CDRegistryError: If registry request fails
|
|
137
|
+
"""
|
|
138
|
+
url = f"{self.base_url}/nodes/{node_id}/install"
|
|
139
|
+
|
|
140
|
+
install_info = self._make_registry_request(url)
|
|
141
|
+
|
|
142
|
+
if install_info is None:
|
|
143
|
+
# This is more exceptional - we're asking for requirements
|
|
144
|
+
# of a node we expect to exist
|
|
145
|
+
raise CDNodeNotFoundError(f"Node '{node_id}' not found in registry")
|
|
146
|
+
|
|
147
|
+
return install_info.get("dependencies", [])
|
|
148
|
+
|
|
149
|
+
@retry_on_rate_limit(RetryConfig(max_retries=3, initial_delay=0.5, max_delay=30.0))
|
|
150
|
+
def _make_registry_request(self, url: str) -> dict | None:
|
|
151
|
+
"""Make a request to Registry API with retry logic.
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
Response data or None for 404 (not found)
|
|
155
|
+
|
|
156
|
+
Raises:
|
|
157
|
+
CDRegistryAuthError: For 401/403 authentication issues
|
|
158
|
+
CDRegistryServerError: For 5xx server errors
|
|
159
|
+
CDRegistryConnectionError: For network issues
|
|
160
|
+
"""
|
|
161
|
+
|
|
162
|
+
# Check cache first
|
|
163
|
+
logger.debug(f"Registry client: Checking cache for '{url}'")
|
|
164
|
+
cached_data = self.cache_manager.get("registry", url)
|
|
165
|
+
if cached_data is not None:
|
|
166
|
+
logger.debug(f"Registry client: Using cached data for '{url}'")
|
|
167
|
+
return cached_data
|
|
168
|
+
|
|
169
|
+
# Rate limit ourselves
|
|
170
|
+
self.rate_limiter.wait_if_needed("registry_api")
|
|
171
|
+
|
|
172
|
+
req = urllib.request.Request(url)
|
|
173
|
+
req.add_header("User-Agent", "ComfyDock/1.0")
|
|
174
|
+
|
|
175
|
+
try:
|
|
176
|
+
with urllib.request.urlopen(req, timeout=30) as response:
|
|
177
|
+
if response.status == 200:
|
|
178
|
+
json_data = json.loads(response.read().decode("utf-8"))
|
|
179
|
+
logger.debug(f"Registry client: Got data for '{url}'")
|
|
180
|
+
self.cache_manager.set("registry", url, json_data)
|
|
181
|
+
return json_data
|
|
182
|
+
|
|
183
|
+
except urllib.error.HTTPError as e:
|
|
184
|
+
if e.code == 404:
|
|
185
|
+
# Not found is expected for many operations
|
|
186
|
+
logger.debug(f"Registry: Node not found at '{url}'")
|
|
187
|
+
return None
|
|
188
|
+
|
|
189
|
+
elif e.code in (401, 403):
|
|
190
|
+
# Authentication/authorization errors
|
|
191
|
+
logger.error(f"Registry auth error for '{url}': HTTP {e.code}")
|
|
192
|
+
|
|
193
|
+
# Try to get more details from response
|
|
194
|
+
error_msg = f"Registry authentication failed (HTTP {e.code})"
|
|
195
|
+
try:
|
|
196
|
+
error_data = e.read().decode("utf-8")
|
|
197
|
+
if error_data:
|
|
198
|
+
error_msg += f": {error_data}"
|
|
199
|
+
except:
|
|
200
|
+
pass
|
|
201
|
+
|
|
202
|
+
raise CDRegistryAuthError(error_msg) from e
|
|
203
|
+
|
|
204
|
+
elif e.code >= 500:
|
|
205
|
+
# Server errors - these might be temporary
|
|
206
|
+
logger.error(f"Registry server error for '{url}': HTTP {e.code}")
|
|
207
|
+
raise CDRegistryServerError(
|
|
208
|
+
f"Registry server error (HTTP {e.code})"
|
|
209
|
+
) from e
|
|
210
|
+
|
|
211
|
+
else:
|
|
212
|
+
# Other HTTP errors
|
|
213
|
+
logger.error(f"Registry HTTP error for '{url}': {e.code} {e.reason}")
|
|
214
|
+
raise CDRegistryError(
|
|
215
|
+
f"Registry request failed: HTTP {e.code} {e.reason}"
|
|
216
|
+
) from e
|
|
217
|
+
|
|
218
|
+
except urllib.error.URLError as e:
|
|
219
|
+
# Network errors (connection refused, DNS failure, etc.)
|
|
220
|
+
logger.error(f"Registry connection error for '{url}': {e}")
|
|
221
|
+
raise CDRegistryConnectionError(
|
|
222
|
+
f"Failed to connect to registry: {e.reason}"
|
|
223
|
+
) from e
|
|
224
|
+
|
|
225
|
+
except Exception as e:
|
|
226
|
+
# Unexpected errors
|
|
227
|
+
logger.error(f"Unexpected error accessing registry at '{url}': {e}")
|
|
228
|
+
raise CDRegistryError(f"Registry request failed: {e}") from e
|
|
229
|
+
|
|
230
|
+
return None # Shouldn't reach here, but for completeness
|