clickhouse-charon 1.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.
- charon/__init__.py +3 -0
- charon/cli/__init__.py +0 -0
- charon/cli/app.py +207 -0
- charon/cli/config_cmd.py +184 -0
- charon/cli/copy_cmd.py +173 -0
- charon/cli/diff_cmd.py +167 -0
- charon/cli/list_cmd.py +72 -0
- charon/cli/status_cmd.py +96 -0
- charon/client.py +164 -0
- charon/config.py +98 -0
- charon/copy.py +310 -0
- charon/ddl.py +144 -0
- charon/diff.py +104 -0
- charon/web/__init__.py +0 -0
- charon/web/app.py +344 -0
- charon/web/jobs.py +191 -0
- charon/web/models.py +61 -0
- charon/web/templates/index.html +630 -0
- clickhouse_charon-1.0.2.dist-info/METADATA +303 -0
- clickhouse_charon-1.0.2.dist-info/RECORD +23 -0
- clickhouse_charon-1.0.2.dist-info/WHEEL +4 -0
- clickhouse_charon-1.0.2.dist-info/entry_points.txt +2 -0
- clickhouse_charon-1.0.2.dist-info/licenses/LICENSE +21 -0
charon/__init__.py
ADDED
charon/cli/__init__.py
ADDED
|
File without changes
|
charon/cli/app.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Main typer application — registers all sub-commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
|
|
11
|
+
from charon import __version__
|
|
12
|
+
from charon.config import AppConfig, CopyProfile, config_path_from_option, load_config
|
|
13
|
+
|
|
14
|
+
from .config_cmd import config_app
|
|
15
|
+
|
|
16
|
+
app = typer.Typer(
|
|
17
|
+
name="charon",
|
|
18
|
+
help="ClickHouse database copier — copy tables between instances.",
|
|
19
|
+
no_args_is_help=True,
|
|
20
|
+
)
|
|
21
|
+
app.add_typer(config_app, name="config")
|
|
22
|
+
|
|
23
|
+
console = Console()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
# State object passed via typer Context
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class State:
|
|
33
|
+
config_path: Path
|
|
34
|
+
profile_name: str | None
|
|
35
|
+
cfg: AppConfig
|
|
36
|
+
profile: CopyProfile | None = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _get_state(ctx: typer.Context) -> State:
|
|
40
|
+
state: State = ctx.ensure_object(State)
|
|
41
|
+
return state
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
# Global callback (sets up state before any sub-command)
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@app.callback()
|
|
50
|
+
def main(
|
|
51
|
+
ctx: typer.Context,
|
|
52
|
+
config: str | None = typer.Option(
|
|
53
|
+
None, "--config", "-c", envvar="CHCOPY_CONFIG", help="Config file path"
|
|
54
|
+
),
|
|
55
|
+
profile: str | None = typer.Option(
|
|
56
|
+
None, "--profile", "-p", envvar="CHCOPY_PROFILE", help="Profile name"
|
|
57
|
+
),
|
|
58
|
+
version: bool = typer.Option(
|
|
59
|
+
False, "--version", "-v", help="Show version and exit", is_eager=True
|
|
60
|
+
),
|
|
61
|
+
) -> None:
|
|
62
|
+
if version:
|
|
63
|
+
console.print(f"charon {__version__}")
|
|
64
|
+
raise typer.Exit()
|
|
65
|
+
|
|
66
|
+
config_path = config_path_from_option(config)
|
|
67
|
+
cfg = load_config(config_path)
|
|
68
|
+
|
|
69
|
+
# Try to resolve the profile (may be None if config is empty)
|
|
70
|
+
copy_profile: CopyProfile | None = None
|
|
71
|
+
if cfg.profiles:
|
|
72
|
+
try:
|
|
73
|
+
copy_profile = cfg.get_profile(profile)
|
|
74
|
+
except KeyError:
|
|
75
|
+
pass
|
|
76
|
+
|
|
77
|
+
ctx.obj = State(
|
|
78
|
+
config_path=config_path,
|
|
79
|
+
profile_name=profile,
|
|
80
|
+
cfg=cfg,
|
|
81
|
+
profile=copy_profile,
|
|
82
|
+
)
|
|
83
|
+
ctx.ensure_object(State)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _require_profile(ctx: typer.Context) -> CopyProfile:
|
|
87
|
+
state = _get_state(ctx)
|
|
88
|
+
if state.profile is None:
|
|
89
|
+
console.print("[red]No profile configured. Run `charon config init` to create one.[/red]")
|
|
90
|
+
raise typer.Exit(1)
|
|
91
|
+
return state.profile
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ---------------------------------------------------------------------------
|
|
95
|
+
# status
|
|
96
|
+
# ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@app.command()
|
|
100
|
+
def status(ctx: typer.Context) -> None:
|
|
101
|
+
"""Ping src and dst; show table count, rows, and bytes."""
|
|
102
|
+
from .status_cmd import run_status
|
|
103
|
+
|
|
104
|
+
profile = _require_profile(ctx)
|
|
105
|
+
run_status(profile)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# ---------------------------------------------------------------------------
|
|
109
|
+
# list
|
|
110
|
+
# ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@app.command("list")
|
|
114
|
+
def list_tables(
|
|
115
|
+
ctx: typer.Context,
|
|
116
|
+
dst: bool = typer.Option(False, "--dst", help="List destination tables instead of source"),
|
|
117
|
+
filter_re: str | None = typer.Option(None, "--filter", "-f", help="Regex filter on table name"),
|
|
118
|
+
) -> None:
|
|
119
|
+
"""List tables on src (or dst with --dst)."""
|
|
120
|
+
from .list_cmd import run_list
|
|
121
|
+
|
|
122
|
+
profile = _require_profile(ctx)
|
|
123
|
+
run_list(profile, use_dst=dst, filter_re=filter_re)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# ---------------------------------------------------------------------------
|
|
127
|
+
# diff
|
|
128
|
+
# ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@app.command()
|
|
132
|
+
def diff(
|
|
133
|
+
ctx: typer.Context,
|
|
134
|
+
table: str | None = typer.Option(
|
|
135
|
+
None, "--table", "-t", help="Focus on single table (partition-level)"
|
|
136
|
+
),
|
|
137
|
+
output_json: bool = typer.Option(False, "--json", help="Output as JSON"),
|
|
138
|
+
) -> None:
|
|
139
|
+
"""Show row-count diff between src and dst."""
|
|
140
|
+
from .diff_cmd import run_diff
|
|
141
|
+
|
|
142
|
+
profile = _require_profile(ctx)
|
|
143
|
+
run_diff(profile, table_name=table, output_json=output_json)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# ---------------------------------------------------------------------------
|
|
147
|
+
# copy
|
|
148
|
+
# ---------------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@app.command()
|
|
152
|
+
def copy(
|
|
153
|
+
ctx: typer.Context,
|
|
154
|
+
table: str | None = typer.Option(None, "--table", "-t", help="Copy a single table"),
|
|
155
|
+
tables: str | None = typer.Option(
|
|
156
|
+
None, "--tables", help="Comma-separated list of tables to copy"
|
|
157
|
+
),
|
|
158
|
+
dry_run: bool = typer.Option(
|
|
159
|
+
False, "--dry-run", help="Show what would be copied without executing"
|
|
160
|
+
),
|
|
161
|
+
no_partitions: bool = typer.Option(
|
|
162
|
+
False, "--no-partitions", help="Copy whole tables without partition awareness"
|
|
163
|
+
),
|
|
164
|
+
) -> None:
|
|
165
|
+
"""Copy tables from src to dst."""
|
|
166
|
+
from .copy_cmd import run_copy
|
|
167
|
+
|
|
168
|
+
profile = _require_profile(ctx)
|
|
169
|
+
table_list = [t.strip() for t in tables.split(",")] if tables else None
|
|
170
|
+
run_copy(
|
|
171
|
+
profile=profile,
|
|
172
|
+
table_name=table,
|
|
173
|
+
tables=table_list,
|
|
174
|
+
dry_run=dry_run,
|
|
175
|
+
no_partitions=no_partitions,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# ---------------------------------------------------------------------------
|
|
180
|
+
# web
|
|
181
|
+
# ---------------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@app.command()
|
|
185
|
+
def web(
|
|
186
|
+
ctx: typer.Context,
|
|
187
|
+
host: str = typer.Option("127.0.0.1", "--host", help="Bind address"),
|
|
188
|
+
port: int = typer.Option(8765, "--port", help="Port"),
|
|
189
|
+
reload: bool = typer.Option(False, "--reload", help="Enable auto-reload (dev mode)"),
|
|
190
|
+
) -> None:
|
|
191
|
+
"""Start the web dashboard."""
|
|
192
|
+
import uvicorn
|
|
193
|
+
|
|
194
|
+
from charon.web.app import create_app
|
|
195
|
+
|
|
196
|
+
state = _get_state(ctx)
|
|
197
|
+
|
|
198
|
+
fastapi_app = create_app(config_path=state.config_path, profile_name=state.profile_name)
|
|
199
|
+
|
|
200
|
+
console.print(f"[bold green]CHaron[/bold green] → [link]http://{host}:{port}[/link]")
|
|
201
|
+
|
|
202
|
+
uvicorn.run(
|
|
203
|
+
fastapi_app,
|
|
204
|
+
host=host,
|
|
205
|
+
port=port,
|
|
206
|
+
reload=reload,
|
|
207
|
+
)
|
charon/cli/config_cmd.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""CLI sub-app: manage connection profiles."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.table import Table
|
|
8
|
+
|
|
9
|
+
from charon.config import (
|
|
10
|
+
CopyProfile,
|
|
11
|
+
InstanceConfig,
|
|
12
|
+
config_path_from_option,
|
|
13
|
+
load_config,
|
|
14
|
+
save_config,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
config_app = typer.Typer(name="config", help="Manage connection profiles.")
|
|
18
|
+
console = Console()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@config_app.command("init")
|
|
22
|
+
def init(
|
|
23
|
+
profile: str = typer.Option(
|
|
24
|
+
"default", "--profile", "-p", help="Profile name to create or update"
|
|
25
|
+
),
|
|
26
|
+
config: str | None = typer.Option(None, "--config", "-c", help="Config file path"),
|
|
27
|
+
) -> None:
|
|
28
|
+
"""Interactively create or update a named profile."""
|
|
29
|
+
config_path = config_path_from_option(config)
|
|
30
|
+
cfg = load_config(config_path)
|
|
31
|
+
|
|
32
|
+
console.print(f"[bold]Creating/updating profile:[/bold] [cyan]{profile}[/cyan]")
|
|
33
|
+
console.print("(Press Enter to accept the default shown in brackets)\n")
|
|
34
|
+
|
|
35
|
+
existing = cfg.profiles.get(profile)
|
|
36
|
+
|
|
37
|
+
# Source
|
|
38
|
+
console.print("[bold yellow]Source ClickHouse[/bold yellow]")
|
|
39
|
+
src_host = typer.prompt(
|
|
40
|
+
" Host", default=existing.src.host if existing else "http://localhost:8123"
|
|
41
|
+
)
|
|
42
|
+
src_user = typer.prompt(" User", default=existing.src.user if existing else "default")
|
|
43
|
+
src_password = typer.prompt(
|
|
44
|
+
" Password",
|
|
45
|
+
default=existing.src.password if existing else "",
|
|
46
|
+
hide_input=True,
|
|
47
|
+
)
|
|
48
|
+
src_database = typer.prompt(
|
|
49
|
+
" Database", default=existing.src.database if existing else "default"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# Destination
|
|
53
|
+
console.print("\n[bold yellow]Destination ClickHouse[/bold yellow]")
|
|
54
|
+
dst_host = typer.prompt(
|
|
55
|
+
" Host", default=existing.dst.host if existing else "http://localhost:8123"
|
|
56
|
+
)
|
|
57
|
+
dst_user = typer.prompt(" User", default=existing.dst.user if existing else "default")
|
|
58
|
+
dst_password = typer.prompt(
|
|
59
|
+
" Password",
|
|
60
|
+
default=existing.dst.password if existing else "",
|
|
61
|
+
hide_input=True,
|
|
62
|
+
)
|
|
63
|
+
dst_database = typer.prompt(
|
|
64
|
+
" Database", default=existing.dst.database if existing else "default"
|
|
65
|
+
)
|
|
66
|
+
dst_tcp = typer.prompt(
|
|
67
|
+
" TCP hostport (host:port, used for remote() INSERT)",
|
|
68
|
+
default=existing.dst.tcp_hostport if existing and existing.dst.tcp_hostport else "",
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
copy_profile = CopyProfile(
|
|
72
|
+
src=InstanceConfig(
|
|
73
|
+
host=src_host, user=src_user, password=src_password, database=src_database
|
|
74
|
+
),
|
|
75
|
+
dst=InstanceConfig(
|
|
76
|
+
host=dst_host,
|
|
77
|
+
user=dst_user,
|
|
78
|
+
password=dst_password,
|
|
79
|
+
database=dst_database,
|
|
80
|
+
tcp_hostport=dst_tcp or None,
|
|
81
|
+
),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
cfg.profiles[profile] = copy_profile
|
|
85
|
+
if not cfg.default_profile or profile == "default":
|
|
86
|
+
cfg.default_profile = profile
|
|
87
|
+
|
|
88
|
+
save_config(cfg, config_path)
|
|
89
|
+
console.print(f"\n[green]Profile '{profile}' saved to {config_path}[/green]")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@config_app.command("show")
|
|
93
|
+
def show(
|
|
94
|
+
profile: str | None = typer.Option(None, "--profile", "-p", help="Profile name"),
|
|
95
|
+
config: str | None = typer.Option(None, "--config", "-c", help="Config file path"),
|
|
96
|
+
) -> None:
|
|
97
|
+
"""Print the current profile (password masked)."""
|
|
98
|
+
config_path = config_path_from_option(config)
|
|
99
|
+
cfg = load_config(config_path)
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
prof = cfg.get_profile(profile)
|
|
103
|
+
except KeyError as exc:
|
|
104
|
+
console.print(f"[red]{exc}[/red]")
|
|
105
|
+
raise typer.Exit(1) from exc
|
|
106
|
+
|
|
107
|
+
name = profile or cfg.default_profile
|
|
108
|
+
|
|
109
|
+
table = Table(title=f"Profile: {name}", show_header=True)
|
|
110
|
+
table.add_column("Field", style="bold")
|
|
111
|
+
table.add_column("Source")
|
|
112
|
+
table.add_column("Destination")
|
|
113
|
+
|
|
114
|
+
table.add_row("Host", prof.src.host, prof.dst.host)
|
|
115
|
+
table.add_row("User", prof.src.user, prof.dst.user)
|
|
116
|
+
table.add_row("Password", "***", "***")
|
|
117
|
+
table.add_row("Database", prof.src.database, prof.dst.database)
|
|
118
|
+
table.add_row("Timeout", str(prof.src.timeout), str(prof.dst.timeout))
|
|
119
|
+
table.add_row("TCP hostport", "-", prof.dst.tcp_hostport or "-")
|
|
120
|
+
|
|
121
|
+
console.print(table)
|
|
122
|
+
|
|
123
|
+
console.print(f"\n[dim]copy_by_partitions:[/dim] {prof.copy_by_partitions}")
|
|
124
|
+
console.print(f"[dim]convert_replicated_to_merge:[/dim] {prof.convert_replicated_to_merge}")
|
|
125
|
+
console.print(f"[dim]retry_count:[/dim] {prof.retry_count}")
|
|
126
|
+
console.print(f"[dim]retry_sleep:[/dim] {prof.retry_sleep}s max {prof.retry_max_sleep}s")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@config_app.command("list")
|
|
130
|
+
def list_profiles(
|
|
131
|
+
config: str | None = typer.Option(None, "--config", "-c", help="Config file path"),
|
|
132
|
+
) -> None:
|
|
133
|
+
"""List all available profiles."""
|
|
134
|
+
config_path = config_path_from_option(config)
|
|
135
|
+
cfg = load_config(config_path)
|
|
136
|
+
|
|
137
|
+
if not cfg.profiles:
|
|
138
|
+
console.print(
|
|
139
|
+
"[yellow]No profiles configured. Run `charon config init` to create one.[/yellow]"
|
|
140
|
+
)
|
|
141
|
+
return
|
|
142
|
+
|
|
143
|
+
table = Table(show_header=True, header_style="bold")
|
|
144
|
+
table.add_column("Profile")
|
|
145
|
+
table.add_column("Src host")
|
|
146
|
+
table.add_column("Src DB")
|
|
147
|
+
table.add_column("Dst host")
|
|
148
|
+
table.add_column("Dst DB")
|
|
149
|
+
table.add_column("Default")
|
|
150
|
+
|
|
151
|
+
for name, prof in cfg.profiles.items():
|
|
152
|
+
is_default = "[green]✓[/green]" if name == cfg.default_profile else ""
|
|
153
|
+
table.add_row(
|
|
154
|
+
name, prof.src.host, prof.src.database, prof.dst.host, prof.dst.database, is_default
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
console.print(table)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@config_app.command("delete")
|
|
161
|
+
def delete(
|
|
162
|
+
profile_name: str = typer.Argument(..., help="Profile name to delete"),
|
|
163
|
+
config: str | None = typer.Option(None, "--config", "-c", help="Config file path"),
|
|
164
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
|
|
165
|
+
) -> None:
|
|
166
|
+
"""Remove a profile."""
|
|
167
|
+
config_path = config_path_from_option(config)
|
|
168
|
+
cfg = load_config(config_path)
|
|
169
|
+
|
|
170
|
+
if profile_name not in cfg.profiles:
|
|
171
|
+
console.print(f"[red]Profile '{profile_name}' not found.[/red]")
|
|
172
|
+
raise typer.Exit(1)
|
|
173
|
+
|
|
174
|
+
if not yes:
|
|
175
|
+
confirmed = typer.confirm(f"Delete profile '{profile_name}'?")
|
|
176
|
+
if not confirmed:
|
|
177
|
+
raise typer.Abort()
|
|
178
|
+
|
|
179
|
+
del cfg.profiles[profile_name]
|
|
180
|
+
if cfg.default_profile == profile_name:
|
|
181
|
+
cfg.default_profile = next(iter(cfg.profiles), "default")
|
|
182
|
+
|
|
183
|
+
save_config(cfg, config_path)
|
|
184
|
+
console.print(f"[green]Profile '{profile_name}' deleted.[/green]")
|
charon/cli/copy_cmd.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""CLI command: copy — copy tables from src to dst with rich progress."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.progress import BarColumn, Progress, SpinnerColumn, TaskID, TextColumn, TimeElapsedColumn
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
|
|
11
|
+
from charon.client import CHClient, RetryConfig
|
|
12
|
+
from charon.config import CopyProfile
|
|
13
|
+
from charon.copy import copy_database, should_copy_table
|
|
14
|
+
|
|
15
|
+
console = Console()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class CopySummary:
|
|
20
|
+
tables_copied: int = 0
|
|
21
|
+
tables_skipped: int = 0
|
|
22
|
+
tables_errored: int = 0
|
|
23
|
+
partitions_copied: int = 0
|
|
24
|
+
errors: list[tuple[str, str]] = field(default_factory=list)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class RichCopyCallback:
|
|
28
|
+
"""Implements CopyCallback using Rich progress bars."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, progress: Progress, summary: CopySummary, dry_run: bool = False) -> None:
|
|
31
|
+
self._progress = progress
|
|
32
|
+
self._summary = summary
|
|
33
|
+
self._dry_run = dry_run
|
|
34
|
+
self._overall_task: TaskID | None = None
|
|
35
|
+
self._partition_task: TaskID | None = None
|
|
36
|
+
|
|
37
|
+
def set_overall_task(self, task_id: TaskID, total: int) -> None:
|
|
38
|
+
self._overall_task = task_id
|
|
39
|
+
self._progress.update(task_id, total=total)
|
|
40
|
+
|
|
41
|
+
def on_table_start(self, table: str, engine: str, partitions: int) -> None:
|
|
42
|
+
prefix = "[DRY RUN] " if self._dry_run else ""
|
|
43
|
+
if self._partition_task is not None:
|
|
44
|
+
self._progress.remove_task(self._partition_task)
|
|
45
|
+
self._partition_task = self._progress.add_task(
|
|
46
|
+
f"{prefix}[cyan]{table}[/cyan] ({engine})",
|
|
47
|
+
total=max(partitions, 1),
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
def on_partition_start(self, table: str, partition_id: str, rows: int) -> None:
|
|
51
|
+
if self._partition_task is not None:
|
|
52
|
+
self._progress.update(
|
|
53
|
+
self._partition_task,
|
|
54
|
+
description=f" partition [yellow]{partition_id}[/yellow] ({rows:,} rows)",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
def on_partition_done(self, table: str, partition_id: str) -> None:
|
|
58
|
+
self._summary.partitions_copied += 1
|
|
59
|
+
if self._partition_task is not None:
|
|
60
|
+
self._progress.advance(self._partition_task)
|
|
61
|
+
|
|
62
|
+
def on_table_done(self, table: str) -> None:
|
|
63
|
+
self._summary.tables_copied += 1
|
|
64
|
+
if self._overall_task is not None:
|
|
65
|
+
self._progress.advance(self._overall_task)
|
|
66
|
+
if self._partition_task is not None:
|
|
67
|
+
self._progress.update(self._partition_task, visible=False)
|
|
68
|
+
|
|
69
|
+
def on_table_skip(self, table: str, reason: str) -> None:
|
|
70
|
+
self._summary.tables_skipped += 1
|
|
71
|
+
console.print(f" [dim]SKIP[/dim] {table} [dim]{reason}[/dim]")
|
|
72
|
+
if self._overall_task is not None:
|
|
73
|
+
self._progress.advance(self._overall_task)
|
|
74
|
+
|
|
75
|
+
def on_error(self, table: str, error: Exception) -> None:
|
|
76
|
+
self._summary.tables_errored += 1
|
|
77
|
+
self._summary.errors.append((table, str(error)))
|
|
78
|
+
console.print(f" [red]ERROR[/red] {table}: {error}")
|
|
79
|
+
if self._overall_task is not None:
|
|
80
|
+
self._progress.advance(self._overall_task)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def run_copy(
|
|
84
|
+
profile: CopyProfile,
|
|
85
|
+
table_name: str | None = None,
|
|
86
|
+
tables: list[str] | None = None,
|
|
87
|
+
dry_run: bool = False,
|
|
88
|
+
no_partitions: bool = False,
|
|
89
|
+
) -> None:
|
|
90
|
+
"""Orchestrate the copy operation with live progress display."""
|
|
91
|
+
retry = RetryConfig(
|
|
92
|
+
retry_count=profile.retry_count,
|
|
93
|
+
retry_sleep=profile.retry_sleep,
|
|
94
|
+
retry_max_sleep=profile.retry_max_sleep,
|
|
95
|
+
)
|
|
96
|
+
src = CHClient(profile.src, retry=retry)
|
|
97
|
+
dst = CHClient(profile.dst, retry=retry)
|
|
98
|
+
|
|
99
|
+
# Resolve which tables to copy
|
|
100
|
+
if table_name:
|
|
101
|
+
table_filter: list[str] | None = [table_name]
|
|
102
|
+
elif tables:
|
|
103
|
+
table_filter = tables
|
|
104
|
+
else:
|
|
105
|
+
table_filter = None
|
|
106
|
+
|
|
107
|
+
# Fetch full table list to determine total work
|
|
108
|
+
src_tables = src.get_tables(profile.src.database)
|
|
109
|
+
if table_filter:
|
|
110
|
+
src_tables = [t for t in src_tables if str(t["name"]) in table_filter]
|
|
111
|
+
|
|
112
|
+
eligible = [
|
|
113
|
+
t
|
|
114
|
+
for t in src_tables
|
|
115
|
+
if str(t["name"]) not in profile.skip_tables
|
|
116
|
+
and should_copy_table(str(t.get("engine", "")), profile)[0]
|
|
117
|
+
]
|
|
118
|
+
|
|
119
|
+
if dry_run:
|
|
120
|
+
console.print("[bold yellow]DRY RUN — no data will be copied[/bold yellow]\n")
|
|
121
|
+
tbl = Table(show_header=True, header_style="bold")
|
|
122
|
+
tbl.add_column("Table", style="cyan")
|
|
123
|
+
tbl.add_column("Engine")
|
|
124
|
+
tbl.add_column("Src rows", justify="right")
|
|
125
|
+
for t in eligible:
|
|
126
|
+
tbl.add_row(str(t["name"]), str(t.get("engine", "")), str(t.get("total_rows") or 0))
|
|
127
|
+
console.print(tbl)
|
|
128
|
+
console.print(f"\n[dim]{len(eligible)} table(s) would be copied.[/dim]")
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
summary = CopySummary()
|
|
132
|
+
|
|
133
|
+
eff_profile = profile
|
|
134
|
+
if no_partitions:
|
|
135
|
+
eff_profile = profile.model_copy(update={"copy_by_partitions": False})
|
|
136
|
+
|
|
137
|
+
with Progress(
|
|
138
|
+
SpinnerColumn(),
|
|
139
|
+
TextColumn("[progress.description]{task.description}"),
|
|
140
|
+
BarColumn(),
|
|
141
|
+
TextColumn("{task.completed}/{task.total}"),
|
|
142
|
+
TimeElapsedColumn(),
|
|
143
|
+
console=console,
|
|
144
|
+
transient=False,
|
|
145
|
+
) as progress:
|
|
146
|
+
cb = RichCopyCallback(progress, summary, dry_run=dry_run)
|
|
147
|
+
overall = progress.add_task("[bold]Overall progress[/bold]", total=len(eligible))
|
|
148
|
+
cb.set_overall_task(overall, len(eligible))
|
|
149
|
+
|
|
150
|
+
copy_database(
|
|
151
|
+
src=src,
|
|
152
|
+
dst=dst,
|
|
153
|
+
profile=eff_profile,
|
|
154
|
+
callback=cb,
|
|
155
|
+
table_filter=table_filter,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
# Summary table
|
|
159
|
+
console.print("\n[bold]Copy summary[/bold]")
|
|
160
|
+
result_table = Table(show_header=False)
|
|
161
|
+
result_table.add_column("Label", style="dim")
|
|
162
|
+
result_table.add_column("Value", style="bold")
|
|
163
|
+
result_table.add_row("Tables copied", f"[green]{summary.tables_copied}[/green]")
|
|
164
|
+
result_table.add_row("Tables skipped", str(summary.tables_skipped))
|
|
165
|
+
errored_val = f"[red]{summary.tables_errored}[/red]" if summary.tables_errored else "0"
|
|
166
|
+
result_table.add_row("Tables errored", errored_val)
|
|
167
|
+
result_table.add_row("Partitions copied", str(summary.partitions_copied))
|
|
168
|
+
console.print(result_table)
|
|
169
|
+
|
|
170
|
+
if summary.errors:
|
|
171
|
+
console.print("\n[bold red]Errors:[/bold red]")
|
|
172
|
+
for tbl_name, err in summary.errors:
|
|
173
|
+
console.print(f" [red]{tbl_name}[/red]: {err}")
|