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,161 @@
1
+ """Manages dynamic registry data fetching and caching."""
2
+ import json
3
+ import time
4
+ from pathlib import Path
5
+ from typing import Optional
6
+ from urllib.request import urlopen, Request
7
+ from urllib.error import URLError
8
+ import socket
9
+
10
+ from ..constants import GITHUB_NODE_MAPPINGS_URL, MAX_REGISTRY_DATA_AGE_HOURS
11
+ from ..logging.logging_config import get_logger
12
+
13
+ logger = get_logger(__name__)
14
+
15
+
16
+ class RegistryDataManager:
17
+ """Manages fetching and caching of registry node mappings."""
18
+
19
+ MAX_AGE_HOURS = MAX_REGISTRY_DATA_AGE_HOURS
20
+ FETCH_TIMEOUT = 10 # seconds
21
+
22
+ def __init__(self, cache_dir: Path):
23
+ """Initialize with cache directory.
24
+
25
+ Args:
26
+ cache_dir: Directory to store cached registry data
27
+ """
28
+ self.cache_dir = cache_dir
29
+ self.registry_dir = cache_dir / "registry"
30
+ self.custom_nodes_dir = cache_dir / "custom_nodes"
31
+ self.mappings_file = self.custom_nodes_dir / "node_mappings.json"
32
+ self.metadata_file = self.registry_dir / "metadata.json"
33
+
34
+ # Ensure directories exist
35
+ self.registry_dir.mkdir(parents=True, exist_ok=True)
36
+ self.custom_nodes_dir.mkdir(parents=True, exist_ok=True)
37
+
38
+ def get_mappings_path(self) -> Path:
39
+ """Get path to node mappings file, fetching if needed.
40
+
41
+ Returns:
42
+ Path to node_mappings.json (may be stale if fetch fails)
43
+ """
44
+ # If no file exists, we MUST fetch
45
+ if not self.mappings_file.exists():
46
+ logger.info("No cached registry data found, fetching...")
47
+ if self._fetch_mappings():
48
+ logger.info("Successfully fetched registry data")
49
+ else:
50
+ logger.error("Failed to fetch registry data - no mappings available")
51
+ return self.mappings_file # Return path even if doesn't exist
52
+
53
+ # If file exists but is stale, try to update (non-blocking)
54
+ elif self._is_stale():
55
+ logger.debug("Registry data is stale, attempting refresh...")
56
+ if self._fetch_mappings():
57
+ logger.info("Updated registry data")
58
+ else:
59
+ logger.warning("Using stale registry data (update failed)")
60
+
61
+ return self.mappings_file
62
+
63
+ def _is_stale(self) -> bool:
64
+ """Check if cached data is older than MAX_AGE_HOURS."""
65
+ if not self.mappings_file.exists():
66
+ return True
67
+
68
+ age_seconds = time.time() - self.mappings_file.stat().st_mtime
69
+ age_hours = age_seconds / 3600
70
+ return age_hours > self.MAX_AGE_HOURS
71
+
72
+ def _fetch_mappings(self) -> bool:
73
+ """Fetch latest mappings from GitHub.
74
+
75
+ Returns:
76
+ True if successful, False otherwise
77
+ """
78
+ try:
79
+ # Create request with timeout
80
+ req = Request(GITHUB_NODE_MAPPINGS_URL, headers={
81
+ 'User-Agent': 'ComfyDock/1.0',
82
+ 'Accept': 'application/json'
83
+ })
84
+
85
+ # Download with timeout
86
+ with urlopen(req, timeout=self.FETCH_TIMEOUT) as response:
87
+ data = response.read()
88
+
89
+ # Parse to validate JSON
90
+ mappings = json.loads(data)
91
+
92
+ # Write atomically (temp file + rename)
93
+ temp_file = self.mappings_file.with_suffix('.tmp')
94
+ temp_file.write_bytes(data)
95
+ temp_file.rename(self.mappings_file)
96
+
97
+ # Update metadata
98
+ self._write_metadata({
99
+ 'updated_at': time.time(),
100
+ 'version': mappings.get('version', 'unknown'),
101
+ 'stats': mappings.get('stats', {})
102
+ })
103
+
104
+ return True
105
+
106
+ except (URLError, socket.timeout) as e:
107
+ logger.debug(f"Network error fetching registry data: {e}")
108
+ return False
109
+ except json.JSONDecodeError as e:
110
+ logger.error(f"Invalid JSON in registry response: {e}")
111
+ return False
112
+ except Exception as e:
113
+ logger.error(f"Unexpected error fetching registry data: {e}")
114
+ return False
115
+
116
+ def _write_metadata(self, metadata: dict) -> None:
117
+ """Write metadata about the cached data."""
118
+ try:
119
+ with open(self.metadata_file, 'w', encoding='utf-8') as f:
120
+ json.dump(metadata, f, indent=2)
121
+ except Exception as e:
122
+ logger.debug(f"Failed to write metadata: {e}")
123
+
124
+ def force_update(self) -> bool:
125
+ """Force fetch latest mappings.
126
+
127
+ Returns:
128
+ True if successful
129
+ """
130
+ logger.info("Force updating registry data...")
131
+ return self._fetch_mappings()
132
+
133
+ def get_cache_info(self) -> dict:
134
+ """Get information about cached data.
135
+
136
+ Returns:
137
+ Dict with cache status and metadata
138
+ """
139
+ info = {
140
+ 'exists': self.mappings_file.exists(),
141
+ 'path': str(self.mappings_file),
142
+ 'stale': False,
143
+ 'age_hours': None,
144
+ 'version': None
145
+ }
146
+
147
+ if self.mappings_file.exists():
148
+ age_seconds = time.time() - self.mappings_file.stat().st_mtime
149
+ age_hours = age_seconds / 3600
150
+ info['age_hours'] = round(age_hours, 1)
151
+ info['stale'] = age_hours > self.MAX_AGE_HOURS
152
+
153
+ if self.metadata_file.exists():
154
+ try:
155
+ with open(self.metadata_file, encoding='utf-8') as f:
156
+ metadata = json.load(f)
157
+ info['version'] = metadata.get('version')
158
+ except:
159
+ pass
160
+
161
+ return info
@@ -0,0 +1,4 @@
1
+ """Resolution strategies for workflow dependencies."""
2
+ from .auto import AutoModelStrategy, AutoNodeStrategy
3
+
4
+ __all__ = ['AutoNodeStrategy', 'AutoModelStrategy']
@@ -0,0 +1,72 @@
1
+ """Auto resolution strategies for workflow dependencies."""
2
+
3
+ from __future__ import annotations
4
+ from typing import TYPE_CHECKING
5
+
6
+ from comfygit_core.models.protocols import ModelResolutionStrategy, NodeResolutionStrategy
7
+
8
+ from ..models.workflow import ModelResolutionContext, NodeResolutionContext, ResolvedModel, WorkflowNodeWidgetRef
9
+
10
+ if TYPE_CHECKING:
11
+ from ..models.workflow import ResolvedNodePackage
12
+
13
+
14
+ class AutoNodeStrategy(NodeResolutionStrategy):
15
+ """Automatic node resolution - makes best effort choices without user input."""
16
+
17
+ def resolve_unknown_node(
18
+ self,
19
+ node_type: str,
20
+ possible: list[ResolvedNodePackage],
21
+ context: "NodeResolutionContext"
22
+ ) -> ResolvedNodePackage | None:
23
+ """Pick the top suggestion by confidence, or first if tied.
24
+
25
+ Args:
26
+ node_type: The unknown node type
27
+ possible: List of possible package matches
28
+ context: Resolution context (unused in auto mode)
29
+
30
+ Returns:
31
+ ResolvedNodePackage or None if no candidates
32
+ """
33
+ if not possible:
34
+ return None
35
+
36
+ # Sort by confidence descending, then just pick first
37
+ sorted_suggestions = sorted(
38
+ possible, key=lambda s: s.match_confidence, reverse=True
39
+ )
40
+
41
+ return sorted_suggestions[0]
42
+
43
+ def confirm_node_install(self, package: ResolvedNodePackage) -> bool:
44
+ """Always confirm - we're making automated choices."""
45
+ return True
46
+
47
+
48
+ class AutoModelStrategy(ModelResolutionStrategy):
49
+ """Automatic model resolution - makes simple naive choices."""
50
+
51
+ def resolve_model(
52
+ self,
53
+ reference: WorkflowNodeWidgetRef,
54
+ candidates: list[ResolvedModel],
55
+ context: ModelResolutionContext,
56
+ ) -> ResolvedModel | None:
57
+ """Pick the first candidate, or skip if none available.
58
+
59
+ Args:
60
+ reference: The model reference from workflow
61
+ candidates: List of potential matches (may be empty for missing models)
62
+ context: Resolution context with search function and workflow info
63
+
64
+ Returns:
65
+ First candidate as ResolvedModel, or None to skip
66
+ """
67
+ if not candidates:
68
+ # No candidates - skip (don't mark as optional)
69
+ return None
70
+
71
+ # Return first candidate (already a ResolvedModel)
72
+ return candidates[0]
@@ -0,0 +1,69 @@
1
+ """Confirmation strategies for user interaction."""
2
+
3
+ from abc import ABC, abstractmethod
4
+
5
+
6
+ class ConfirmationStrategy(ABC):
7
+ """Strategy for confirming user actions."""
8
+
9
+ @abstractmethod
10
+ def confirm_update(self, node_name: str, current_version: str, new_version: str) -> bool:
11
+ """Ask user to confirm a node update.
12
+
13
+ Args:
14
+ node_name: Name of the node
15
+ current_version: Current version
16
+ new_version: New version
17
+
18
+ Returns:
19
+ True if user confirms, False otherwise
20
+ """
21
+ pass
22
+
23
+ def confirm_replace_dev_node(self, node_name: str, current_version: str, new_version: str) -> bool:
24
+ """Ask user to confirm replacing a development node.
25
+
26
+ Args:
27
+ node_name: Name of the development node
28
+ current_version: Current version ('dev')
29
+ new_version: New version to install
30
+
31
+ Returns:
32
+ True if user confirms, False otherwise
33
+ """
34
+ # Default: use confirm_update for backward compatibility
35
+ return self.confirm_update(node_name, current_version, new_version)
36
+
37
+
38
+ class AutoConfirmStrategy(ConfirmationStrategy):
39
+ """Always confirm without asking."""
40
+
41
+ def confirm_update(self, node_name: str, current_version: str, new_version: str) -> bool:
42
+ return True
43
+
44
+
45
+ class InteractiveConfirmStrategy(ConfirmationStrategy):
46
+ """Ask user interactively via CLI."""
47
+
48
+ def confirm_update(self, node_name: str, current_version: str, new_version: str) -> bool:
49
+ response = input(
50
+ f"Update '{node_name}' from {current_version} → {new_version}? (Y/n): "
51
+ )
52
+ return response.lower() != 'n'
53
+
54
+ def confirm_replace_dev_node(self, node_name: str, current_version: str, new_version: str) -> bool:
55
+ """Ask user to confirm replacing a development node.
56
+
57
+ Args:
58
+ node_name: Name of the development node
59
+ current_version: Current version ('dev')
60
+ new_version: New version to install
61
+
62
+ Returns:
63
+ True if user confirms, False otherwise
64
+ """
65
+ print(f"⚠️ '{node_name}' is a development node (local changes may exist)")
66
+ response = input(
67
+ f"Replace with registry version {new_version}? This will delete local changes. (y/N): "
68
+ )
69
+ return response.lower() == 'y'
@@ -0,0 +1,125 @@
1
+ from pathlib import Path
2
+
3
+ from ..logging.logging_config import get_logger
4
+ from .common import run_command
5
+ from .git import git_clone
6
+
7
+ logger = get_logger(__name__)
8
+
9
+
10
+ def validate_comfyui_installation(comfyui_path: Path) -> bool:
11
+ """Check if a directory contains a valid ComfyUI installation.
12
+
13
+ Args:
14
+ comfyui_path: Path to check
15
+
16
+ Returns:
17
+ True if valid ComfyUI installation, False otherwise
18
+ """
19
+ # Check for essential ComfyUI files
20
+ required_files = ["main.py", "nodes.py", "folder_paths.py"]
21
+
22
+ for file in required_files:
23
+ if not (comfyui_path / file).exists():
24
+ return False
25
+
26
+ # Check for essential directories
27
+ required_dirs = ["comfy", "models"]
28
+
29
+ for dir_name in required_dirs:
30
+ if not (comfyui_path / dir_name).is_dir():
31
+ return False
32
+
33
+ return True
34
+
35
+
36
+ def get_comfyui_version(comfyui_path: Path) -> str:
37
+ """Detect ComfyUI version from git tags."""
38
+ comfyui_version = "unknown"
39
+ try:
40
+ git_dir = comfyui_path / ".git"
41
+ if git_dir.exists():
42
+ result = run_command(
43
+ ["git", "describe", "--tags", "--always"], cwd=comfyui_path
44
+ )
45
+ if result.returncode == 0:
46
+ comfyui_version = result.stdout.strip()
47
+ except Exception as e:
48
+ logger.debug(f"Could not detect ComfyUI version from {comfyui_path}: {e}")
49
+
50
+ return comfyui_version
51
+
52
+
53
+ def resolve_comfyui_version(
54
+ version_spec: str | None,
55
+ github_client
56
+ ) -> tuple[str, str, str | None]:
57
+ """Resolve version specification to concrete version.
58
+
59
+ Args:
60
+ version_spec: User input ("latest", "v0.3.20", "abc123", "main", None)
61
+ github_client: GitHub client for API calls
62
+
63
+ Returns:
64
+ Tuple of (version_to_clone, version_type, commit_sha)
65
+ - version_to_clone: What to pass to git clone
66
+ - version_type: "release" | "commit" | "branch"
67
+ - commit_sha: Actual commit SHA (None if not yet cloned)
68
+
69
+ Examples:
70
+ None → ("v0.3.20", "release", None) # Latest release
71
+ "latest" → ("v0.3.20", "release", None)
72
+ "v0.3.15" → ("v0.3.15", "release", None)
73
+ "abc123" → ("abc123", "commit", None)
74
+ "master" → ("master", "branch", None)
75
+ """
76
+ COMFYUI_REPO = "https://github.com/comfyanonymous/ComfyUI.git"
77
+
78
+ # Handle None or "latest" - fetch latest release
79
+ if version_spec is None or version_spec == "latest":
80
+ repo_info = github_client.get_repository_info(COMFYUI_REPO)
81
+ if repo_info and repo_info.latest_release:
82
+ return (repo_info.latest_release, "release", None)
83
+ else:
84
+ logger.warning("No releases found, falling back to master branch")
85
+ return ("master", "branch", None)
86
+
87
+ # Handle release tags (starts with 'v')
88
+ if version_spec.startswith('v'):
89
+ # Validate release exists
90
+ if github_client.validate_version_exists(COMFYUI_REPO, version_spec):
91
+ return (version_spec, "release", None)
92
+ else:
93
+ logger.warning(f"Release {version_spec} not found on GitHub")
94
+ raise ValueError(f"ComfyUI release {version_spec} does not exist")
95
+
96
+ # Handle branch alias (ComfyUI only has master branch)
97
+ if version_spec == "master":
98
+ return (version_spec, "branch", None)
99
+
100
+ # Assume commit hash
101
+ return (version_spec, "commit", None)
102
+
103
+
104
+ def clone_comfyui(target_path: Path, version: str | None = None) -> str | None:
105
+ """Clone ComfyUI repository to a target path.
106
+
107
+ Args:
108
+ target_path: Where to clone ComfyUI
109
+ version: Optional specific version/tag/commit to checkout
110
+
111
+ Returns:
112
+ ComfyUI version string (commit hash or tag)
113
+
114
+ Raises:
115
+ RuntimeError: If cloning fails
116
+ """
117
+ # Clone the repository with shallow clone for speed
118
+ git_clone(
119
+ "https://github.com/comfyanonymous/ComfyUI.git",
120
+ target_path,
121
+ depth=1,
122
+ ref=version,
123
+ timeout=60,
124
+ )
125
+ return get_comfyui_version(target_path)
@@ -0,0 +1,164 @@
1
+ """Common utilities for ComfyUI Environment Capture."""
2
+
3
+ import re
4
+ import subprocess
5
+ from pathlib import Path
6
+
7
+ from ..logging.logging_config import get_logger
8
+ from ..models.exceptions import CDProcessError
9
+
10
+ logger = get_logger(__name__)
11
+
12
+
13
+ def run_command(
14
+ cmd: list[str],
15
+ cwd: Path | None = None,
16
+ timeout: int | None = 30,
17
+ capture_output: bool = True,
18
+ text: bool = True,
19
+ check: bool = False,
20
+ env: dict | None = None
21
+ ) -> subprocess.CompletedProcess:
22
+ """Run a subprocess command with proper error handling.
23
+
24
+ Args:
25
+ cmd: Command and arguments as a list
26
+ cwd: Working directory for the command
27
+ timeout: Command timeout in seconds
28
+ capture_output: Whether to capture stdout/stderr
29
+ text: Whether to decode output as text
30
+ check: Whether to raise exception on non-zero exit code
31
+ env: Environment variables to pass to subprocess
32
+
33
+ Returns:
34
+ CompletedProcess instance
35
+
36
+ Raises:
37
+ CDProcessError: If command fails and check=True
38
+ subprocess.TimeoutExpired: If command times out
39
+ """
40
+ try:
41
+ logger.debug(f"Running command: {' '.join(cmd)}")
42
+ if cwd:
43
+ logger.debug(f"Working directory: {cwd}")
44
+
45
+ result = subprocess.run(
46
+ cmd,
47
+ cwd=str(cwd) if cwd else None,
48
+ check=check,
49
+ timeout=timeout,
50
+ capture_output=capture_output,
51
+ text=text,
52
+ env=env
53
+ )
54
+
55
+ return result
56
+
57
+ except subprocess.CalledProcessError as e:
58
+ # Transform CalledProcessError into our custom exception
59
+ error_msg = f"Command failed with exit code {e.returncode}: {' '.join(cmd)}"
60
+ if e.stderr:
61
+ error_msg += f"\nStderr: {e.stderr}"
62
+ logger.error(error_msg)
63
+ raise CDProcessError(
64
+ message=error_msg,
65
+ command=cmd,
66
+ stderr=e.stderr,
67
+ stdout=e.stdout,
68
+ returncode=e.returncode
69
+ )
70
+ except subprocess.TimeoutExpired:
71
+ error_msg = f"Command timed out after {timeout}s: {' '.join(cmd)}"
72
+ logger.error(error_msg)
73
+ # Let TimeoutExpired propagate - it's specific and useful
74
+ raise
75
+ except Exception as e:
76
+ error_msg = f"Error running command {' '.join(cmd)}: {e}"
77
+ logger.error(error_msg)
78
+ raise
79
+
80
+ def format_size(size_bytes: int) -> str:
81
+ """Format a size in bytes as human-readable string.
82
+
83
+ Args:
84
+ size_bytes: Size in bytes
85
+
86
+ Returns:
87
+ Human-readable size string (e.g., "1.5 MB")
88
+ """
89
+ if size_bytes == 0:
90
+ return "0 B"
91
+
92
+ size_names = ["B", "KB", "MB", "GB", "TB"]
93
+ size_index = 0
94
+ size = float(size_bytes)
95
+
96
+ while size >= 1024.0 and size_index < len(size_names) - 1:
97
+ size /= 1024.0
98
+ size_index += 1
99
+
100
+ if size_index == 0:
101
+ return f"{int(size)} {size_names[size_index]}"
102
+ else:
103
+ return f"{size:.1f} {size_names[size_index]}"
104
+
105
+
106
+ def log_pyproject_content(pyproject_path: Path, context: str = "") -> None:
107
+ """Log pyproject.toml content in a nicely formatted way.
108
+
109
+ Args:
110
+ pyproject_path: Path to pyproject.toml file
111
+ context: Optional context string for the log message
112
+ """
113
+ try:
114
+ content = pyproject_path.read_text(encoding='utf-8')
115
+ # Add indentation to each line for nice formatting
116
+ indented_lines = ['' + line for line in content.split('\n')]
117
+ formatted_content = '\n'.join(indented_lines)
118
+
119
+ # Create the log message with separator lines
120
+ separator = '-' * 60
121
+ if context:
122
+ msg = f"{context}:\n{separator}\n{formatted_content}\n{separator}"
123
+ else:
124
+ msg = f"pyproject.toml content:\n{separator}\n{formatted_content}\n{separator}"
125
+
126
+ # Log as a single INFO message (change to DEBUG if too verbose)
127
+ logger.info(msg)
128
+ except Exception as e:
129
+ logger.debug(f"Could not log pyproject.toml: {e}")
130
+
131
+ def log_requirements_content(requirements_file: Path, show_all: bool = True) -> None:
132
+ """Log the compiled requirements file content.
133
+
134
+ Args:
135
+ requirements_file: Path to the compiled requirements file
136
+ show_all: If True, show all lines, otherwise show a summary
137
+ """
138
+ try:
139
+ content = requirements_file.read_text(encoding='utf-8')
140
+ lines = content.split('\n')
141
+
142
+ # Count non-comment, non-empty lines
143
+ package_lines = [l for l in lines if l.strip() and not l.strip().startswith('#')]
144
+
145
+ #
146
+ separator = '=' * 60
147
+ msg_lines = [
148
+ f"Compiled requirements file ({len(package_lines)} packages):",
149
+ separator
150
+ ]
151
+
152
+ # Show first 10 and last 5 packages if there are many
153
+ if len(lines) > 50 and not show_all:
154
+ msg_lines.extend(lines[:30])
155
+ msg_lines.append(f"... ({len(lines) - 35} more lines) ...")
156
+ msg_lines.extend(lines[-5:])
157
+ else:
158
+ msg_lines.extend(lines)
159
+
160
+ msg_lines.append(separator)
161
+
162
+ logger.info('\n'.join(msg_lines))
163
+ except Exception as e:
164
+ logger.debug(f"Could not log requirements file: {e}")