db-sync-tool-kmi 2.11.6__py3-none-any.whl → 3.0.2__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 (41) hide show
  1. db_sync_tool/__main__.py +7 -252
  2. db_sync_tool/cli.py +733 -0
  3. db_sync_tool/database/process.py +94 -111
  4. db_sync_tool/database/utility.py +339 -121
  5. db_sync_tool/info.py +1 -1
  6. db_sync_tool/recipes/drupal.py +87 -12
  7. db_sync_tool/recipes/laravel.py +7 -6
  8. db_sync_tool/recipes/parsing.py +102 -0
  9. db_sync_tool/recipes/symfony.py +17 -28
  10. db_sync_tool/recipes/typo3.py +33 -54
  11. db_sync_tool/recipes/wordpress.py +13 -12
  12. db_sync_tool/remote/client.py +206 -71
  13. db_sync_tool/remote/file_transfer.py +303 -0
  14. db_sync_tool/remote/rsync.py +18 -15
  15. db_sync_tool/remote/system.py +2 -3
  16. db_sync_tool/remote/transfer.py +51 -47
  17. db_sync_tool/remote/utility.py +29 -30
  18. db_sync_tool/sync.py +52 -28
  19. db_sync_tool/utility/config.py +367 -0
  20. db_sync_tool/utility/config_resolver.py +573 -0
  21. db_sync_tool/utility/console.py +779 -0
  22. db_sync_tool/utility/exceptions.py +32 -0
  23. db_sync_tool/utility/helper.py +155 -148
  24. db_sync_tool/utility/info.py +53 -20
  25. db_sync_tool/utility/log.py +55 -31
  26. db_sync_tool/utility/logging_config.py +410 -0
  27. db_sync_tool/utility/mode.py +85 -150
  28. db_sync_tool/utility/output.py +122 -51
  29. db_sync_tool/utility/parser.py +33 -53
  30. db_sync_tool/utility/pure.py +93 -0
  31. db_sync_tool/utility/security.py +79 -0
  32. db_sync_tool/utility/system.py +277 -194
  33. db_sync_tool/utility/validation.py +2 -9
  34. db_sync_tool_kmi-3.0.2.dist-info/METADATA +99 -0
  35. db_sync_tool_kmi-3.0.2.dist-info/RECORD +44 -0
  36. {db_sync_tool_kmi-2.11.6.dist-info → db_sync_tool_kmi-3.0.2.dist-info}/WHEEL +1 -1
  37. db_sync_tool_kmi-2.11.6.dist-info/METADATA +0 -276
  38. db_sync_tool_kmi-2.11.6.dist-info/RECORD +0 -34
  39. {db_sync_tool_kmi-2.11.6.dist-info → db_sync_tool_kmi-3.0.2.dist-info}/entry_points.txt +0 -0
  40. {db_sync_tool_kmi-2.11.6.dist-info → db_sync_tool_kmi-3.0.2.dist-info/licenses}/LICENSE +0 -0
  41. {db_sync_tool_kmi-2.11.6.dist-info → db_sync_tool_kmi-3.0.2.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,779 @@
1
+ #!/usr/bin/env python3
2
+
3
+ """
4
+ Modern CLI output using Rich.
5
+
6
+ This module provides a unified output interface supporting:
7
+ - Interactive mode: Compact progress display with status updates
8
+ - CI mode: GitHub Actions / GitLab CI annotations
9
+ - JSON mode: Machine-readable structured output
10
+ - Verbose mode: Detailed multi-line output
11
+
12
+ Usage:
13
+ from db_sync_tool.utility.console import get_output_manager
14
+
15
+ output_manager = get_output_manager()
16
+ output_manager.step("Creating database dump")
17
+ output_manager.success("Dump complete", tables=66, size=2516582, duration=3.2)
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import os
24
+ import sys
25
+ import time
26
+ from dataclasses import dataclass, field
27
+ from enum import Enum
28
+ from collections.abc import Callable
29
+ from typing import Any
30
+
31
+
32
+ class OutputFormat(Enum):
33
+ """Output format modes."""
34
+ INTERACTIVE = "interactive"
35
+ CI = "ci"
36
+ JSON = "json"
37
+ QUIET = "quiet"
38
+
39
+
40
+ class CIProvider(Enum):
41
+ """CI/CD provider detection."""
42
+ GITHUB = "github"
43
+ GITLAB = "gitlab"
44
+ JENKINS = "jenkins"
45
+ GENERIC = "generic"
46
+ NONE = "none"
47
+
48
+
49
+ # CI provider detection mapping: env_var -> provider
50
+ _CI_ENV_MAP = {
51
+ "GITHUB_ACTIONS": CIProvider.GITHUB,
52
+ "GITLAB_CI": CIProvider.GITLAB,
53
+ "JENKINS_URL": CIProvider.JENKINS,
54
+ "CI": CIProvider.GENERIC,
55
+ }
56
+
57
+
58
+ # Badge color palette - consistent styling for all badges
59
+ # Format: (background_color, text_color)
60
+ # Colors match the original Rich theme for consistency
61
+ BADGE_COLORS = {
62
+ "origin": ("magenta", "white"), # Magenta (same as original)
63
+ "target": ("blue", "white"), # Blue (same as original)
64
+ "local": ("cyan", "black"), # Cyan (same as original)
65
+ "remote": ("bright_black", "white"), # Gray
66
+ "info": ("cyan", "black"), # Cyan (same as original)
67
+ "success": ("green", "white"), # Green (same as original)
68
+ "warning": ("yellow", "black"), # Yellow (same as original)
69
+ "error": ("red", "white"), # Red (same as original)
70
+ "debug": ("bright_black", "white"), # Dim gray (same as original)
71
+ }
72
+
73
+ # Consistent icons
74
+ ICONS = {
75
+ "success": "✓",
76
+ "error": "✗",
77
+ "warning": "⚠",
78
+ "info": "ℹ",
79
+ "progress": "⋯",
80
+ }
81
+
82
+
83
+ @dataclass
84
+ class StepInfo:
85
+ """Information about a sync step."""
86
+ name: str
87
+ subject: str = ""
88
+ remote: bool = False
89
+ start_time: float = field(default_factory=time.time)
90
+
91
+
92
+ # Known sync steps for progress tracking
93
+ SYNC_STEPS = [
94
+ "Loading host configuration",
95
+ "Validating configuration",
96
+ "Sync mode:",
97
+ "Sync base:",
98
+ "Checking database configuration", # origin
99
+ "Initialize remote SSH connection", # origin (optional)
100
+ "Validating database credentials", # origin
101
+ "Database version:", # origin
102
+ "Creating database dump",
103
+ "table(s) exported",
104
+ "Downloading database dump", # or uploading
105
+ "Cleaning up", # origin
106
+ "Checking database configuration", # target
107
+ "Initialize remote SSH connection", # target (optional)
108
+ "Validating database credentials", # target
109
+ "Database version:", # target
110
+ "Importing database dump",
111
+ "Cleaning up", # target
112
+ "Successfully synchronized",
113
+ ]
114
+
115
+
116
+ class OutputManager:
117
+ """
118
+ Unified output manager for CLI operations.
119
+
120
+ Handles different output formats and provides a consistent API
121
+ for displaying progress, status, and results.
122
+ """
123
+
124
+ # Subject styles for Rich
125
+ _SUBJECT_STYLES = {
126
+ "origin": "origin",
127
+ "target": "target",
128
+ "local": "local",
129
+ "info": "info",
130
+ "warning": "warning",
131
+ "error": "error",
132
+ }
133
+
134
+ def __init__(
135
+ self,
136
+ format: OutputFormat = OutputFormat.INTERACTIVE,
137
+ verbose: int | bool = 0,
138
+ mute: bool = False,
139
+ total_steps: int = 18, # Default estimate for typical receiver sync
140
+ ):
141
+ self.format = format
142
+ # Support both bool (legacy) and int (new -v/-vv)
143
+ self.verbose = int(verbose) if isinstance(verbose, (int, bool)) else 0
144
+ self.mute = mute
145
+ self.ci_provider = self._detect_ci_provider()
146
+ self._current_step: StepInfo | None = None
147
+ self._steps_completed = 0
148
+ self._total_steps = total_steps
149
+ self._start_time = time.time()
150
+ self._console: Any = None
151
+ self._escape: Callable[[str], str] | None = None
152
+ self._gitlab_section_id: str | None = None
153
+ self._sync_stats: dict[str, Any] = {} # Track tables, size, durations
154
+ self._text_class: Any = None
155
+ self._style_class: Any = None
156
+ self._panel_class: Any = None
157
+
158
+ if self.format == OutputFormat.INTERACTIVE:
159
+ self._init_rich()
160
+
161
+ def _init_rich(self) -> None:
162
+ """Initialize Rich console."""
163
+ try:
164
+ from rich.console import Console
165
+ from rich.markup import escape
166
+ from rich.theme import Theme
167
+ from rich.text import Text
168
+ from rich.style import Style
169
+ from rich.panel import Panel
170
+
171
+ theme = Theme({
172
+ "info": "cyan", "success": "green", "warning": "yellow",
173
+ "error": "red bold", "origin": "magenta", "target": "blue",
174
+ "local": "cyan", "debug": "dim",
175
+ })
176
+ self._console = Console(theme=theme, force_terminal=True)
177
+ self._escape = escape
178
+ self._text_class = Text
179
+ self._style_class = Style
180
+ self._panel_class = Panel
181
+ except ImportError:
182
+ self.format = OutputFormat.CI
183
+
184
+ def _render_badge(self, label: str, badge_type: str | None = None) -> Any:
185
+ """
186
+ Render a styled badge with background color.
187
+
188
+ Args:
189
+ label: Badge text (e.g., "ORIGIN", "REMOTE")
190
+ badge_type: Badge type for color lookup, defaults to label.lower()
191
+
192
+ Returns:
193
+ Rich Text object with styled badge
194
+ """
195
+ if not self._text_class or not self._style_class:
196
+ # Fallback for when Rich is not available
197
+ return f"[{label}]" # type: ignore[return-value]
198
+
199
+ badge_key = (badge_type or label).lower()
200
+ bg_color, fg_color = BADGE_COLORS.get(badge_key, ("#7f8c8d", "white"))
201
+
202
+ style = self._style_class(color=fg_color, bgcolor=bg_color, bold=True)
203
+ return self._text_class(f" {label} ", style=style)
204
+
205
+ def _render_badges(self, subject: str, remote: bool = False) -> Any:
206
+ """
207
+ Render subject + location badges.
208
+
209
+ Args:
210
+ subject: Subject type (ORIGIN, TARGET, INFO, etc.)
211
+ remote: Whether operation is on remote host
212
+
213
+ Returns:
214
+ Rich Text object with styled badges
215
+ """
216
+ if not self._text_class:
217
+ # Fallback
218
+ subj = subject.upper()
219
+ if subj in ("ORIGIN", "TARGET"):
220
+ return f"[{subj}][{'REMOTE' if remote else 'LOCAL'}]" # type: ignore[return-value]
221
+ return f"[{subj}]" # type: ignore[return-value]
222
+
223
+ result = self._text_class()
224
+ subj = subject.upper()
225
+
226
+ if subj in ("ORIGIN", "TARGET"):
227
+ result.append_text(self._render_badge(subj, subj.lower()))
228
+ result.append(" ")
229
+ location = "REMOTE" if remote else "LOCAL"
230
+ result.append_text(self._render_badge(location, location.lower()))
231
+ elif subj in ("WARNING", "ERROR", "INFO", "DEBUG"):
232
+ result.append_text(self._render_badge(subj, subj.lower()))
233
+ else:
234
+ result.append_text(self._render_badge(subj, "info"))
235
+
236
+ return result
237
+
238
+ @staticmethod
239
+ def _detect_ci_provider() -> CIProvider:
240
+ """Detect CI environment from environment variables."""
241
+ for env_var, provider in _CI_ENV_MAP.items():
242
+ if os.environ.get(env_var):
243
+ return provider
244
+ return CIProvider.NONE
245
+
246
+ def _format_prefix(self, subject: str, remote: bool = False) -> str:
247
+ """Format subject prefix with optional remote indicator."""
248
+ subj = subject.upper()
249
+ if subj in ("ORIGIN", "TARGET"):
250
+ return f"[{subj}][{'REMOTE' if remote else 'LOCAL'}]"
251
+ return f"[{subj}]"
252
+
253
+ def _get_style(self, subject: str) -> str:
254
+ """Get Rich style for subject."""
255
+ return self._SUBJECT_STYLES.get(subject.lower(), "info")
256
+
257
+ def _print_rich(self, text: str, **kwargs: Any) -> None:
258
+ """Print with Rich console or fallback to plain print."""
259
+ if self._console:
260
+ self._console.print(text, **kwargs)
261
+ else:
262
+ # Strip Rich markup for fallback
263
+ import re
264
+ plain = re.sub(r'\[/?[^\]]+\]', '', text)
265
+ # Ensure output is flushed immediately (especially for \r endings)
266
+ print(plain, flush=True, **kwargs)
267
+
268
+ def _route_output(
269
+ self,
270
+ event: str,
271
+ message: str,
272
+ json_data: dict[str, Any] | None = None,
273
+ ci_handler: Callable[[], None] | None = None,
274
+ interactive_handler: Callable[[], None] | None = None,
275
+ force: bool = False,
276
+ ) -> bool:
277
+ """Route output to appropriate handler based on format. Returns True if handled."""
278
+ if self.format == OutputFormat.QUIET and not force:
279
+ return True
280
+ if self.format == OutputFormat.JSON:
281
+ self._json_output(event, message=message, **(json_data or {}))
282
+ return True
283
+ if self.format == OutputFormat.CI and ci_handler:
284
+ ci_handler()
285
+ return True
286
+ if interactive_handler:
287
+ interactive_handler()
288
+ return True
289
+ return False
290
+
291
+ # --- Public API ---
292
+
293
+ def _setup_step(self, message: str, subject: str = "INFO", remote: bool = False) -> None:
294
+ """Set up step context without displaying (for legacy API compatibility)."""
295
+ self._current_step = StepInfo(name=message, subject=subject, remote=remote)
296
+ # Auto-extract stats from known message patterns
297
+ self._extract_stats_from_message(message)
298
+
299
+ def _extract_stats_from_message(self, message: str) -> None:
300
+ """Extract statistics from known message patterns."""
301
+ import re
302
+ # Extract table count from "X table(s) exported"
303
+ match = re.search(r"(\d+)\s+table\(s\)\s+exported", message)
304
+ if match:
305
+ self._sync_stats["tables"] = int(match.group(1))
306
+
307
+ # Extract host info from "Checking database configuration" message
308
+ # Use the remote flag from current step to determine local vs remote
309
+ if "Checking database configuration" in message and self._current_step:
310
+ location = "remote" if self._current_step.remote else "local"
311
+ if "ORIGIN" in self._current_step.subject.upper() and not self._sync_stats.get("origin_host"):
312
+ self._sync_stats["origin_host"] = location
313
+ elif "TARGET" in self._current_step.subject.upper() and not self._sync_stats.get("target_host"):
314
+ self._sync_stats["target_host"] = location
315
+
316
+ def track_stat(self, key: str, value: Any) -> None:
317
+ """Track a sync statistic for the final summary."""
318
+ self._sync_stats[key] = value
319
+
320
+ def step(self, message: str, subject: str = "INFO", remote: bool = False, debug: bool = False) -> None:
321
+ """Display a step message with spinner (for long-running operations in progress)."""
322
+ # Debug messages only shown at verbose level 2 (-vv)
323
+ if self.mute or (debug and self.verbose < 2):
324
+ return
325
+
326
+ self._current_step = StepInfo(name=message, subject=subject, remote=remote)
327
+ prefix = self._format_prefix(subject, remote)
328
+
329
+ def ci() -> None:
330
+ print(f"[INFO] {prefix} {message}")
331
+
332
+ def interactive() -> None:
333
+ # Verbose mode prints in success(), compact mode updates progress bar there too
334
+ pass
335
+
336
+ self._route_output("step", message, {"subject": subject, "remote": remote}, ci, interactive)
337
+
338
+ def _render_progress_bar(self, width: int = 20) -> str:
339
+ """Render a progress bar based on completed steps."""
340
+ if self._total_steps <= 0:
341
+ return "━" * width
342
+ progress = min(self._steps_completed / self._total_steps, 1.0)
343
+ filled = int(width * progress)
344
+ if filled < width:
345
+ return "━" * filled + "╸" + "─" * (width - filled - 1)
346
+ return "━" * width
347
+
348
+ def success(self, message: str | None = None, **stats: Any) -> None:
349
+ """Mark current step as successful."""
350
+ if self.mute and not stats:
351
+ return
352
+
353
+ self._steps_completed += 1
354
+ step = self._current_step
355
+ display_msg = message or (step.name if step else "Operation")
356
+
357
+ # Detect final sync message and show as summary
358
+ is_final = "Successfully synchronized" in display_msg
359
+
360
+ def ci() -> None:
361
+ subject = step.subject if step else "INFO"
362
+ prefix = self._format_prefix(subject, step.remote if step else False)
363
+ print(f"[INFO] {prefix} {display_msg}")
364
+
365
+ def interactive() -> None:
366
+ subject = step.subject if step else "INFO"
367
+ remote = step.remote if step else False
368
+ esc = self._escape or (lambda x: x)
369
+
370
+ # Clear line
371
+ print("\033[2K\r", end="")
372
+
373
+ if is_final:
374
+ # Final message: Always show summary line
375
+ self._print_summary_line()
376
+ elif self.verbose >= 1:
377
+ # Verbose (-v/-vv): Table-based output with badge
378
+ if self._console and self._text_class:
379
+ self._print_table_row(subject, remote, display_msg)
380
+ if stats:
381
+ stats_str = " • ".join(f"{k}: {v}" for k, v in stats.items())
382
+ self._print_rich(f" [dim]{stats_str}[/dim]", highlight=False)
383
+ else:
384
+ prefix = self._format_prefix(subject, remote)
385
+ self._print_rich(f"{prefix} {esc(display_msg)}", highlight=False)
386
+ else:
387
+ # Compact: Single progress bar line (updates in place)
388
+ bar = self._render_progress_bar()
389
+ step_info = f"{self._steps_completed}/{self._total_steps}"
390
+ # Truncate message if too long
391
+ max_msg_len = 50
392
+ short_msg = display_msg[:max_msg_len] + "..." if len(display_msg) > max_msg_len else display_msg
393
+
394
+ if self._console and self._text_class:
395
+ badge = self._render_badge(subject.upper(), subject.lower())
396
+ line = self._text_class()
397
+ line.append(f"{bar} {step_info} ")
398
+ line.append_text(badge)
399
+ line.append(f" {short_msg}")
400
+ self._console.print(line, end="\r", highlight=False)
401
+ else:
402
+ prefix = self._format_prefix(subject, remote)
403
+ self._print_rich(f"{bar} {step_info} {prefix} {esc(short_msg)}", end="\r", highlight=False)
404
+
405
+ self._route_output("success", display_msg, stats, ci, interactive)
406
+
407
+ def _print_table_row(self, subject: str, remote: bool, message: str) -> None:
408
+ """Print a table-style row with badge, optional location, and message."""
409
+ if not self._console or not self._text_class:
410
+ return
411
+
412
+ subj = subject.upper()
413
+
414
+ # Determine badge color
415
+ if subj == "ORIGIN":
416
+ badge = self._render_badge("ORIGIN", "origin")
417
+ elif subj == "TARGET":
418
+ badge = self._render_badge("TARGET", "target")
419
+ else:
420
+ badge = self._render_badge(subj, subj.lower())
421
+
422
+ line = self._text_class()
423
+ line.append_text(badge)
424
+
425
+ # Only show location column for ORIGIN/TARGET
426
+ if subj in ("ORIGIN", "TARGET"):
427
+ location = "remote" if remote else "local"
428
+ line.append(f" ", style="dim")
429
+ line.append(f"{location:<8}", style="dim")
430
+ else:
431
+ line.append(" ")
432
+
433
+ line.append(message)
434
+ self._console.print(line, highlight=False)
435
+
436
+ def _print_summary_line(self) -> None:
437
+ """Print the final sync summary as a simple line."""
438
+ duration = round(time.time() - self._start_time, 1)
439
+
440
+ # Build context info (origin → target)
441
+ context_parts = []
442
+ if self._sync_stats.get("origin_host"):
443
+ context_parts.append(self._sync_stats["origin_host"])
444
+ if self._sync_stats.get("target_host"):
445
+ context_parts.append(self._sync_stats["target_host"])
446
+ context = " → ".join(context_parts) if context_parts else ""
447
+
448
+ # Build stats
449
+ stats_parts = []
450
+ if self._sync_stats.get("tables"):
451
+ stats_parts.append(f"{self._sync_stats['tables']} tables")
452
+ if self._sync_stats.get("size"):
453
+ stats_parts.append(f"{round(self._sync_stats['size'] / 1024 / 1024, 1)} MB")
454
+ stats_parts.append(f"{duration}s")
455
+ stats_str = " • ".join(stats_parts)
456
+
457
+ # Build summary line
458
+ if context:
459
+ summary = f"{context} • {stats_str}"
460
+ else:
461
+ summary = stats_str
462
+
463
+ # Escape summary to prevent Rich markup interpretation (e.g., IPv6 addresses with brackets)
464
+ esc = self._escape or (lambda x: x)
465
+ self._console.print() # Empty line before summary
466
+ self._print_rich(f"[green]{ICONS['success']} Sync complete:[/green] {esc(summary)}", highlight=False)
467
+
468
+ def error(self, message: str, exception: Exception | None = None) -> None:
469
+ """Display an error message."""
470
+ def ci() -> None:
471
+ if self.ci_provider == CIProvider.GITHUB:
472
+ print(f"::error::{message}")
473
+ elif self.ci_provider == CIProvider.GITLAB:
474
+ print(f"\033[0;31mERROR: {message}\033[0m")
475
+ else:
476
+ print(f"[ERROR] {message}", file=sys.stderr)
477
+
478
+ def interactive() -> None:
479
+ # Clear line to prevent leftover characters from previous output
480
+ print("\033[2K\r", end="")
481
+ esc = self._escape or (lambda x: x)
482
+ if self._console and self._text_class:
483
+ line = self._text_class()
484
+ line.append_text(self._render_badge("ERROR", "error"))
485
+ line.append(f" {ICONS['error']} {message}")
486
+ self._console.print(line, highlight=False)
487
+ if exception and self.verbose >= 2:
488
+ self._print_rich(f" [dim]{esc(str(exception))}[/dim]", highlight=False)
489
+ else:
490
+ self._print_rich(f"[error]{ICONS['error']} [ERROR] {esc(message)}[/error]", highlight=False)
491
+
492
+ exc_str = str(exception) if exception else None
493
+ self._route_output("error", message, {"exception": exc_str}, ci, interactive, force=True)
494
+
495
+ def warning(self, message: str) -> None:
496
+ """Display a warning message."""
497
+ if self.mute:
498
+ return
499
+
500
+ def ci() -> None:
501
+ if self.ci_provider == CIProvider.GITHUB:
502
+ print(f"::warning::{message}")
503
+ elif self.ci_provider == CIProvider.GITLAB:
504
+ print(f"\033[0;33mWARNING: {message}\033[0m")
505
+ else:
506
+ print(f"[WARNING] {message}")
507
+
508
+ def interactive() -> None:
509
+ # Clear line to prevent leftover characters from previous output
510
+ print("\033[2K\r", end="")
511
+ esc = self._escape or (lambda x: x)
512
+ if self._console and self._text_class:
513
+ line = self._text_class()
514
+ line.append_text(self._render_badge("WARNING", "warning"))
515
+ line.append(f" {ICONS['warning']} {message}")
516
+ self._console.print(line, highlight=False)
517
+ else:
518
+ self._print_rich(f"[warning]{ICONS['warning']} [WARNING] {esc(message)}[/warning]", highlight=False)
519
+
520
+ self._route_output("warning", message, None, ci, interactive)
521
+
522
+ def info(self, message: str) -> None:
523
+ """Display an info message."""
524
+ if self.mute:
525
+ return
526
+
527
+ def ci() -> None:
528
+ print(f"[INFO] {message}")
529
+
530
+ def interactive() -> None:
531
+ esc = self._escape or (lambda x: x)
532
+ if self._console and self._text_class:
533
+ line = self._text_class()
534
+ line.append_text(self._render_badge("INFO", "info"))
535
+ line.append(f" {ICONS['info']} {message}")
536
+ self._console.print(line, highlight=False)
537
+ else:
538
+ self._print_rich(f"[info]{ICONS['info']} {esc(message)}[/info]", highlight=False)
539
+
540
+ self._route_output("info", message, None, ci, interactive)
541
+
542
+ def debug(self, message: str) -> None:
543
+ """Display a debug message (only at -vv level)."""
544
+ if self.verbose < 2:
545
+ return
546
+
547
+ def interactive() -> None:
548
+ esc = self._escape or (lambda x: x)
549
+ if self._console and self._text_class:
550
+ line = self._text_class()
551
+ line.append_text(self._render_badge("DEBUG", "debug"))
552
+ line.append(f" {message}")
553
+ self._console.print(line, highlight=False)
554
+ else:
555
+ self._print_rich(f"[debug][DEBUG] {esc(message)}[/debug]", highlight=False)
556
+
557
+ self._route_output("debug", message, None, None, interactive)
558
+
559
+ def progress(self, current: int, total: int, message: str = "", speed: float | None = None) -> None:
560
+ """Display transfer progress."""
561
+ if self.mute or self.format == OutputFormat.QUIET:
562
+ return
563
+
564
+ percent = int(current / total * 100) if total > 0 else 0
565
+ current_mb = round(current / 1024 / 1024, 1)
566
+ total_mb = round(total / 1024 / 1024, 1)
567
+
568
+ def ci() -> None:
569
+ if percent % 10 == 0:
570
+ print(f"[INFO] Transfer: {percent}% of {total_mb} MB")
571
+
572
+ def interactive() -> None:
573
+ speed_str = f" • {round(speed / 1024 / 1024, 1)} MB/s" if speed else ""
574
+ step = self._current_step
575
+ subject = step.subject if step else "INFO"
576
+ remote = step.remote if step else False
577
+
578
+ bar_width = 20
579
+ filled = int(bar_width * current / total) if total > 0 else 0
580
+ bar = "━" * filled + ("╸" + "─" * (bar_width - filled - 1) if filled < bar_width else "")
581
+
582
+ msg = message or "Transferring"
583
+
584
+ if self._console and self._text_class:
585
+ badges = self._render_badges(subject, remote)
586
+ line = self._text_class()
587
+ line.append(f"{bar} {percent}% ")
588
+ line.append_text(badges)
589
+ line.append(f" {msg}: {current_mb}/{total_mb} MB{speed_str}")
590
+ self._console.print(line, end="\r", highlight=False)
591
+ else:
592
+ prefix = self._format_prefix(subject, remote)
593
+ esc = self._escape or (lambda x: x)
594
+ style = self._get_style(subject)
595
+ self._print_rich(
596
+ f"{bar} {percent}% [{style}]{esc(prefix)}[/{style}] {esc(msg)}: {current_mb}/{total_mb} MB{speed_str}",
597
+ end="\r", highlight=False
598
+ )
599
+
600
+ self._route_output(
601
+ "progress", message,
602
+ {"current": current, "total": total, "percent": percent, "speed": speed},
603
+ ci, interactive
604
+ )
605
+
606
+ def summary(self, **stats: Any) -> None:
607
+ """Display final sync summary."""
608
+ total_duration = round(time.time() - self._start_time, 1)
609
+ stats["total_duration"] = total_duration
610
+
611
+ # Update internal stats for summary rendering
612
+ if "tables" in stats:
613
+ self._sync_stats["tables"] = stats["tables"]
614
+ if "size" in stats:
615
+ self._sync_stats["size"] = stats["size"]
616
+
617
+ parts = []
618
+ if "tables" in stats:
619
+ parts.append(f"{stats['tables']} tables")
620
+ if "size" in stats:
621
+ parts.append(f"{round(stats['size'] / 1024 / 1024, 1)} MB")
622
+ parts.append(f"{total_duration}s")
623
+
624
+ breakdown_keys = ["dump_duration", "transfer_duration", "import_duration"]
625
+ breakdown = [f"{k.replace('_duration', '').title()}: {stats[k]}s" for k in breakdown_keys if k in stats]
626
+ if breakdown:
627
+ parts.append(f"({', '.join(breakdown)})")
628
+
629
+ summary_str = " • ".join(parts)
630
+
631
+ def ci() -> None:
632
+ print(f"[INFO] Sync complete: {summary_str}")
633
+
634
+ def interactive() -> None:
635
+ # Clear progress bar line and show summary
636
+ print("\033[2K\r", end="")
637
+ self._print_summary_line()
638
+
639
+ self._route_output("summary", summary_str, stats, ci, interactive)
640
+
641
+ def _json_output(self, event: str, **data: Any) -> None:
642
+ """Output a JSON event."""
643
+ output = {"event": event, "timestamp": time.time()}
644
+ output.update({k: v for k, v in data.items() if v is not None})
645
+ print(json.dumps(output), flush=True)
646
+
647
+ def group_start(self, title: str) -> None:
648
+ """Start a collapsible group (CI mode only)."""
649
+ if self.format != OutputFormat.CI:
650
+ return
651
+ if self.ci_provider == CIProvider.GITHUB:
652
+ print(f"::group::{title}")
653
+ elif self.ci_provider == CIProvider.GITLAB:
654
+ self._gitlab_section_id = title.lower().replace(" ", "_")
655
+ print(f"\033[0Ksection_start:{int(time.time())}:{self._gitlab_section_id}[collapsed=true]\r\033[0K{title}")
656
+
657
+ def group_end(self) -> None:
658
+ """End a collapsible group (CI mode only)."""
659
+ if self.format != OutputFormat.CI:
660
+ return
661
+ if self.ci_provider == CIProvider.GITHUB:
662
+ print("::endgroup::")
663
+ elif self.ci_provider == CIProvider.GITLAB and self._gitlab_section_id:
664
+ print(f"\033[0Ksection_end:{int(time.time())}:{self._gitlab_section_id}\r\033[0K")
665
+ self._gitlab_section_id = None
666
+
667
+ def build_prompt(
668
+ self,
669
+ message: str,
670
+ subject: str = "INFO",
671
+ remote: bool = False,
672
+ ) -> str:
673
+ """
674
+ Build a styled prompt string for use with input() or getpass().
675
+
676
+ This clears any progress bar line and returns a plain-text prompt
677
+ suitable for terminal input functions.
678
+
679
+ Args:
680
+ message: The prompt message
681
+ subject: Subject type (TARGET, ORIGIN, INFO, etc.)
682
+ remote: Whether operation is on remote host
683
+
684
+ Returns:
685
+ Plain-text prompt string with ANSI styling
686
+ """
687
+ # Clear any progress bar line
688
+ print("\033[2K\r", end="", flush=True)
689
+
690
+ prefix = self._format_prefix(subject, remote)
691
+ return f"{prefix} {message}"
692
+
693
+ def confirm(
694
+ self,
695
+ message: str,
696
+ subject: str = "INFO",
697
+ remote: bool = False,
698
+ default: bool = True,
699
+ ) -> bool:
700
+ """
701
+ Display a styled confirmation prompt and return user response.
702
+
703
+ Args:
704
+ message: The confirmation message/question
705
+ subject: Subject type (TARGET, ORIGIN, INFO, etc.)
706
+ remote: Whether operation is on remote host
707
+ default: Default response when user presses Enter (True=yes, False=no)
708
+
709
+ Returns:
710
+ True for yes, False for no
711
+ """
712
+ # Clear any progress bar line
713
+ print("\033[2K\r", end="", flush=True)
714
+
715
+ # Build styled prompt
716
+ if default:
717
+ choice_hint = "[Y|n]"
718
+ else:
719
+ choice_hint = "[y|N]"
720
+
721
+ if self._console and self._text_class:
722
+ # Rich-styled prompt
723
+ badges = self._render_badges(subject, remote)
724
+ prompt_line = self._text_class()
725
+ prompt_line.append_text(badges)
726
+ prompt_line.append(f" {message} {choice_hint}: ")
727
+ self._console.print(prompt_line, end="", highlight=False)
728
+ else:
729
+ # Fallback to plain text
730
+ prefix = self._format_prefix(subject, remote)
731
+ print(f"{prefix} {message} {choice_hint}: ", end="", flush=True)
732
+
733
+ while True:
734
+ ans = input().lower()
735
+ if not ans:
736
+ return default
737
+ if ans in ('y', 'n'):
738
+ return ans == 'y'
739
+ print('Please enter y or n.')
740
+
741
+
742
+ # Global singleton
743
+ _output_manager: OutputManager | None = None
744
+
745
+
746
+ def get_output_manager() -> OutputManager:
747
+ """Get the global OutputManager instance."""
748
+ global _output_manager
749
+ if _output_manager is None:
750
+ _output_manager = OutputManager()
751
+ return _output_manager
752
+
753
+
754
+ def init_output_manager(
755
+ format: str | OutputFormat = OutputFormat.INTERACTIVE,
756
+ verbose: int | bool = 0,
757
+ mute: bool = False,
758
+ ) -> OutputManager:
759
+ """Initialize the global OutputManager with specific settings."""
760
+ global _output_manager
761
+
762
+ if isinstance(format, str):
763
+ try:
764
+ format = OutputFormat(format)
765
+ except ValueError:
766
+ format = OutputFormat.INTERACTIVE
767
+
768
+ # Auto-detect CI mode
769
+ if format == OutputFormat.INTERACTIVE and os.environ.get("CI"):
770
+ format = OutputFormat.CI
771
+
772
+ _output_manager = OutputManager(format=format, verbose=verbose, mute=mute)
773
+ return _output_manager
774
+
775
+
776
+ def reset_output_manager() -> None:
777
+ """Reset the global OutputManager (for testing)."""
778
+ global _output_manager
779
+ _output_manager = None