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 @@
|
|
|
1
|
+
"""Interactive CLI components."""
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Dual-output rotating file handler with real-time log compression."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from logging.handlers import RotatingFileHandler
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .log_compressor import LogCompressor
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CompressedDualHandler(RotatingFileHandler):
|
|
11
|
+
"""File handler that writes both full and compressed logs simultaneously.
|
|
12
|
+
|
|
13
|
+
Writes to:
|
|
14
|
+
- full.log: Complete verbose logs with rotation
|
|
15
|
+
- compressed.log: Real-time compressed version
|
|
16
|
+
|
|
17
|
+
The compressed log uses a lighter format optimized for token count reduction
|
|
18
|
+
while preserving all semantic content.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
log_dir: Path,
|
|
24
|
+
env_name: str,
|
|
25
|
+
compression_level: str = 'medium',
|
|
26
|
+
maxBytes: int = 10 * 1024 * 1024,
|
|
27
|
+
backupCount: int = 5,
|
|
28
|
+
encoding: str = 'utf-8'
|
|
29
|
+
):
|
|
30
|
+
"""Initialize dual-output handler.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
log_dir: Directory for log files (e.g., workspace/logs/test1/)
|
|
34
|
+
env_name: Environment name (for header comments)
|
|
35
|
+
compression_level: Compression level (light, medium, aggressive)
|
|
36
|
+
maxBytes: Max size before rotation (applies to full.log)
|
|
37
|
+
backupCount: Number of backup files to keep
|
|
38
|
+
encoding: File encoding
|
|
39
|
+
"""
|
|
40
|
+
# Ensure log directory exists
|
|
41
|
+
log_dir = Path(log_dir)
|
|
42
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
|
|
44
|
+
# Initialize base handler for full.log
|
|
45
|
+
full_log = log_dir / 'full.log'
|
|
46
|
+
super().__init__(full_log, maxBytes=maxBytes, backupCount=backupCount, encoding=encoding)
|
|
47
|
+
|
|
48
|
+
# Store instance variables for rotation
|
|
49
|
+
self.env_name = env_name
|
|
50
|
+
self.compression_level = compression_level
|
|
51
|
+
self.encoding = encoding
|
|
52
|
+
|
|
53
|
+
# Open compressed.log
|
|
54
|
+
self.compressed_path = log_dir / 'compressed.log'
|
|
55
|
+
self.compressed_file = open(self.compressed_path, 'a', encoding=encoding)
|
|
56
|
+
|
|
57
|
+
# Initialize compressor
|
|
58
|
+
self.compressor = LogCompressor(compression_level=compression_level)
|
|
59
|
+
|
|
60
|
+
# Write header to compressed log
|
|
61
|
+
self._write_compressed_header(env_name, compression_level)
|
|
62
|
+
|
|
63
|
+
def _write_compressed_header(self, env_name: str, level: str) -> None:
|
|
64
|
+
"""Write header to compressed log file."""
|
|
65
|
+
from datetime import datetime
|
|
66
|
+
self.compressed_file.write(f"# Compressed logs for environment: {env_name}\n")
|
|
67
|
+
self.compressed_file.write(f"# Compression level: {level}\n")
|
|
68
|
+
self.compressed_file.write(f"# Session started: {datetime.now().isoformat()}\n")
|
|
69
|
+
self.compressed_file.write("#\n")
|
|
70
|
+
self.compressed_file.flush()
|
|
71
|
+
|
|
72
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
73
|
+
"""Emit a record to both full and compressed logs.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
record: Log record to emit
|
|
77
|
+
"""
|
|
78
|
+
try:
|
|
79
|
+
# Write to full.log via parent handler
|
|
80
|
+
super().emit(record)
|
|
81
|
+
|
|
82
|
+
# Format the record for compression
|
|
83
|
+
formatted = self.format(record)
|
|
84
|
+
|
|
85
|
+
# Compress and write to compressed.log
|
|
86
|
+
compressed = self.compressor.compress_record(formatted)
|
|
87
|
+
if compressed: # Empty string means skip this line
|
|
88
|
+
self.compressed_file.write(compressed + '\n')
|
|
89
|
+
self.compressed_file.flush()
|
|
90
|
+
|
|
91
|
+
except Exception:
|
|
92
|
+
self.handleError(record)
|
|
93
|
+
|
|
94
|
+
def doRollover(self) -> None:
|
|
95
|
+
"""Override to rotate both full.log and compressed.log together."""
|
|
96
|
+
import os
|
|
97
|
+
|
|
98
|
+
# First, rotate full.log using parent
|
|
99
|
+
super().doRollover()
|
|
100
|
+
|
|
101
|
+
# Close current compressed file
|
|
102
|
+
if self.compressed_file:
|
|
103
|
+
# Write dictionary before closing
|
|
104
|
+
dictionary = self.compressor.get_dictionary()
|
|
105
|
+
if dictionary:
|
|
106
|
+
self.compressed_file.write(dictionary)
|
|
107
|
+
self.compressed_file.close()
|
|
108
|
+
|
|
109
|
+
# Rotate compressed backups: .3→.4, .2→.3, .1→.2
|
|
110
|
+
for i in range(self.backupCount - 1, 0, -1):
|
|
111
|
+
sfn = f"{self.compressed_path}.{i}"
|
|
112
|
+
dfn = f"{self.compressed_path}.{i + 1}"
|
|
113
|
+
if os.path.exists(sfn):
|
|
114
|
+
if os.path.exists(dfn):
|
|
115
|
+
os.remove(dfn)
|
|
116
|
+
os.rename(sfn, dfn)
|
|
117
|
+
|
|
118
|
+
# Rename current compressed.log → compressed.log.1
|
|
119
|
+
dfn = f"{self.compressed_path}.1"
|
|
120
|
+
if os.path.exists(dfn):
|
|
121
|
+
os.remove(dfn)
|
|
122
|
+
if os.path.exists(self.compressed_path):
|
|
123
|
+
os.rename(self.compressed_path, dfn)
|
|
124
|
+
|
|
125
|
+
# Reopen compressed.log for new session
|
|
126
|
+
self.compressed_file = open(self.compressed_path, 'a', encoding=self.encoding)
|
|
127
|
+
|
|
128
|
+
# Create new compressor for new session
|
|
129
|
+
self.compressor = LogCompressor(compression_level=self.compression_level)
|
|
130
|
+
|
|
131
|
+
# Write header to new compressed log
|
|
132
|
+
self._write_compressed_header(self.env_name, self.compression_level)
|
|
133
|
+
|
|
134
|
+
def close(self) -> None:
|
|
135
|
+
"""Close both log files and write dictionary."""
|
|
136
|
+
try:
|
|
137
|
+
# Write module dictionary to compressed log
|
|
138
|
+
dictionary = self.compressor.get_dictionary()
|
|
139
|
+
if dictionary:
|
|
140
|
+
self.compressed_file.write(dictionary)
|
|
141
|
+
|
|
142
|
+
# Close compressed file
|
|
143
|
+
self.compressed_file.close()
|
|
144
|
+
|
|
145
|
+
# Close full log via parent
|
|
146
|
+
super().close()
|
|
147
|
+
|
|
148
|
+
except Exception:
|
|
149
|
+
# Ensure we don't break on cleanup
|
|
150
|
+
pass
|
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
"""Environment-specific logging for ComfyGit."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from functools import wraps
|
|
9
|
+
from logging.handlers import RotatingFileHandler
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from .compressed_handler import CompressedDualHandler
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class EnvironmentLogger:
|
|
16
|
+
"""Manages environment-specific logging with rotation.
|
|
17
|
+
|
|
18
|
+
This integrates with the existing logging system by adding/removing
|
|
19
|
+
handlers to the root logger, so all get_logger(__name__) calls
|
|
20
|
+
in managers will automatically log to the environment file.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
# Shared configuration
|
|
24
|
+
MAX_BYTES = 10 * 1024 * 1024 # 10 MB per log file
|
|
25
|
+
BACKUP_COUNT = 5 # Keep 5 old log files
|
|
26
|
+
DETAILED_FORMAT = "%(asctime)s - %(name)s - %(levelname)s - %(funcName)s:%(lineno)d - %(message)s"
|
|
27
|
+
|
|
28
|
+
_workspace_path: Path | None = None
|
|
29
|
+
_active_handler: RotatingFileHandler | None = None
|
|
30
|
+
_current_env: str | None = None
|
|
31
|
+
_original_root_level: int | None = None
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
def set_workspace_path(cls, workspace_path: Path) -> None:
|
|
35
|
+
"""Set the workspace path for all environment loggers.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
workspace_path: Path to ComfyGit workspace
|
|
39
|
+
"""
|
|
40
|
+
cls._workspace_path = workspace_path
|
|
41
|
+
|
|
42
|
+
# Create logs directory if workspace exists
|
|
43
|
+
if workspace_path and workspace_path.exists():
|
|
44
|
+
logs_dir = workspace_path / "logs"
|
|
45
|
+
logs_dir.mkdir(exist_ok=True)
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def _add_env_handler(cls, env_name: str) -> logging.Handler | None:
|
|
49
|
+
"""Add a file handler for the environment to the root logger.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
env_name: Environment name
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
The handler that was added, or None if workspace not set
|
|
56
|
+
"""
|
|
57
|
+
if not cls._workspace_path or not cls._workspace_path.exists():
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
# Remove any existing environment handler
|
|
61
|
+
cls._remove_env_handler()
|
|
62
|
+
|
|
63
|
+
# ALWAYS use directory structure for consistency
|
|
64
|
+
log_dir = cls._workspace_path / "logs" / env_name
|
|
65
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
66
|
+
|
|
67
|
+
# Check if compressed logging is enabled via env var
|
|
68
|
+
enable_compressed = os.environ.get('COMFYGIT_DEV_COMPRESS_LOGS', '').lower() in ('true', '1', 'yes')
|
|
69
|
+
|
|
70
|
+
if enable_compressed:
|
|
71
|
+
# Dual-output handler (full.log + compressed.log)
|
|
72
|
+
handler = CompressedDualHandler(
|
|
73
|
+
log_dir=log_dir,
|
|
74
|
+
env_name=env_name,
|
|
75
|
+
compression_level='medium', # Configurable in future
|
|
76
|
+
maxBytes=cls.MAX_BYTES,
|
|
77
|
+
backupCount=cls.BACKUP_COUNT,
|
|
78
|
+
encoding='utf-8'
|
|
79
|
+
)
|
|
80
|
+
else:
|
|
81
|
+
# Single file in directory
|
|
82
|
+
log_file = log_dir / "full.log"
|
|
83
|
+
handler = RotatingFileHandler(
|
|
84
|
+
log_file,
|
|
85
|
+
maxBytes=cls.MAX_BYTES,
|
|
86
|
+
backupCount=cls.BACKUP_COUNT,
|
|
87
|
+
encoding='utf-8'
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
handler.setLevel(logging.DEBUG)
|
|
91
|
+
|
|
92
|
+
# Set formatter
|
|
93
|
+
formatter = logging.Formatter(cls.DETAILED_FORMAT)
|
|
94
|
+
handler.setFormatter(formatter)
|
|
95
|
+
|
|
96
|
+
# Add a name to identify this handler
|
|
97
|
+
handler.set_name(f"env_handler_{env_name}")
|
|
98
|
+
|
|
99
|
+
# Add handler to root logger and ensure it's configured
|
|
100
|
+
root_logger = logging.getLogger()
|
|
101
|
+
|
|
102
|
+
# Ensure root logger level allows DEBUG messages through
|
|
103
|
+
if root_logger.level > logging.DEBUG:
|
|
104
|
+
# Store original level to restore later
|
|
105
|
+
cls._original_root_level = root_logger.level
|
|
106
|
+
root_logger.setLevel(logging.DEBUG)
|
|
107
|
+
else:
|
|
108
|
+
cls._original_root_level = None
|
|
109
|
+
|
|
110
|
+
root_logger.addHandler(handler)
|
|
111
|
+
|
|
112
|
+
# Store reference
|
|
113
|
+
cls._active_handler = handler
|
|
114
|
+
cls._current_env = env_name
|
|
115
|
+
|
|
116
|
+
return handler
|
|
117
|
+
|
|
118
|
+
@classmethod
|
|
119
|
+
def _remove_env_handler(cls) -> None:
|
|
120
|
+
"""Remove the current environment handler from the root logger."""
|
|
121
|
+
if cls._active_handler:
|
|
122
|
+
root_logger = logging.getLogger()
|
|
123
|
+
root_logger.removeHandler(cls._active_handler)
|
|
124
|
+
cls._active_handler.close()
|
|
125
|
+
cls._active_handler = None
|
|
126
|
+
cls._current_env = None
|
|
127
|
+
|
|
128
|
+
# Restore original root logger level if we changed it
|
|
129
|
+
if cls._original_root_level is not None:
|
|
130
|
+
root_logger.setLevel(cls._original_root_level)
|
|
131
|
+
cls._original_root_level = None
|
|
132
|
+
|
|
133
|
+
@classmethod
|
|
134
|
+
@contextmanager
|
|
135
|
+
def log_command(cls, env_name: str, command: str, **context):
|
|
136
|
+
"""Context manager for logging a command execution.
|
|
137
|
+
|
|
138
|
+
This adds a file handler to the root logger for the duration
|
|
139
|
+
of the command, so all logging from any module will go to
|
|
140
|
+
the environment's log file.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
env_name: Environment name
|
|
144
|
+
command: Command being executed
|
|
145
|
+
**context: Additional context to log
|
|
146
|
+
|
|
147
|
+
Example:
|
|
148
|
+
with EnvironmentLogger.log_command("my-env", "node add"):
|
|
149
|
+
# All logging from any module will go to my-env.log
|
|
150
|
+
env_mgr.create_environment(...) # Its logs go to my-env.log
|
|
151
|
+
"""
|
|
152
|
+
handler = cls._add_env_handler(env_name)
|
|
153
|
+
|
|
154
|
+
if not handler:
|
|
155
|
+
# No workspace, yield None
|
|
156
|
+
yield None
|
|
157
|
+
return
|
|
158
|
+
|
|
159
|
+
# Get root logger for command logging
|
|
160
|
+
logger = logging.getLogger("comfygit.command")
|
|
161
|
+
|
|
162
|
+
# Log command start
|
|
163
|
+
separator = "=" * 60
|
|
164
|
+
logger.info(separator)
|
|
165
|
+
logger.info(f"Command: {command}")
|
|
166
|
+
logger.info(f"Started: {datetime.now().isoformat()}")
|
|
167
|
+
|
|
168
|
+
# Log any context
|
|
169
|
+
for key, value in context.items():
|
|
170
|
+
if value is not None: # Only log non-None values
|
|
171
|
+
logger.info(f"{key}: {value}")
|
|
172
|
+
|
|
173
|
+
logger.info("-" * 40)
|
|
174
|
+
|
|
175
|
+
try:
|
|
176
|
+
# Yield - during this time all logging goes to the env file
|
|
177
|
+
yield logger
|
|
178
|
+
|
|
179
|
+
# Log successful completion
|
|
180
|
+
logger.info(f"Command '{command}' completed successfully")
|
|
181
|
+
|
|
182
|
+
except (SystemExit, KeyboardInterrupt) as e:
|
|
183
|
+
# Log system exit/interrupt
|
|
184
|
+
if isinstance(e, SystemExit):
|
|
185
|
+
logger.info(f"Command '{command}' exited with code {e.code}")
|
|
186
|
+
else:
|
|
187
|
+
logger.info(f"Command '{command}' interrupted")
|
|
188
|
+
raise
|
|
189
|
+
|
|
190
|
+
except Exception as e:
|
|
191
|
+
# Log the error
|
|
192
|
+
logger.error(f"Command '{command}' failed: {e}", exc_info=True)
|
|
193
|
+
raise
|
|
194
|
+
|
|
195
|
+
finally:
|
|
196
|
+
# Log command end
|
|
197
|
+
logger.info(f"Ended: {datetime.now().isoformat()}")
|
|
198
|
+
logger.info(separator + "\n")
|
|
199
|
+
|
|
200
|
+
# Remove the handler
|
|
201
|
+
cls._remove_env_handler()
|
|
202
|
+
|
|
203
|
+
@classmethod
|
|
204
|
+
def set_environment(cls, env_name: str) -> None:
|
|
205
|
+
"""Set the active environment for logging.
|
|
206
|
+
|
|
207
|
+
This is useful for long-running operations where you want
|
|
208
|
+
all logs to go to a specific environment file without
|
|
209
|
+
using the context manager.
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
env_name: Environment name
|
|
213
|
+
"""
|
|
214
|
+
cls._add_env_handler(env_name)
|
|
215
|
+
|
|
216
|
+
@classmethod
|
|
217
|
+
def clear_environment(cls) -> None:
|
|
218
|
+
"""Clear the active environment logging."""
|
|
219
|
+
cls._remove_env_handler()
|
|
220
|
+
|
|
221
|
+
@classmethod
|
|
222
|
+
def get_current_environment(cls) -> str | None:
|
|
223
|
+
"""Get the currently active environment for logging."""
|
|
224
|
+
return cls._current_env
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
class WorkspaceLogger:
|
|
228
|
+
"""Manages workspace-level logging separate from environment-specific logging.
|
|
229
|
+
|
|
230
|
+
This creates logs under logs/workspace/workspace.log for global workspace commands.
|
|
231
|
+
"""
|
|
232
|
+
|
|
233
|
+
# Shared configuration
|
|
234
|
+
MAX_BYTES = 10 * 1024 * 1024 # 10 MB per log file
|
|
235
|
+
BACKUP_COUNT = 5 # Keep 5 old log files
|
|
236
|
+
DETAILED_FORMAT = "%(asctime)s - %(name)s - %(levelname)s - %(funcName)s:%(lineno)d - %(message)s"
|
|
237
|
+
|
|
238
|
+
_workspace_path: Path | None = None
|
|
239
|
+
_active_handler: RotatingFileHandler | None = None
|
|
240
|
+
_original_root_level: int | None = None
|
|
241
|
+
|
|
242
|
+
@classmethod
|
|
243
|
+
def set_workspace_path(cls, workspace_path: Path) -> None:
|
|
244
|
+
"""Set the workspace path for workspace logging.
|
|
245
|
+
|
|
246
|
+
Args:
|
|
247
|
+
workspace_path: Path to ComfyGit workspace
|
|
248
|
+
"""
|
|
249
|
+
cls._workspace_path = workspace_path
|
|
250
|
+
|
|
251
|
+
# Create workspace logs directory if workspace exists
|
|
252
|
+
if workspace_path and workspace_path.exists():
|
|
253
|
+
logs_dir = workspace_path / "logs" / "workspace"
|
|
254
|
+
logs_dir.mkdir(parents=True, exist_ok=True)
|
|
255
|
+
|
|
256
|
+
@classmethod
|
|
257
|
+
def _add_workspace_handler(cls) -> logging.Handler | None:
|
|
258
|
+
"""Add a file handler for workspace commands to the root logger.
|
|
259
|
+
|
|
260
|
+
Returns:
|
|
261
|
+
The handler that was added, or None if workspace not set
|
|
262
|
+
"""
|
|
263
|
+
if not cls._workspace_path or not cls._workspace_path.exists():
|
|
264
|
+
return None
|
|
265
|
+
|
|
266
|
+
# Remove any existing workspace handler
|
|
267
|
+
cls._remove_workspace_handler()
|
|
268
|
+
|
|
269
|
+
# Use consistent directory structure
|
|
270
|
+
log_dir = cls._workspace_path / "logs" / "workspace"
|
|
271
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
272
|
+
|
|
273
|
+
# Check if compressed logging is enabled via env var
|
|
274
|
+
enable_compressed = os.environ.get('COMFYGIT_DEV_COMPRESS_LOGS', '').lower() in ('true', '1', 'yes')
|
|
275
|
+
|
|
276
|
+
if enable_compressed:
|
|
277
|
+
# Dual-output handler (full.log + compressed.log)
|
|
278
|
+
handler = CompressedDualHandler(
|
|
279
|
+
log_dir=log_dir,
|
|
280
|
+
env_name='workspace', # For header
|
|
281
|
+
compression_level='medium',
|
|
282
|
+
maxBytes=cls.MAX_BYTES,
|
|
283
|
+
backupCount=cls.BACKUP_COUNT,
|
|
284
|
+
encoding='utf-8'
|
|
285
|
+
)
|
|
286
|
+
else:
|
|
287
|
+
# Single file in directory (renamed to full.log for consistency)
|
|
288
|
+
log_file = log_dir / "full.log"
|
|
289
|
+
handler = RotatingFileHandler(
|
|
290
|
+
log_file,
|
|
291
|
+
maxBytes=cls.MAX_BYTES,
|
|
292
|
+
backupCount=cls.BACKUP_COUNT,
|
|
293
|
+
encoding='utf-8'
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
handler.setLevel(logging.DEBUG)
|
|
297
|
+
|
|
298
|
+
# Set formatter
|
|
299
|
+
formatter = logging.Formatter(cls.DETAILED_FORMAT)
|
|
300
|
+
handler.setFormatter(formatter)
|
|
301
|
+
|
|
302
|
+
# Add a name to identify this handler
|
|
303
|
+
handler.set_name("workspace_handler")
|
|
304
|
+
|
|
305
|
+
# Add handler to root logger and ensure it's configured
|
|
306
|
+
root_logger = logging.getLogger()
|
|
307
|
+
|
|
308
|
+
# Ensure root logger level allows DEBUG messages through
|
|
309
|
+
if root_logger.level > logging.DEBUG:
|
|
310
|
+
# Store original level to restore later
|
|
311
|
+
cls._original_root_level = root_logger.level
|
|
312
|
+
root_logger.setLevel(logging.DEBUG)
|
|
313
|
+
else:
|
|
314
|
+
cls._original_root_level = None
|
|
315
|
+
|
|
316
|
+
root_logger.addHandler(handler)
|
|
317
|
+
|
|
318
|
+
# Store reference
|
|
319
|
+
cls._active_handler = handler
|
|
320
|
+
|
|
321
|
+
return handler
|
|
322
|
+
|
|
323
|
+
@classmethod
|
|
324
|
+
def _remove_workspace_handler(cls) -> None:
|
|
325
|
+
"""Remove the current workspace handler from the root logger."""
|
|
326
|
+
if cls._active_handler:
|
|
327
|
+
root_logger = logging.getLogger()
|
|
328
|
+
root_logger.removeHandler(cls._active_handler)
|
|
329
|
+
cls._active_handler.close()
|
|
330
|
+
cls._active_handler = None
|
|
331
|
+
|
|
332
|
+
# Restore original root logger level if we changed it
|
|
333
|
+
if cls._original_root_level is not None:
|
|
334
|
+
root_logger.setLevel(cls._original_root_level)
|
|
335
|
+
cls._original_root_level = None
|
|
336
|
+
|
|
337
|
+
@classmethod
|
|
338
|
+
@contextmanager
|
|
339
|
+
def log_command(cls, command: str, **context):
|
|
340
|
+
"""Context manager for logging a workspace command execution.
|
|
341
|
+
|
|
342
|
+
This adds a file handler to the root logger for the duration
|
|
343
|
+
of the command, so all logging from any module will go to
|
|
344
|
+
the workspace log file.
|
|
345
|
+
|
|
346
|
+
Args:
|
|
347
|
+
command: Command being executed
|
|
348
|
+
**context: Additional context to log
|
|
349
|
+
|
|
350
|
+
Example:
|
|
351
|
+
with WorkspaceLogger.log_command("init"):
|
|
352
|
+
# All logging from any module will go to workspace.log
|
|
353
|
+
workspace_mgr.create_workspace(...)
|
|
354
|
+
"""
|
|
355
|
+
handler = cls._add_workspace_handler()
|
|
356
|
+
|
|
357
|
+
if not handler:
|
|
358
|
+
# No workspace, yield None
|
|
359
|
+
yield None
|
|
360
|
+
return
|
|
361
|
+
|
|
362
|
+
# Get root logger for command logging
|
|
363
|
+
logger = logging.getLogger("comfygit.workspace")
|
|
364
|
+
|
|
365
|
+
# Log command start
|
|
366
|
+
separator = "=" * 60
|
|
367
|
+
logger.info(separator)
|
|
368
|
+
logger.info(f"Command: {command}")
|
|
369
|
+
logger.info(f"Started: {datetime.now().isoformat()}")
|
|
370
|
+
|
|
371
|
+
# Log any context
|
|
372
|
+
for key, value in context.items():
|
|
373
|
+
if value is not None: # Only log non-None values
|
|
374
|
+
logger.info(f"{key}: {value}")
|
|
375
|
+
|
|
376
|
+
logger.info("-" * 40)
|
|
377
|
+
|
|
378
|
+
try:
|
|
379
|
+
# Yield - during this time all logging goes to the workspace file
|
|
380
|
+
yield logger
|
|
381
|
+
|
|
382
|
+
# Log successful completion
|
|
383
|
+
logger.info(f"Command '{command}' completed successfully")
|
|
384
|
+
|
|
385
|
+
except (SystemExit, KeyboardInterrupt) as e:
|
|
386
|
+
# Log system exit/interrupt
|
|
387
|
+
if isinstance(e, SystemExit):
|
|
388
|
+
logger.info(f"Command '{command}' exited with code {e.code}")
|
|
389
|
+
else:
|
|
390
|
+
logger.info(f"Command '{command}' interrupted")
|
|
391
|
+
raise
|
|
392
|
+
|
|
393
|
+
except Exception as e:
|
|
394
|
+
# Log the error
|
|
395
|
+
logger.error(f"Command '{command}' failed: {e}", exc_info=True)
|
|
396
|
+
raise
|
|
397
|
+
|
|
398
|
+
finally:
|
|
399
|
+
# Log command end
|
|
400
|
+
logger.info(f"Ended: {datetime.now().isoformat()}")
|
|
401
|
+
logger.info(separator + "\n")
|
|
402
|
+
|
|
403
|
+
# Remove the handler
|
|
404
|
+
cls._remove_workspace_handler()
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def with_env_logging(command_name: str, get_env_name: Callable | None = None, log_args: bool = True, **log_context):
|
|
408
|
+
"""Decorator for environment commands that automatically sets up logging.
|
|
409
|
+
|
|
410
|
+
Args:
|
|
411
|
+
command_name: Name of the command for logging (e.g., "env create")
|
|
412
|
+
get_env_name: Optional function to extract env name from args.
|
|
413
|
+
If None, tries args.name, then args.env_name,
|
|
414
|
+
then calls self._get_env_name(args) if available.
|
|
415
|
+
log_args: If True, automatically logs all args attributes (default: True)
|
|
416
|
+
**log_context: Additional static context to log
|
|
417
|
+
|
|
418
|
+
Example:
|
|
419
|
+
@with_env_logging("env create") # Automatically logs all args
|
|
420
|
+
def create(self, args):
|
|
421
|
+
# All logging automatically goes to environment log
|
|
422
|
+
result = self.env_mgr.create_environment(...)
|
|
423
|
+
|
|
424
|
+
@with_env_logging("env apply", log_args=False, custom_field="value")
|
|
425
|
+
def apply(self, args):
|
|
426
|
+
# Only logs custom_field, not args
|
|
427
|
+
"""
|
|
428
|
+
def decorator(func: Callable) -> Callable:
|
|
429
|
+
@wraps(func)
|
|
430
|
+
def wrapper(self, args, *extra_args, **kwargs):
|
|
431
|
+
# Determine environment name
|
|
432
|
+
env_name = None
|
|
433
|
+
if get_env_name:
|
|
434
|
+
# Try calling with self first, fall back to just args
|
|
435
|
+
import inspect
|
|
436
|
+
sig = inspect.signature(get_env_name)
|
|
437
|
+
if len(sig.parameters) >= 2:
|
|
438
|
+
env_name = get_env_name(self, args)
|
|
439
|
+
else:
|
|
440
|
+
env_name = get_env_name(args)
|
|
441
|
+
elif hasattr(args, 'name'):
|
|
442
|
+
# For commands like 'create', args.name is the target environment
|
|
443
|
+
env_name = args.name
|
|
444
|
+
elif hasattr(args, 'env_name'):
|
|
445
|
+
env_name = args.env_name
|
|
446
|
+
elif hasattr(self, '_get_env'):
|
|
447
|
+
# For commands operating IN an environment, fall back to active env
|
|
448
|
+
env_name = self._get_env(args).name
|
|
449
|
+
|
|
450
|
+
# If no environment name available, run without logging
|
|
451
|
+
if not env_name:
|
|
452
|
+
return func(self, args, *extra_args, **kwargs)
|
|
453
|
+
|
|
454
|
+
# Ensure EnvironmentLogger has workspace path set
|
|
455
|
+
# Import here to avoid circular imports
|
|
456
|
+
from ..cli_utils import get_workspace_optional
|
|
457
|
+
|
|
458
|
+
workspace = get_workspace_optional()
|
|
459
|
+
if workspace:
|
|
460
|
+
EnvironmentLogger.set_workspace_path(workspace.path)
|
|
461
|
+
|
|
462
|
+
# Build context
|
|
463
|
+
context = {}
|
|
464
|
+
|
|
465
|
+
# Auto-capture all args attributes if enabled
|
|
466
|
+
if log_args and hasattr(args, '__dict__'):
|
|
467
|
+
# Get all non-private attributes from args
|
|
468
|
+
# Prefix with 'arg_' to avoid conflicts with log_command parameters
|
|
469
|
+
args_dict = {f'arg_{k}': v for k, v in vars(args).items() if not k.startswith('_')}
|
|
470
|
+
context.update(args_dict)
|
|
471
|
+
|
|
472
|
+
# Add/override with explicit log_context
|
|
473
|
+
for key, value in log_context.items():
|
|
474
|
+
if callable(value):
|
|
475
|
+
# It's a function to extract from args
|
|
476
|
+
try:
|
|
477
|
+
context[key] = value(args)
|
|
478
|
+
except (AttributeError, TypeError):
|
|
479
|
+
pass # Skip if extraction fails
|
|
480
|
+
else:
|
|
481
|
+
# Static value
|
|
482
|
+
context[key] = value
|
|
483
|
+
|
|
484
|
+
# Run with logging context
|
|
485
|
+
with EnvironmentLogger.log_command(env_name, command_name, **context) as logger:
|
|
486
|
+
# Pass logger to function if it accepts it
|
|
487
|
+
import inspect
|
|
488
|
+
sig = inspect.signature(func)
|
|
489
|
+
if 'logger' in sig.parameters:
|
|
490
|
+
kwargs['logger'] = logger
|
|
491
|
+
return func(self, args, *extra_args, **kwargs)
|
|
492
|
+
|
|
493
|
+
return wrapper
|
|
494
|
+
return decorator
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def with_workspace_logging(command_name: str, log_args: bool = True, **log_context):
|
|
498
|
+
"""Decorator for workspace commands that automatically sets up logging.
|
|
499
|
+
|
|
500
|
+
Args:
|
|
501
|
+
command_name: Name of the command for logging (e.g., "init", "list", "model scan")
|
|
502
|
+
log_args: If True, automatically logs all args attributes (default: True)
|
|
503
|
+
**log_context: Additional static context to log
|
|
504
|
+
|
|
505
|
+
Example:
|
|
506
|
+
@with_workspace_logging("init") # Automatically logs all args
|
|
507
|
+
def init(self, args):
|
|
508
|
+
# All logging automatically goes to workspace log
|
|
509
|
+
result = self.workspace_factory.create(...)
|
|
510
|
+
|
|
511
|
+
@with_workspace_logging("model scan", log_args=False, custom_field="value")
|
|
512
|
+
def model_scan(self, args):
|
|
513
|
+
# Only logs custom_field, not args
|
|
514
|
+
"""
|
|
515
|
+
def decorator(func: Callable) -> Callable:
|
|
516
|
+
@wraps(func)
|
|
517
|
+
def wrapper(self, args, *extra_args, **kwargs):
|
|
518
|
+
# Ensure workspace logger is initialized
|
|
519
|
+
# This is needed because the decorator runs before the method body
|
|
520
|
+
# Import here to avoid circular imports
|
|
521
|
+
from ..cli_utils import get_workspace_optional
|
|
522
|
+
|
|
523
|
+
workspace = get_workspace_optional()
|
|
524
|
+
if workspace:
|
|
525
|
+
WorkspaceLogger.set_workspace_path(workspace.path)
|
|
526
|
+
|
|
527
|
+
# Build context
|
|
528
|
+
context = {}
|
|
529
|
+
|
|
530
|
+
# Auto-capture all args attributes if enabled
|
|
531
|
+
if log_args and hasattr(args, '__dict__'):
|
|
532
|
+
# Get all non-private attributes from args
|
|
533
|
+
# Prefix with 'arg_' to avoid conflicts with log_command parameters
|
|
534
|
+
args_dict = {f'arg_{k}': v for k, v in vars(args).items() if not k.startswith('_')}
|
|
535
|
+
context.update(args_dict)
|
|
536
|
+
|
|
537
|
+
# Add/override with explicit log_context
|
|
538
|
+
for key, value in log_context.items():
|
|
539
|
+
if callable(value):
|
|
540
|
+
# It's a function to extract from args
|
|
541
|
+
try:
|
|
542
|
+
context[key] = value(args)
|
|
543
|
+
except (AttributeError, TypeError):
|
|
544
|
+
pass # Skip if extraction fails
|
|
545
|
+
else:
|
|
546
|
+
# Static value
|
|
547
|
+
context[key] = value
|
|
548
|
+
|
|
549
|
+
# Run with logging context
|
|
550
|
+
with WorkspaceLogger.log_command(command_name, **context):
|
|
551
|
+
return func(self, args, *extra_args, **kwargs)
|
|
552
|
+
|
|
553
|
+
return wrapper
|
|
554
|
+
return decorator
|