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