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,569 @@
1
+ """UV project management with smart orchestration and pyproject.toml coordination."""
2
+ from __future__ import annotations
3
+
4
+ import tempfile
5
+ from pathlib import Path
6
+ from typing import TYPE_CHECKING
7
+
8
+ from ..integrations.uv_command import UVCommand
9
+ from ..logging.logging_config import get_logger
10
+ from ..models.exceptions import CDPyprojectError, UVCommandError
11
+
12
+ if TYPE_CHECKING:
13
+ from ..managers.pyproject_manager import PyprojectManager
14
+
15
+ logger = get_logger(__name__)
16
+
17
+
18
+ class UVProjectManager:
19
+ """High-level UV project management with smart workflows and pyproject.toml coordination."""
20
+
21
+ # Marker translations for converting requirements.txt to pyproject.toml format
22
+ MARKER_TRANSLATIONS = {
23
+ 'platform_system == "Linux"': "sys_platform == 'linux'",
24
+ 'platform_system == "Windows"': "sys_platform == 'win32'",
25
+ 'platform_system == "Darwin"': "sys_platform == 'darwin'",
26
+ 'platform_system': 'sys_platform',
27
+ 'platform_machine': 'platform_machine',
28
+ '"Linux"': "'linux'",
29
+ '"Windows"': "'win32'",
30
+ '"Darwin"': "'darwin'",
31
+ '"x86_64"': "'x86_64'",
32
+ 'python_version': 'python_version',
33
+ 'python_full_version': 'python_full_version',
34
+ }
35
+
36
+ def __init__(
37
+ self,
38
+ uv_command: UVCommand,
39
+ pyproject_manager: PyprojectManager,
40
+ ):
41
+ self.uv = uv_command
42
+ self.pyproject = pyproject_manager
43
+
44
+ # ===== Properties =====
45
+
46
+ @property
47
+ def project_path(self) -> Path:
48
+ return self.pyproject.path.parent
49
+
50
+ @property
51
+ def python_executable(self) -> Path:
52
+ return self.uv.python_executable
53
+
54
+ @property
55
+ def binary(self) -> str:
56
+ return self.uv.binary
57
+
58
+ # ===== Basic Operations =====
59
+
60
+ def init_project(self, name: str | None = None, python_version: str | None = None, **flags) -> str:
61
+ result = self.uv.init(name=name, python=python_version, **flags)
62
+ return result.stdout
63
+
64
+ def add_dependency(
65
+ self,
66
+ package: str | None = None,
67
+ packages: list[str] | None = None,
68
+ requirements_file: Path | None = None,
69
+ upgrade: bool = False,
70
+ group: str | None = None,
71
+ dev: bool = False,
72
+ editable: bool = False,
73
+ bounds: str | None = None,
74
+ **flags
75
+ ) -> str:
76
+ """Add one or more dependencies to the project.
77
+
78
+ Args:
79
+ package: Single package to add (legacy parameter)
80
+ packages: List of packages to add
81
+ requirements_file: Path to requirements file
82
+ upgrade: Whether to upgrade existing packages
83
+ group: Dependency group name (e.g., 'optional-cuda')
84
+ dev: Add to dev dependencies
85
+ editable: Install as editable (for local development)
86
+ bounds: Version specifier style ('lower', 'major', 'minor', 'exact')
87
+ **flags: Additional UV flags
88
+
89
+ Returns:
90
+ UV command stdout
91
+
92
+ Raises:
93
+ ValueError: If none of package, packages, or requirements_file is provided
94
+ """
95
+ if packages:
96
+ pkg_list = packages
97
+ elif package:
98
+ pkg_list = [package]
99
+ elif requirements_file:
100
+ pkg_list = None
101
+ else:
102
+ raise ValueError("Either 'package', 'packages', or 'requirements_file' must be provided")
103
+
104
+ result = self.uv.add(
105
+ packages=pkg_list,
106
+ requirements_file=requirements_file,
107
+ upgrade=upgrade,
108
+ group=group,
109
+ dev=dev,
110
+ editable=editable,
111
+ bounds=bounds,
112
+ **flags
113
+ )
114
+ return result.stdout
115
+
116
+ def remove_dependency(self, package: str | None = None, packages: list[str] | None = None, **flags) -> dict:
117
+ """Remove one or more dependencies from the project.
118
+
119
+ Filters out packages that don't exist in dependencies before calling uv remove.
120
+ This makes the operation idempotent and safe to call with non-existent packages.
121
+
122
+ Args:
123
+ package: Single package to remove (legacy parameter)
124
+ packages: List of packages to remove
125
+ **flags: Additional UV flags
126
+
127
+ Returns:
128
+ Dict with 'removed' (list of packages removed) and 'skipped' (list of packages not in deps)
129
+ """
130
+ if packages:
131
+ pkg_list = packages
132
+ elif package:
133
+ pkg_list = [package]
134
+ else:
135
+ raise ValueError("Either 'package' or 'packages' must be provided")
136
+
137
+ # Get current dependencies to filter what actually exists
138
+ from ..utils.dependency_parser import parse_dependency_string
139
+ config = self.pyproject.load()
140
+ current_deps = config.get('project', {}).get('dependencies', [])
141
+ current_pkg_names = {parse_dependency_string(dep)[0].lower() for dep in current_deps}
142
+
143
+ # Filter to only packages that exist
144
+ existing_packages = [pkg for pkg in pkg_list if pkg.lower() in current_pkg_names]
145
+ missing_packages = [pkg for pkg in pkg_list if pkg.lower() not in current_pkg_names]
146
+
147
+ # If nothing to remove, return early
148
+ if not existing_packages:
149
+ return {
150
+ 'removed': [],
151
+ 'skipped': missing_packages
152
+ }
153
+
154
+ # Remove only existing packages
155
+ result = self.uv.remove(existing_packages, **flags)
156
+
157
+ return {
158
+ 'removed': existing_packages,
159
+ 'skipped': missing_packages
160
+ }
161
+
162
+ def sync_project(self, verbose: bool = False, **flags) -> str:
163
+ result = self.uv.sync(verbose=verbose, **flags)
164
+ return result.stdout
165
+
166
+ def lock_project(self, **flags) -> str:
167
+ result = self.uv.lock(**flags)
168
+ return result.stdout
169
+
170
+ def run_command(self, command: list[str], **flags) -> str:
171
+ result = self.uv.run(command, **flags)
172
+ return result.stdout
173
+
174
+ def create_venv(self, venv_path: Path, python_version: str | None = None, **flags) -> str:
175
+ result = self.uv.venv(venv_path, python=python_version, **flags)
176
+ return result.stdout
177
+
178
+ # ===== Smart Requirements Handling =====
179
+
180
+ def add_requirements_with_sources(
181
+ self,
182
+ requirements: Path | list[str],
183
+ group: str | None = None,
184
+ **flags
185
+ ) -> None:
186
+ """Smart requirements.txt handler that coordinates UV and pyproject.toml."""
187
+ logger.info("Adding requirements with sources...")
188
+
189
+ categorized = self._categorize_requirements(requirements)
190
+
191
+ # Create temp file with everything EXCEPT multi-URL packages
192
+ tmp_path = None
193
+ try:
194
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as tmp:
195
+ for line in categorized["regular_lines"]:
196
+ tmp.write(line + "\n")
197
+ for line in categorized["git_urls"]:
198
+ tmp.write(line + "\n")
199
+ for line in categorized["single_urls"]:
200
+ tmp.write(line + "\n")
201
+ tmp_path = Path(tmp.name)
202
+
203
+ # Let UV handle everything it can
204
+ if tmp_path.stat().st_size > 0:
205
+ self.add_dependency(requirements_file=tmp_path, group=group, **flags)
206
+
207
+ # Post-process multi-URL packages via pyproject.toml
208
+ for package, urls_with_markers in categorized["multi_url_packages"].items():
209
+ self._add_url_sources_with_markers(package, urls_with_markers, group)
210
+
211
+ logger.info("Successfully added all requirements")
212
+ except Exception as e:
213
+ raise UVCommandError(f"Failed to add requirements: {e}")
214
+ finally:
215
+ if tmp_path and tmp_path.exists():
216
+ tmp_path.unlink()
217
+
218
+ def _categorize_requirements(self, requirements: Path | list[str]) -> dict:
219
+ """Categorize requirements into UV-compatible and special handling categories."""
220
+ logger.info("Categorizing requirements...")
221
+
222
+ categorized = {
223
+ 'regular_lines': [],
224
+ 'git_urls': [],
225
+ 'single_urls': [],
226
+ 'multi_url_packages': {}, # package_name -> [{"url": ..., "marker": ...}, ...]
227
+ }
228
+
229
+ url_packages = {} # Track URL-based packages to detect multiples
230
+
231
+ # Handle both Path and list[str] input
232
+ if isinstance(requirements, Path):
233
+ with open(requirements, encoding='utf-8') as f:
234
+ lines = f.readlines()
235
+ else:
236
+ lines = requirements
237
+
238
+ for line in lines:
239
+ line = line.strip()
240
+ if not line or line.startswith('#'):
241
+ continue
242
+
243
+ # Git URLs - UV handles these perfectly
244
+ if line.startswith('git+') or 'git+' in line:
245
+ categorized['git_urls'].append(line)
246
+
247
+ # Direct URLs (http/https/file)
248
+ elif any(proto in line for proto in ['http://', 'https://', 'file://']):
249
+ package_name, url, marker = self._parse_url_requirement(line)
250
+
251
+ if package_name in url_packages:
252
+ # Multiple URLs for same package - needs special handling
253
+ if package_name not in categorized['multi_url_packages']:
254
+ # Move the first occurrence from single_urls to multi_url
255
+ categorized['multi_url_packages'][package_name] = [url_packages[package_name]]
256
+ # Remove from single_urls
257
+ categorized['single_urls'] = [
258
+ line for line in categorized['single_urls']
259
+ if url_packages[package_name]['url'] not in line
260
+ ]
261
+ categorized['multi_url_packages'][package_name].append({
262
+ 'url': url,
263
+ 'marker': marker
264
+ })
265
+ else:
266
+ # First URL for this package
267
+ url_packages[package_name] = {'url': url, 'marker': marker}
268
+ categorized['single_urls'].append(line)
269
+
270
+ # Regular package specifications
271
+ else:
272
+ categorized['regular_lines'].append(line)
273
+
274
+ return categorized
275
+
276
+ def _parse_url_requirement(self, line: str) -> tuple[str, str, str | None]:
277
+ """Parse a URL requirement line. Returns (package_name, url, marker)."""
278
+ marker = None
279
+
280
+ # Check for environment marker
281
+ if ';' in line:
282
+ url_part, marker_part = line.split(';', 1)
283
+ marker = self._translate_marker(marker_part.strip())
284
+ else:
285
+ url_part = line
286
+
287
+ # Handle "package @ url" format
288
+ if ' @ ' in url_part:
289
+ package_name, url = url_part.split(' @ ', 1)
290
+ package_name = package_name.strip()
291
+ url = url.strip()
292
+ else:
293
+ # Extract from URL
294
+ url = url_part.strip()
295
+ package_name = self._extract_package_from_url(url)
296
+
297
+ return package_name, url, marker
298
+
299
+ def _extract_package_from_url(self, url: str) -> str:
300
+ """Extract package name from various URL types."""
301
+ # Remove URL parameters
302
+ url = url.split('?')[0].split('#')[0]
303
+
304
+ # Get filename
305
+ filename = url.split('/')[-1]
306
+
307
+ if filename.endswith('.whl'):
308
+ # Wheel format: {distribution}-{version}[-{build tag}]-{python}-{abi}-{platform}.whl
309
+ parts = filename[:-4].split('-') # Remove .whl
310
+ package_name = parts[0]
311
+ elif filename.endswith(('.tar.gz', '.zip', '.tar.bz2')):
312
+ # Source dist format: {name}-{version}.tar.gz
313
+ if filename.endswith('.tar.gz'):
314
+ base = filename[:-7]
315
+ elif filename.endswith('.tar.bz2'):
316
+ base = filename[:-8]
317
+ else:
318
+ base = filename[:-4]
319
+ # Split on last hyphen to separate name from version
320
+ parts = base.rsplit('-', 1)
321
+ package_name = parts[0] if parts else base
322
+ else:
323
+ # Fallback: use filename without extension
324
+ package_name = filename.split('.')[0]
325
+
326
+ # Normalize: convert underscores to hyphens (PEP 503)
327
+ return package_name.replace('_', '-').lower()
328
+
329
+ def _translate_marker(self, marker: str) -> str:
330
+ """Translate requirements.txt markers to pyproject.toml format."""
331
+ result = marker
332
+
333
+ # Apply translations from class constant
334
+ for old, new in self.MARKER_TRANSLATIONS.items():
335
+ result = result.replace(old, new)
336
+
337
+ # Normalize all remaining double quotes to single quotes
338
+ result = result.replace('"', "'")
339
+
340
+ return result
341
+
342
+ def _add_url_sources_with_markers(self, package_name: str, urls_with_markers: list[dict],
343
+ group: str | None = None) -> None:
344
+ """Update [tool.uv.sources] and optionally add to dependency group."""
345
+ self.pyproject.uv_config.add_url_sources(package_name, urls_with_markers, group)
346
+ logger.info(f"Added {len(urls_with_markers)} URL source(s) for '{package_name}'")
347
+
348
+ # ===== Constraint and Index Management =====
349
+
350
+ def add_constraint_dependency(self, package: str) -> None:
351
+ """Add a constraint dependency to the project's pyproject.toml."""
352
+ self.pyproject.uv_config.add_constraint(package)
353
+ logger.info(f"Added constraint: {package}")
354
+
355
+ def create_index(self, name: str, url: str, explicit: bool = True) -> None:
356
+ """Create a new index in the project's pyproject.toml."""
357
+ self.pyproject.uv_config.add_index(name, url, explicit)
358
+ logger.info(f"Created index '{name}'")
359
+
360
+ def add_source_index(self, package_name: str, index: str) -> None:
361
+ """Add a source index mapping for a package in pyproject.toml."""
362
+ # Validate that the index exists
363
+ indexes = self.pyproject.uv_config.get_indexes()
364
+ if not any(idx.get('name') == index for idx in indexes):
365
+ raise CDPyprojectError(f"Index '{index}' does not exist. Please create it first using create_index()")
366
+
367
+ self.pyproject.uv_config.add_source(package_name, {'index': index})
368
+ logger.info(f"Added source for '{package_name}': index = '{index}'")
369
+
370
+ def remove_dependency_group(self, group_name: str) -> None:
371
+ """Remove a dependency group from pyproject.toml."""
372
+ self.pyproject.dependencies.remove_group(group_name)
373
+ logger.info(f"Removed dependency group: {group_name}")
374
+
375
+ # ===== Package Operations =====
376
+
377
+ def install_packages(self, packages: list[str] | None = None, requirements_file: Path | None = None,
378
+ python: Path | None = None, torch_backend: str | None = None,
379
+ verbose: bool = False, **flags) -> str:
380
+ """Install packages using uv pip install."""
381
+ result = self.uv.pip_install(
382
+ packages=packages,
383
+ requirements_file=requirements_file,
384
+ python=python,
385
+ torch_backend=torch_backend,
386
+ verbose=verbose,
387
+ **flags
388
+ )
389
+ return result.stdout
390
+
391
+ def show_package(self, package: str, python: Path) -> str:
392
+ """Show package information."""
393
+ result = self.uv.pip_show(package, python)
394
+ return result.stdout
395
+
396
+ def list_packages(self, python: Path) -> str:
397
+ """List installed packages."""
398
+ result = self.uv.pip_list(python)
399
+ return result.stdout
400
+
401
+ def uninstall_packages(self, packages: list[str], python: Path) -> str:
402
+ """Uninstall packages."""
403
+ result = self.uv.pip_install(packages=[f"-{pkg}" for pkg in packages], python=python)
404
+ return result.stdout
405
+
406
+ def freeze_packages(self, python: Path) -> str:
407
+ """Export installed packages in requirements format."""
408
+ result = self.uv.pip_freeze(python)
409
+ return result.stdout
410
+
411
+ def pip_compile(self, in_requirements_file: Path | None = None,
412
+ out_requirements_file: Path | None = None, **flags) -> str:
413
+ """Compile dependencies to requirements format."""
414
+ # Check if we're compiling a requirements file or a pyproject.toml
415
+ if in_requirements_file and in_requirements_file.exists():
416
+ input_file = in_requirements_file
417
+ else:
418
+ input_file = self.pyproject.path
419
+
420
+ # Add dependency groups if compiling pyproject.toml
421
+ if self.pyproject.exists():
422
+ try:
423
+ dependency_groups = self.pyproject.dependencies.get_groups()
424
+ for group_name in dependency_groups.keys():
425
+ flags.setdefault('group', []).append(group_name) if 'group' in flags else None
426
+ except Exception as e:
427
+ logger.debug(f"Could not get dependency groups: {e}")
428
+
429
+ result = self.uv.pip_compile(
430
+ input_file=input_file,
431
+ output_file=out_requirements_file,
432
+ **flags
433
+ )
434
+ return result.stdout
435
+
436
+ # ===== Tool Management =====
437
+
438
+ def run_tool(self, tool: str, args: list[str] | None = None) -> str:
439
+ """Run a tool in an isolated environment using uvx."""
440
+ result = self.uv.tool_run(tool, args)
441
+ return result.stdout
442
+
443
+ def install_tool(self, tool: str) -> str:
444
+ """Install a tool globally."""
445
+ result = self.uv.tool_install(tool)
446
+ return result.stdout
447
+
448
+ # ===== Python Management =====
449
+
450
+ def install_python(self, version: str) -> str:
451
+ """Install a Python version."""
452
+ result = self.uv.python_install(version)
453
+ return result.stdout
454
+
455
+ def list_python_versions(self) -> str:
456
+ """List available Python versions."""
457
+ result = self.uv.python_list()
458
+ return result.stdout
459
+
460
+ # ===== Advanced Sync Operations =====
461
+
462
+ def sync_dependencies_progressive(
463
+ self,
464
+ dry_run: bool = False,
465
+ callbacks = None,
466
+ verbose: bool = False
467
+ ) -> dict:
468
+ """Install dependencies progressively with graceful optional group handling.
469
+
470
+ Installs dependencies in phases:
471
+ 1. Base dependencies + all groups together with iterative optional group removal on failure
472
+
473
+ If optional groups fail to build, we iteratively:
474
+ - Parse the error to identify the failing group
475
+ - Remove that group from pyproject.toml
476
+ - Delete uv.lock to force re-resolution
477
+ - Retry the sync with all remaining groups
478
+ - Continue until success or max retries
479
+
480
+ Args:
481
+ dry_run: If True, don't actually install
482
+ callbacks: Optional callbacks for progress reporting
483
+ verbose: If True, show uv output in real-time
484
+
485
+ Returns:
486
+ Dict with keys:
487
+ - packages_synced: bool
488
+ - dependency_groups_installed: list[str]
489
+ - dependency_groups_failed: list[tuple[str, str]]
490
+ """
491
+ from ..constants import MAX_OPT_GROUP_RETRIES
492
+ from ..utils.uv_error_handler import parse_failed_dependency_group
493
+
494
+ result = {
495
+ "packages_synced": False,
496
+ "dependency_groups_installed": [],
497
+ "dependency_groups_failed": []
498
+ }
499
+
500
+ attempts = 0
501
+
502
+ logger.info("Installing dependencies with all groups...")
503
+
504
+ while attempts < MAX_OPT_GROUP_RETRIES:
505
+ try:
506
+ # Get all dependency groups (may have changed after removal)
507
+ dep_groups = self.pyproject.dependencies.get_groups()
508
+
509
+ if dep_groups:
510
+ # Install base + all groups together
511
+ group_list = list(dep_groups.keys())
512
+ logger.debug(f"Syncing with groups: {group_list}")
513
+ self.sync_project(group=group_list, dry_run=dry_run, verbose=verbose)
514
+
515
+ # Track successful installations
516
+ result["dependency_groups_installed"].extend(group_list)
517
+ else:
518
+ # No groups - just sync base dependencies
519
+ logger.debug("No dependency groups, syncing base only")
520
+ self.sync_project(dry_run=dry_run, no_default_groups=True, verbose=verbose)
521
+
522
+ result["packages_synced"] = True
523
+ break # Success - exit loop
524
+
525
+ except UVCommandError as e:
526
+ failed_group = parse_failed_dependency_group(e.stderr or "")
527
+
528
+ if failed_group and failed_group.startswith('optional-'):
529
+ attempts += 1
530
+ logger.warning(
531
+ f"Build failed for optional group '{failed_group}' (attempt {attempts}/{MAX_OPT_GROUP_RETRIES}), "
532
+ "removing and retrying..."
533
+ )
534
+
535
+ # Remove the problematic group
536
+ try:
537
+ self.pyproject.dependencies.remove_group(failed_group)
538
+ except ValueError:
539
+ pass # Group already gone
540
+
541
+ # Delete lockfile to force re-resolution
542
+ lockfile = self.project_path / "uv.lock"
543
+ if lockfile.exists():
544
+ lockfile.unlink()
545
+ logger.debug("Deleted uv.lock to force re-resolution")
546
+
547
+ result["dependency_groups_failed"].append((failed_group, "Build failed (incompatible platform)"))
548
+
549
+ if callbacks:
550
+ callbacks.on_dependency_group_complete(failed_group, success=False, error="Build failed - removed")
551
+
552
+ if attempts >= MAX_OPT_GROUP_RETRIES:
553
+ raise RuntimeError(
554
+ f"Failed to install dependencies after {MAX_OPT_GROUP_RETRIES} attempts. "
555
+ f"Removed groups: {[g for g, _ in result['dependency_groups_failed']]}"
556
+ )
557
+
558
+ # Loop continues for retry
559
+ else:
560
+ # Not an optional group failure - fail immediately
561
+ raise
562
+
563
+ return result
564
+
565
+ # ===== Utility =====
566
+
567
+ def version(self) -> str:
568
+ """Get the installed UV version."""
569
+ return self.uv.version()