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.
@@ -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