comfygit 0.3.1__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-0.3.1.dist-info/METADATA +654 -0
- comfygit-0.3.1.dist-info/RECORD +30 -0
- comfygit-0.3.1.dist-info/WHEEL +4 -0
- comfygit-0.3.1.dist-info/entry_points.txt +3 -0
- comfygit-0.3.1.dist-info/licenses/LICENSE.txt +661 -0
- comfygit_cli/__init__.py +12 -0
- comfygit_cli/__main__.py +6 -0
- comfygit_cli/cli.py +704 -0
- comfygit_cli/cli_utils.py +32 -0
- comfygit_cli/completers.py +239 -0
- comfygit_cli/completion_commands.py +246 -0
- comfygit_cli/env_commands.py +2701 -0
- comfygit_cli/formatters/__init__.py +5 -0
- comfygit_cli/formatters/error_formatter.py +141 -0
- comfygit_cli/global_commands.py +1806 -0
- comfygit_cli/interactive/__init__.py +1 -0
- comfygit_cli/logging/compressed_handler.py +150 -0
- comfygit_cli/logging/environment_logger.py +554 -0
- comfygit_cli/logging/log_compressor.py +101 -0
- comfygit_cli/logging/logging_config.py +97 -0
- comfygit_cli/resolution_strategies.py +89 -0
- comfygit_cli/strategies/__init__.py +1 -0
- comfygit_cli/strategies/conflict_resolver.py +113 -0
- comfygit_cli/strategies/interactive.py +843 -0
- comfygit_cli/strategies/rollback.py +40 -0
- comfygit_cli/utils/__init__.py +12 -0
- comfygit_cli/utils/civitai_errors.py +9 -0
- comfygit_cli/utils/orchestrator.py +252 -0
- comfygit_cli/utils/pagination.py +82 -0
- comfygit_cli/utils/progress.py +128 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Real-time log compression for reducing token count in debug logs."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class LogCompressor:
|
|
8
|
+
"""Compresses log records in real-time while preserving semantic content."""
|
|
9
|
+
|
|
10
|
+
LOG_PATTERN = re.compile(
|
|
11
|
+
r'^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3}) - '
|
|
12
|
+
r'([^ ]+) - '
|
|
13
|
+
r'(\w+) - '
|
|
14
|
+
r'([^:]+):(\d+) - '
|
|
15
|
+
r'(.+)$'
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
def __init__(self, compression_level: str = 'medium'):
|
|
19
|
+
self.compression_level = compression_level
|
|
20
|
+
self.module_dict = {}
|
|
21
|
+
self.session_start = None
|
|
22
|
+
|
|
23
|
+
def _get_module_id(self, module: str) -> str:
|
|
24
|
+
"""Get or create short ID for module path."""
|
|
25
|
+
if module not in self.module_dict:
|
|
26
|
+
self.module_dict[module] = f"M{len(self.module_dict)}"
|
|
27
|
+
return self.module_dict[module]
|
|
28
|
+
|
|
29
|
+
def _format_delta(self, timestamp: datetime) -> str:
|
|
30
|
+
"""Format timestamp as delta from session start."""
|
|
31
|
+
if self.session_start is None:
|
|
32
|
+
self.session_start = timestamp
|
|
33
|
+
return "+0.000s"
|
|
34
|
+
|
|
35
|
+
delta = (timestamp - self.session_start).total_seconds()
|
|
36
|
+
return f"+{delta:.3f}s"
|
|
37
|
+
|
|
38
|
+
def compress_record(self, message: str) -> str:
|
|
39
|
+
"""Compress a single log record.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
message: Formatted log message from handler
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
Compressed version of the message
|
|
46
|
+
"""
|
|
47
|
+
# Try to parse structured log line
|
|
48
|
+
match = self.LOG_PATTERN.match(message)
|
|
49
|
+
if not match:
|
|
50
|
+
# Non-standard line (command separator, continuation, etc)
|
|
51
|
+
return message
|
|
52
|
+
|
|
53
|
+
ts_str, module, level, function, line_num, msg = match.groups()
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
timestamp = datetime.strptime(ts_str, '%Y-%m-%d %H:%M:%S,%f')
|
|
57
|
+
except ValueError:
|
|
58
|
+
# Fallback if timestamp parse fails
|
|
59
|
+
return message
|
|
60
|
+
|
|
61
|
+
# Always preserve errors and warnings with full module info
|
|
62
|
+
if level in ('ERROR', 'WARNING'):
|
|
63
|
+
delta = self._format_delta(timestamp)
|
|
64
|
+
return f"{delta} [{level}] {module} - {msg}"
|
|
65
|
+
|
|
66
|
+
# Command boundaries (separator lines)
|
|
67
|
+
if '====' in msg or '----' in msg:
|
|
68
|
+
return message
|
|
69
|
+
|
|
70
|
+
# For aggressive compression, skip some DEBUG details
|
|
71
|
+
if self.compression_level == 'aggressive' and level == 'DEBUG':
|
|
72
|
+
if 'Cache' in function or 'resolve_single_node' in function:
|
|
73
|
+
# Skip verbose cache/resolution internals
|
|
74
|
+
return ''
|
|
75
|
+
|
|
76
|
+
# Compress based on level
|
|
77
|
+
delta = self._format_delta(timestamp)
|
|
78
|
+
|
|
79
|
+
if self.compression_level == 'light':
|
|
80
|
+
# Light: Just delta + shortened module
|
|
81
|
+
mod_id = self._get_module_id(module)
|
|
82
|
+
return f"{delta} {mod_id}.{function} [{level}] {msg}"
|
|
83
|
+
|
|
84
|
+
# Medium/aggressive: more compression
|
|
85
|
+
if level == 'DEBUG':
|
|
86
|
+
mod_id = self._get_module_id(module)
|
|
87
|
+
return f"{delta} [{mod_id}] {msg}"
|
|
88
|
+
elif level == 'INFO':
|
|
89
|
+
return f"{delta} [INFO] {msg}"
|
|
90
|
+
else:
|
|
91
|
+
return f"{delta} [{level}] {msg}"
|
|
92
|
+
|
|
93
|
+
def get_dictionary(self) -> str:
|
|
94
|
+
"""Get module dictionary for appending to log file."""
|
|
95
|
+
if not self.module_dict:
|
|
96
|
+
return ""
|
|
97
|
+
|
|
98
|
+
lines = ["\n# Module Dictionary:"]
|
|
99
|
+
for module, mod_id in sorted(self.module_dict.items(), key=lambda x: x[1]):
|
|
100
|
+
lines.append(f"# {mod_id} = {module}")
|
|
101
|
+
return '\n'.join(lines) + '\n'
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Logging configuration for ComfyUI Environment Capture."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import logging.handlers
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
# Default log format
|
|
9
|
+
LOG_FORMAT = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
|
10
|
+
SIMPLE_FORMAT = "%(levelname)s - %(message)s"
|
|
11
|
+
DETAILED_FORMAT = "%(asctime)s - %(name)s - %(levelname)s - %(funcName)s:%(lineno)d - %(message)s"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def setup_logging(
|
|
15
|
+
level: str = "INFO",
|
|
16
|
+
log_file: Path | None = None,
|
|
17
|
+
simple_format: bool = False,
|
|
18
|
+
use_rotation: bool = True,
|
|
19
|
+
max_bytes: int = 10 * 1024 * 1024, # 10MB
|
|
20
|
+
backup_count: int = 5,
|
|
21
|
+
console_level: str | None = None,
|
|
22
|
+
file_level: str | None = None
|
|
23
|
+
) -> None:
|
|
24
|
+
"""Configure logging for the application.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
level: Default logging level (DEBUG, INFO, WARNING, ERROR)
|
|
28
|
+
log_file: Optional file path for logging output
|
|
29
|
+
simple_format: Use simple format for console output
|
|
30
|
+
use_rotation: Use rotating file handler for log files
|
|
31
|
+
max_bytes: Maximum size of each log file in bytes (default 10MB)
|
|
32
|
+
backup_count: Number of backup files to keep (default 5)
|
|
33
|
+
console_level: Override console logging level (defaults to 'level')
|
|
34
|
+
file_level: Override file logging level (defaults to DEBUG)
|
|
35
|
+
"""
|
|
36
|
+
default_level = getattr(logging, level.upper(), logging.INFO)
|
|
37
|
+
console_log_level = getattr(logging, (console_level or level).upper(), logging.INFO)
|
|
38
|
+
file_log_level = getattr(logging, (file_level or "DEBUG").upper(), logging.DEBUG)
|
|
39
|
+
|
|
40
|
+
# Root logger configuration - set to lowest of all levels
|
|
41
|
+
root_logger = logging.getLogger()
|
|
42
|
+
root_logger.setLevel(min(default_level, console_log_level, file_log_level))
|
|
43
|
+
|
|
44
|
+
# Remove existing handlers
|
|
45
|
+
root_logger.handlers.clear()
|
|
46
|
+
|
|
47
|
+
# Console handler
|
|
48
|
+
console_handler = logging.StreamHandler(sys.stdout)
|
|
49
|
+
console_handler.setLevel(console_log_level)
|
|
50
|
+
console_format = SIMPLE_FORMAT if simple_format else LOG_FORMAT
|
|
51
|
+
console_handler.setFormatter(logging.Formatter(console_format))
|
|
52
|
+
root_logger.addHandler(console_handler)
|
|
53
|
+
|
|
54
|
+
# File handler (if specified)
|
|
55
|
+
if log_file:
|
|
56
|
+
# Ensure log directory exists
|
|
57
|
+
log_file = Path(log_file)
|
|
58
|
+
log_file.parent.mkdir(parents=True, exist_ok=True)
|
|
59
|
+
|
|
60
|
+
if use_rotation:
|
|
61
|
+
# Use rotating file handler
|
|
62
|
+
file_handler = logging.handlers.RotatingFileHandler(
|
|
63
|
+
log_file,
|
|
64
|
+
maxBytes=max_bytes,
|
|
65
|
+
backupCount=backup_count,
|
|
66
|
+
encoding='utf-8'
|
|
67
|
+
)
|
|
68
|
+
else:
|
|
69
|
+
# Use regular file handler
|
|
70
|
+
file_handler = logging.FileHandler(log_file, encoding='utf-8')
|
|
71
|
+
|
|
72
|
+
file_handler.setLevel(file_log_level) # Use specified file log level
|
|
73
|
+
file_handler.setFormatter(logging.Formatter(DETAILED_FORMAT))
|
|
74
|
+
root_logger.addHandler(file_handler)
|
|
75
|
+
|
|
76
|
+
# Log the start of a new session
|
|
77
|
+
root_logger.debug("🟩" * 50)
|
|
78
|
+
root_logger.debug("=" * 80)
|
|
79
|
+
root_logger.debug("New logging session started")
|
|
80
|
+
root_logger.debug("=" * 80)
|
|
81
|
+
root_logger.debug("🟩" * 50)
|
|
82
|
+
|
|
83
|
+
# Set specific logger levels
|
|
84
|
+
logging.getLogger("urllib3").setLevel(logging.WARNING)
|
|
85
|
+
logging.getLogger("requests").setLevel(logging.WARNING)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def get_logger(name: str) -> logging.Logger:
|
|
89
|
+
"""Get a logger instance for the given module.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
name: Module name (typically __name__)
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
Configured logger instance
|
|
96
|
+
"""
|
|
97
|
+
return logging.getLogger(name)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Model resolution strategies for CLI interaction."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from comfygit_core.models.commit import ModelResolutionRequest
|
|
8
|
+
from comfygit_core.models.shared import ModelWithLocation
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class InteractiveModelResolver:
|
|
12
|
+
"""Handles interactive model resolution for CLI."""
|
|
13
|
+
|
|
14
|
+
def resolve_ambiguous_models(
|
|
15
|
+
self,
|
|
16
|
+
requests: list[ModelResolutionRequest]
|
|
17
|
+
) -> dict[str, ModelWithLocation]:
|
|
18
|
+
"""Interactively resolve ambiguous models.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
requests: List of models needing resolution
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
Dict mapping request key to selected model
|
|
25
|
+
"""
|
|
26
|
+
resolutions = {}
|
|
27
|
+
|
|
28
|
+
if not requests:
|
|
29
|
+
return resolutions
|
|
30
|
+
|
|
31
|
+
print(f"\n🤔 Found {len(requests)} models requiring confirmation:")
|
|
32
|
+
|
|
33
|
+
for req in requests:
|
|
34
|
+
print(f"\nWorkflow: {req.workflow_name}")
|
|
35
|
+
print(f"Node: {req.node_type} (ID: {req.node_id})")
|
|
36
|
+
print(f"Looking for: {req.original_value}")
|
|
37
|
+
print(f"Found {len(req.candidates)} potential matches:")
|
|
38
|
+
|
|
39
|
+
for i, model in enumerate(req.candidates, 1):
|
|
40
|
+
size_gb = model.file_size / (1024**3)
|
|
41
|
+
print(f" {i}. {model.relative_path}")
|
|
42
|
+
print(f" Size: {size_gb:.1f} GB | Hash: {model.hash}")
|
|
43
|
+
|
|
44
|
+
print(" 0. Skip this model")
|
|
45
|
+
|
|
46
|
+
while True:
|
|
47
|
+
choice = input(f"Select [1-{len(req.candidates)}] or 0: ").strip()
|
|
48
|
+
|
|
49
|
+
if choice == "0":
|
|
50
|
+
break # Skip this model
|
|
51
|
+
|
|
52
|
+
if choice.isdigit():
|
|
53
|
+
idx = int(choice) - 1
|
|
54
|
+
if 0 <= idx < len(req.candidates):
|
|
55
|
+
# Create unique key for request
|
|
56
|
+
req_key = f"{req.workflow_name}:{req.node_id}:{req.widget_index}"
|
|
57
|
+
resolutions[req_key] = req.candidates[idx]
|
|
58
|
+
print(f"✓ Selected: {req.candidates[idx].filename}")
|
|
59
|
+
break
|
|
60
|
+
|
|
61
|
+
print("Invalid choice, please try again.")
|
|
62
|
+
|
|
63
|
+
return resolutions
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class AutomaticModelResolver:
|
|
67
|
+
"""Automatically resolves models (for --no-interactive mode)."""
|
|
68
|
+
|
|
69
|
+
def resolve_ambiguous_models(
|
|
70
|
+
self,
|
|
71
|
+
requests: list[ModelResolutionRequest]
|
|
72
|
+
) -> dict[str, ModelWithLocation]:
|
|
73
|
+
"""Auto-resolve by picking first match or skipping.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
requests: List of models needing resolution
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
Dict mapping request key to selected model
|
|
80
|
+
"""
|
|
81
|
+
resolutions = {}
|
|
82
|
+
|
|
83
|
+
for req in requests:
|
|
84
|
+
if req.candidates:
|
|
85
|
+
# Pick first candidate automatically
|
|
86
|
+
req_key = f"{req.workflow_name}:{req.node_id}:{req.widget_index}"
|
|
87
|
+
resolutions[req_key] = req.candidates[0]
|
|
88
|
+
|
|
89
|
+
return resolutions
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Resolution strategy implementations for CLI."""
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Conflict resolution strategies for pull/merge operations.
|
|
2
|
+
|
|
3
|
+
Based on the Atomic Semantic Merge architecture:
|
|
4
|
+
- Only WORKFLOW conflicts are shown to users (nodes/deps are derived)
|
|
5
|
+
- NO skip option - every conflict must be resolved
|
|
6
|
+
- pyproject.toml conflicts are not shown (it's derived state)
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from typing import Literal
|
|
10
|
+
|
|
11
|
+
from comfygit_core.models.ref_diff import (
|
|
12
|
+
RefDiff,
|
|
13
|
+
WorkflowConflict,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
# Only two valid resolutions - no "skip"
|
|
17
|
+
Resolution = Literal["take_base", "take_target"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class InteractiveConflictResolver:
|
|
21
|
+
"""CLI interactive conflict resolver - NO skip option.
|
|
22
|
+
|
|
23
|
+
Prompts user for each WORKFLOW conflict detected before a merge/pull.
|
|
24
|
+
Node and dependency conflicts are not shown as they are derived from
|
|
25
|
+
workflow resolutions.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def resolve_workflow(self, conflict: WorkflowConflict) -> Resolution:
|
|
29
|
+
"""Prompt user to resolve a workflow conflict."""
|
|
30
|
+
print(f"\n Workflow: {conflict.identifier}.json")
|
|
31
|
+
print(f" Your version: {(conflict.base_hash or 'unknown')[:8]}...")
|
|
32
|
+
print(f" Their version: {(conflict.target_hash or 'unknown')[:8]}...")
|
|
33
|
+
print()
|
|
34
|
+
|
|
35
|
+
while True:
|
|
36
|
+
choice = input(" [m] Keep mine [t] Keep theirs: ").lower().strip()
|
|
37
|
+
if choice == "m":
|
|
38
|
+
return "take_base"
|
|
39
|
+
elif choice == "t":
|
|
40
|
+
return "take_target"
|
|
41
|
+
print(" Please enter 'm' or 't'")
|
|
42
|
+
|
|
43
|
+
def resolve_all(self, diff: RefDiff) -> dict[str, Resolution]:
|
|
44
|
+
"""Resolve all workflow conflicts interactively.
|
|
45
|
+
|
|
46
|
+
Only shows WORKFLOW conflicts. Nodes/deps are derived from workflows.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
diff: RefDiff with conflicts
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
Dict mapping workflow names to resolutions
|
|
53
|
+
"""
|
|
54
|
+
resolutions: dict[str, Resolution] = {}
|
|
55
|
+
|
|
56
|
+
if not diff.has_conflicts:
|
|
57
|
+
return resolutions
|
|
58
|
+
|
|
59
|
+
# Only handle WORKFLOW conflicts
|
|
60
|
+
workflow_conflicts = [
|
|
61
|
+
wf.conflict
|
|
62
|
+
for wf in diff.workflow_changes
|
|
63
|
+
if wf.conflict and wf.conflict.resolution == "unresolved"
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
if not workflow_conflicts:
|
|
67
|
+
return resolutions
|
|
68
|
+
|
|
69
|
+
print(f"\n{len(workflow_conflicts)} workflow conflict(s) to resolve:\n")
|
|
70
|
+
|
|
71
|
+
for conflict in workflow_conflicts:
|
|
72
|
+
resolution = self.resolve_workflow(conflict)
|
|
73
|
+
resolutions[conflict.identifier] = resolution
|
|
74
|
+
conflict.resolution = resolution
|
|
75
|
+
print()
|
|
76
|
+
|
|
77
|
+
return resolutions
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class AutoConflictResolver:
|
|
81
|
+
"""Auto-resolve conflicts using a fixed strategy.
|
|
82
|
+
|
|
83
|
+
Used with --auto-resolve or --strategy flag for non-interactive resolution.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
def __init__(self, strategy: Literal["mine", "theirs"]):
|
|
87
|
+
"""Initialize with resolution strategy.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
strategy: "mine" to keep local (take_base), "theirs" to take incoming (take_target)
|
|
91
|
+
"""
|
|
92
|
+
self._resolution: Resolution = (
|
|
93
|
+
"take_base" if strategy == "mine" else "take_target"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
def resolve_all(self, diff: RefDiff) -> dict[str, Resolution]:
|
|
97
|
+
"""Auto-resolve all workflow conflicts.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
diff: RefDiff with conflicts
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
Dict mapping workflow names to resolutions
|
|
104
|
+
"""
|
|
105
|
+
resolutions: dict[str, Resolution] = {}
|
|
106
|
+
|
|
107
|
+
# Only handle WORKFLOW conflicts
|
|
108
|
+
for wf in diff.workflow_changes:
|
|
109
|
+
if wf.conflict and wf.conflict.resolution == "unresolved":
|
|
110
|
+
resolutions[wf.conflict.identifier] = self._resolution
|
|
111
|
+
wf.conflict.resolution = self._resolution
|
|
112
|
+
|
|
113
|
+
return resolutions
|