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 ADDED
@@ -0,0 +1,3 @@
1
+ """CHaron — ClickHouse migration tool."""
2
+
3
+ __version__ = "1.0.2"
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
+ )
@@ -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}")