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.
- db_sync_tool/__main__.py +7 -252
- db_sync_tool/cli.py +733 -0
- db_sync_tool/database/process.py +94 -111
- db_sync_tool/database/utility.py +339 -121
- db_sync_tool/info.py +1 -1
- db_sync_tool/recipes/drupal.py +87 -12
- db_sync_tool/recipes/laravel.py +7 -6
- db_sync_tool/recipes/parsing.py +102 -0
- db_sync_tool/recipes/symfony.py +17 -28
- db_sync_tool/recipes/typo3.py +33 -54
- db_sync_tool/recipes/wordpress.py +13 -12
- db_sync_tool/remote/client.py +206 -71
- db_sync_tool/remote/file_transfer.py +303 -0
- db_sync_tool/remote/rsync.py +18 -15
- db_sync_tool/remote/system.py +2 -3
- db_sync_tool/remote/transfer.py +51 -47
- db_sync_tool/remote/utility.py +29 -30
- db_sync_tool/sync.py +52 -28
- db_sync_tool/utility/config.py +367 -0
- db_sync_tool/utility/config_resolver.py +573 -0
- db_sync_tool/utility/console.py +779 -0
- db_sync_tool/utility/exceptions.py +32 -0
- db_sync_tool/utility/helper.py +155 -148
- db_sync_tool/utility/info.py +53 -20
- db_sync_tool/utility/log.py +55 -31
- db_sync_tool/utility/logging_config.py +410 -0
- db_sync_tool/utility/mode.py +85 -150
- db_sync_tool/utility/output.py +122 -51
- db_sync_tool/utility/parser.py +33 -53
- db_sync_tool/utility/pure.py +93 -0
- db_sync_tool/utility/security.py +79 -0
- db_sync_tool/utility/system.py +277 -194
- db_sync_tool/utility/validation.py +2 -9
- db_sync_tool_kmi-3.0.2.dist-info/METADATA +99 -0
- db_sync_tool_kmi-3.0.2.dist-info/RECORD +44 -0
- {db_sync_tool_kmi-2.11.6.dist-info → db_sync_tool_kmi-3.0.2.dist-info}/WHEEL +1 -1
- db_sync_tool_kmi-2.11.6.dist-info/METADATA +0 -276
- db_sync_tool_kmi-2.11.6.dist-info/RECORD +0 -34
- {db_sync_tool_kmi-2.11.6.dist-info → db_sync_tool_kmi-3.0.2.dist-info}/entry_points.txt +0 -0
- {db_sync_tool_kmi-2.11.6.dist-info → db_sync_tool_kmi-3.0.2.dist-info/licenses}/LICENSE +0 -0
- {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
|