ch-migrate-cli 0.5.0__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.
ch_migrate/display.py ADDED
@@ -0,0 +1,450 @@
1
+ """Rich terminal rendering for migration history and status."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from rich.console import Console, Group
8
+ from rich.panel import Panel
9
+ from rich.table import Table
10
+ from rich.text import Text
11
+ from rich.tree import Tree
12
+
13
+ from ch_migrate import ui
14
+ from ch_migrate.rebase import RevisionGraph
15
+
16
+
17
+ def _short_error(error: str) -> str:
18
+ """Extract a concise message from verbose connection errors."""
19
+ # clickhouse_connect wraps urllib3 errors in nested messages.
20
+ # Pull out the innermost bracketed reason if present.
21
+ import re
22
+
23
+ inner = re.search(r"\[Errno \d+\] (.+?)(?:\)|\")", error)
24
+ if inner:
25
+ return inner.group(1)
26
+ # Fall back to first line
27
+ return error.split("\n")[0][:120]
28
+
29
+
30
+ def _format_revision(
31
+ revision: str,
32
+ graph: RevisionGraph,
33
+ applied_revisions: set[str] | None,
34
+ heads: set[str],
35
+ ) -> str:
36
+ """Format a single revision line."""
37
+ migration = graph.migrations.get(revision)
38
+ desc = migration.description if migration else ""
39
+ short_rev = revision[:8]
40
+
41
+ if applied_revisions is None:
42
+ line = f" [dim]\u2500 {short_rev}[/dim] {desc}"
43
+ elif revision in applied_revisions:
44
+ line = f" [green]\u2713 {short_rev}[/green] {desc}"
45
+ else:
46
+ line = f" [yellow]\u25cb {short_rev}[/yellow] {desc}"
47
+
48
+ if revision in heads:
49
+ line += " [bold cyan](HEAD)[/bold cyan]"
50
+
51
+ return line
52
+
53
+
54
+ def render_history(
55
+ graph: RevisionGraph,
56
+ applied_revisions: set[str] | None,
57
+ db_error: str | None = None,
58
+ console: Console | None = None,
59
+ ) -> None:
60
+ """Render migration history as a flat list, newest first.
61
+
62
+ Linear chains render flat (like git log). Branches are shown as
63
+ indented sections only where the graph actually forks.
64
+
65
+ Args:
66
+ graph: Parsed revision graph from migration files.
67
+ applied_revisions: Set of applied revision IDs, or None if DB unreachable.
68
+ db_error: Error message if DB connection failed.
69
+ console: Optional Console for testability.
70
+ """
71
+ console = console or ui.out
72
+
73
+ if db_error:
74
+ console.print(
75
+ Text(f"! Could not read applied migrations: {_short_error(db_error)}", style="yellow")
76
+ )
77
+ console.print("[dim]Showing local files only; applied status is unknown.[/dim]")
78
+ console.print()
79
+
80
+ heads = set(graph.heads())
81
+ roots = graph.children.get(None, [])
82
+
83
+ if not roots:
84
+ console.print("[dim]No migrations found.[/dim]")
85
+ return
86
+
87
+ # Build ordered list from heads back to roots (newest first).
88
+ ordered = _build_display_order(graph)
89
+
90
+ console.print("[bold]Migration History[/bold]")
91
+ console.print()
92
+
93
+ visited: set[str] = set()
94
+ for revision in ordered:
95
+ if revision in visited:
96
+ continue
97
+ visited.add(revision)
98
+ console.print(_format_revision(revision, graph, applied_revisions, heads))
99
+
100
+ console.print()
101
+
102
+
103
+ def _build_display_order(graph: RevisionGraph) -> list[str]:
104
+ """Build a newest-first display order by walking from heads to roots.
105
+
106
+ For a linear chain this produces a simple reverse-chronological list.
107
+ For branches, interleaves them by walking each head fully before the next.
108
+ Falls back to all revisions if no heads are found (e.g., due to a cycle).
109
+ """
110
+ heads = graph.heads()
111
+ visited: set[str] = set()
112
+ ordered: list[str] = []
113
+
114
+ for head in heads:
115
+ chain = graph.walk_to_root(head)
116
+ for rev in chain:
117
+ if rev not in visited:
118
+ visited.add(rev)
119
+ ordered.append(rev)
120
+
121
+ # Fallback: include any revisions not reachable from heads (e.g., cycles).
122
+ for rev in graph.migrations:
123
+ if rev not in visited:
124
+ ordered.append(rev)
125
+
126
+ return ordered
127
+
128
+
129
+ def render_status(
130
+ env_name: str,
131
+ env_config: dict[str, Any],
132
+ graph: RevisionGraph,
133
+ applied_revisions: set[str] | None,
134
+ db_error: str | None = None,
135
+ console: Console | None = None,
136
+ ) -> None:
137
+ """Render migration status as a Rich panel.
138
+
139
+ Args:
140
+ env_name: Environment name (e.g., "dev").
141
+ env_config: Environment config dict.
142
+ graph: Parsed revision graph.
143
+ applied_revisions: Set of applied revision IDs, or None if DB unreachable.
144
+ db_error: Error message if DB connection failed.
145
+ console: Optional Console for testability.
146
+ """
147
+ console = console or ui.out
148
+
149
+ env_table = Table(show_header=False, box=None, padding=(0, 2))
150
+ env_table.add_column(style="bold")
151
+ env_table.add_column()
152
+ env_table.add_row("Host", env_config.get("host", "unknown"))
153
+ env_table.add_row("Database", env_config.get("database", "unknown"))
154
+ env_table.add_row("User", env_config.get("migration_user") or env_config.get("user", "unknown"))
155
+
156
+ all_revisions = set(graph.migrations.keys())
157
+ heads = graph.heads()
158
+
159
+ status_text = Text()
160
+
161
+ if db_error:
162
+ # The full error is printed below the panel, where it can wrap.
163
+ status_text.append("\nApplied: ", style="bold")
164
+ status_text.append("unknown (database unreachable)", style="yellow")
165
+ status_text.append("\nOn disk: ", style="bold")
166
+ status_text.append(f"{len(all_revisions)}")
167
+ status_text.append("\nLocal heads: ", style="bold")
168
+ status_text.append(", ".join(h[:8] for h in heads) or "none")
169
+ else:
170
+ applied = applied_revisions or set()
171
+ pending = all_revisions - applied
172
+ n_applied = len(applied)
173
+ n_pending = len(pending)
174
+
175
+ # Find last applied: walk from each head toward root, first hit in applied wins
176
+ last_applied_desc = "none"
177
+ for head in heads:
178
+ chain = graph.walk_to_root(head)
179
+ for rev in chain:
180
+ if rev in applied:
181
+ m = graph.migrations[rev]
182
+ desc = m.description or ""
183
+ last_applied_desc = f"{rev[:8]} {desc}"
184
+ break
185
+ if last_applied_desc != "none":
186
+ break
187
+
188
+ if n_pending == 0:
189
+ head_status = ("At head", "green")
190
+ else:
191
+ head_status = (f"Behind by {n_pending}", "yellow")
192
+
193
+ status_text.append("\nApplied: ", style="bold")
194
+ status_text.append(f"{n_applied}")
195
+ status_text.append("\nPending: ", style="bold")
196
+ status_text.append(f"{n_pending}")
197
+ status_text.append("\nLast applied: ", style="bold")
198
+ status_text.append(last_applied_desc)
199
+ status_text.append("\nHead status: ", style="bold")
200
+ status_text.append(head_status[0], style=head_status[1])
201
+
202
+ panel = Panel(
203
+ Group(env_table, status_text),
204
+ title=f"[bold]{env_name}[/bold] migration status",
205
+ title_align="left",
206
+ border_style="blue",
207
+ expand=False,
208
+ )
209
+ console.print(panel)
210
+
211
+
212
+ def render_lint_report(
213
+ report: Any,
214
+ *,
215
+ runtime: bool = False,
216
+ console: Console | None = None,
217
+ ) -> None:
218
+ """Render lint results, one finding per line, then a count.
219
+
220
+ Args:
221
+ report: LintReport with results.
222
+ runtime: Whether runtime rules were included.
223
+ console: Optional Console for testability.
224
+ """
225
+ console = console or ui.out
226
+
227
+ if not report.results:
228
+ mode = "static + runtime" if runtime else "static"
229
+ console.print(Text.assemble(("✓ ", "green"), f"No lint findings ({mode} checks)."))
230
+ return
231
+
232
+ # One finding per line, file:line first, so terminals and editors can jump to it.
233
+ for r in report.results:
234
+ is_error = r.severity.value == "error"
235
+ # Text(marker, style=...) would make that style the base for the whole line.
236
+ line = Text()
237
+ line.append("✗ " if is_error else "! ", style="bold red" if is_error else "yellow")
238
+ location = f"{r.file}:{r.line}" if r.file and r.line else (r.file or "")
239
+ line.append(location, style="bold")
240
+ line.append(f" {r.message} ")
241
+ line.append(f"[{r.rule}]", style="dim")
242
+ console.print(line)
243
+
244
+ summary_parts = []
245
+ if report.error_count:
246
+ noun = "error" if report.error_count == 1 else "errors"
247
+ summary_parts.append(f"[bold red]{report.error_count} {noun}[/bold red]")
248
+ if report.warning_count:
249
+ noun = "warning" if report.warning_count == 1 else "warnings"
250
+ summary_parts.append(f"[yellow]{report.warning_count} {noun}[/yellow]")
251
+
252
+ console.print(", ".join(summary_parts))
253
+
254
+
255
+ def render_snapshot_progress(
256
+ output_dir: str,
257
+ counts: dict[str, int],
258
+ excluded: int = 0,
259
+ console: Console | None = None,
260
+ ) -> None:
261
+ """Render snapshot completion summary.
262
+
263
+ Args:
264
+ output_dir: Path to the snapshot directory.
265
+ counts: Dict mapping object type to count (e.g. {"tables": 3, "views": 1}).
266
+ excluded: Number of objects excluded by filter.
267
+ console: Optional Console for testability.
268
+ """
269
+ console = console or ui.out
270
+
271
+ table = Table(show_header=False, box=None, padding=(0, 2))
272
+ table.add_column(style="bold")
273
+ table.add_column(justify="right")
274
+
275
+ total = 0
276
+ for obj_type, count in counts.items():
277
+ if count > 0:
278
+ table.add_row(obj_type.replace("_", " ").title(), str(count))
279
+ total += count
280
+
281
+ status_text = Text()
282
+ status_text.append(f"\nSnapshot saved to ", style="dim")
283
+ status_text.append(output_dir, style="bold")
284
+ status_text.append(f"\n{_count(total, 'object')} captured", style="green")
285
+ if excluded:
286
+ status_text.append(f", {excluded} excluded", style="dim")
287
+
288
+ panel = Panel(
289
+ Group(table, status_text),
290
+ title="[bold]Schema Snapshot[/bold]",
291
+ title_align="left",
292
+ border_style="blue",
293
+ expand=False,
294
+ )
295
+ console.print(panel)
296
+
297
+
298
+ def render_diff_report(
299
+ diffs: list[Any],
300
+ *,
301
+ console: Console | None = None,
302
+ ) -> None:
303
+ """Render schema drift, one line per finding, then a count.
304
+
305
+ Args:
306
+ diffs: List of SchemaDiff objects from compare_schemas().
307
+ console: Optional Console for testability.
308
+ """
309
+ from ch_migrate.diff import DiffStatus
310
+
311
+ console = console or ui.out
312
+
313
+ in_sync = [d for d in diffs if d.status == DiffStatus.IN_SYNC]
314
+ modified = [d for d in diffs if d.status == DiffStatus.MODIFIED]
315
+ local_only = [d for d in diffs if d.status == DiffStatus.LOCAL_ONLY]
316
+ remote_only = [d for d in diffs if d.status == DiffStatus.REMOTE_ONLY]
317
+
318
+ if not (modified or local_only or remote_only):
319
+ console.print(Text.assemble(("✓ ", "green"), f"All {_count(len(in_sync), 'object')} in sync."))
320
+ return
321
+
322
+ for d in modified:
323
+ for field in d.field_diffs:
324
+ console.print(_drift_line(d, f": {field.message}"))
325
+ for d in remote_only:
326
+ console.print(_drift_line(d, " is in the database but not in the snapshot"))
327
+ for d in local_only:
328
+ console.print(_drift_line(d, " is in the snapshot but not in the database"))
329
+
330
+ parts = []
331
+ if modified:
332
+ parts.append(f"[bold red]{len(modified)} modified[/bold red]")
333
+ if remote_only:
334
+ parts.append(f"[bold red]{len(remote_only)} only in the database[/bold red]")
335
+ if local_only:
336
+ parts.append(f"[bold red]{len(local_only)} only in the snapshot[/bold red]")
337
+ if in_sync:
338
+ parts.append(f"[green]{len(in_sync)} in sync[/green]")
339
+ console.print(", ".join(parts))
340
+
341
+
342
+ _OBJ_TYPE_STYLES = {
343
+ "table": ("bold", "T"),
344
+ "view": ("cyan", "V"),
345
+ "materialized_view": ("magenta", "MV"),
346
+ "dictionary": ("yellow", "D"),
347
+ }
348
+
349
+ _DEP_TYPE_LABELS = {
350
+ "schema": "[dim]schema[/dim]",
351
+ "data_flow": "[bold blue]data_flow[/bold blue]",
352
+ }
353
+
354
+
355
+ def render_dependency_tree(
356
+ graph: Any,
357
+ *,
358
+ console: Console | None = None,
359
+ ) -> None:
360
+ """Render a dependency graph as a Rich Tree.
361
+
362
+ Each root node (no incoming edges) gets a tree branch. Dependent objects
363
+ are shown as children with edge type annotations.
364
+
365
+ Args:
366
+ graph: A DependencyGraph from introspect.
367
+ console: Optional Console for testability.
368
+ """
369
+ console = console or ui.out
370
+
371
+ if not graph.nodes:
372
+ console.print("[dim]No objects found in database.[/dim]")
373
+ return
374
+
375
+ tree = Tree("[bold]Dependency Graph[/bold]")
376
+
377
+ # Build adjacency: source -> [(target, dep_label)]
378
+ # Deduplicate: if multiple edge types exist for the same pair, combine them
379
+ children_raw: dict[str, dict[str, list[str]]] = {name: {} for name in graph.nodes}
380
+ has_parent: set[str] = set()
381
+ for edge in graph.edges:
382
+ if edge.source in children_raw and edge.target in graph.nodes:
383
+ children_raw[edge.source].setdefault(edge.target, []).append(edge.dep_type.value)
384
+ has_parent.add(edge.target)
385
+
386
+ children_map: dict[str, list[tuple[str, str]]] = {}
387
+ for source, targets in children_raw.items():
388
+ children_map[source] = [
389
+ (target, " + ".join(dep_types)) for target, dep_types in targets.items()
390
+ ]
391
+
392
+ # Roots: nodes with no incoming edges
393
+ roots = [name for name in graph.nodes if name not in has_parent]
394
+ if not roots:
395
+ # All nodes have parents (cycles) — just show all
396
+ roots = sorted(graph.nodes.keys())
397
+
398
+ def _add_node(parent_tree: Tree, name: str, dep_label: str | None, visited: set[str]) -> None:
399
+ node = graph.nodes[name]
400
+ style, prefix = _OBJ_TYPE_STYLES.get(node.obj_type, ("", "?"))
401
+ label = f"[{style}][{prefix}][/{style}] {name}"
402
+ if dep_label:
403
+ label += f" {dep_label}"
404
+
405
+ if name in visited:
406
+ parent_tree.add(f"{label} [dim](circular)[/dim]")
407
+ return
408
+
409
+ branch = parent_tree.add(label)
410
+ visited.add(name)
411
+
412
+ for child_name, child_dep_type in children_map.get(name, []):
413
+ dep_str = _DEP_TYPE_LABELS.get(child_dep_type, child_dep_type)
414
+ _add_node(branch, child_name, dep_str, visited)
415
+
416
+ for root_name in sorted(roots):
417
+ _add_node(tree, root_name, None, set())
418
+
419
+ console.print(tree)
420
+ console.print()
421
+
422
+ # Summary
423
+ type_counts: dict[str, int] = {}
424
+ for node in graph.nodes.values():
425
+ type_counts[node.obj_type] = type_counts.get(node.obj_type, 0) + 1
426
+
427
+ parts = []
428
+ for obj_type, count in sorted(type_counts.items()):
429
+ _, prefix = _OBJ_TYPE_STYLES.get(obj_type, ("", "?"))
430
+ parts.append(f"{_count(count, obj_type.replace('_', ' '))} \\[{prefix}]")
431
+
432
+ edges = _count(len(graph.edges), "edge")
433
+ console.print(f" {', '.join(parts)} — {edges}", soft_wrap=True)
434
+
435
+
436
+ def _count(count: int, noun: str) -> str:
437
+ """`1 table`, `2 tables`, `2 dictionaries`."""
438
+ if count == 1:
439
+ return f"{count} {noun}"
440
+ return f"{count} {noun[:-1]}ies" if noun.endswith("y") else f"{count} {noun}s"
441
+
442
+
443
+ def _drift_line(diff: Any, text: str) -> Text:
444
+ """`✗ events (table): column 'country' ...`, with the object name in bold."""
445
+ line = Text()
446
+ line.append("✗ ", style="bold red")
447
+ line.append(diff.name, style="bold")
448
+ line.append(f" ({diff.obj_type.replace('_', ' ')})", style="dim")
449
+ line.append(text)
450
+ return line
@@ -0,0 +1,103 @@
1
+ """Irreversible migrations, and working out what a `down` would revert.
2
+
3
+ A migration declares itself irreversible with a module-level reason:
4
+
5
+ irreversible = "Drops column legacy_id; its data cannot be restored."
6
+
7
+ `ch-migrate down` refuses before running anything when the range it would
8
+ revert includes such a migration. The migration's own downgrade() raises
9
+ IrreversibleMigration as well, for when Alembic is run directly.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+
16
+ from ch_migrate.rebase import _MISSING, RevisionGraph, _literal_assignment
17
+
18
+ _RELATIVE = re.compile(r"^-(\d+)$")
19
+
20
+
21
+ class IrreversibleMigration(RuntimeError):
22
+ """Raised by the downgrade() of a migration that cannot be reversed."""
23
+
24
+ def __init__(self, revision: str, reason: str) -> None:
25
+ super().__init__(f"Migration {revision} is irreversible: {reason}")
26
+ self.revision = revision
27
+ self.reason = reason
28
+
29
+
30
+ def irreversible_reason(graph: RevisionGraph, revision: str) -> str | None:
31
+ """The migration's irreversible reason, or None if it can be reversed."""
32
+ migration = graph.migrations.get(revision)
33
+ if migration is None:
34
+ return None
35
+ value = _literal_assignment(migration.path.read_text(), "irreversible")
36
+ if value is _MISSING or value is None or value is False:
37
+ return None
38
+ return value if isinstance(value, str) and value.strip() else "(no reason given)"
39
+
40
+
41
+ def revisions_to_revert(graph: RevisionGraph, heads: set[str], target: str) -> list[str] | None:
42
+ """Revisions a downgrade from `heads` to `target` would revert, newest first.
43
+
44
+ Understands `base`, `-N` and a full or unique-prefix revision id. Returns
45
+ None for any other form, or when the answer depends on a merge point; the
46
+ caller then relies on each migration's own downgrade() to refuse.
47
+ """
48
+ if target == "base":
49
+ return _applied_newest_first(graph, heads, keep=set())
50
+ relative = _RELATIVE.match(target)
51
+ if relative:
52
+ return _walk_back(graph, heads, int(relative.group(1)))
53
+ revision = _resolve(graph, target)
54
+ if revision is None or revision not in _applied(graph, heads):
55
+ return None
56
+ return _applied_newest_first(graph, heads, keep=_applied(graph, {revision}))
57
+
58
+
59
+ def _applied(graph: RevisionGraph, heads: set[str]) -> set[str]:
60
+ applied: set[str] = set()
61
+ for head in heads:
62
+ applied.update(graph.walk_to_root(head))
63
+ return applied
64
+
65
+
66
+ def _applied_newest_first(graph: RevisionGraph, heads: set[str], keep: set[str]) -> list[str]:
67
+ """Applied revisions not in `keep`, ordered so children come before parents."""
68
+ remaining = _applied(graph, heads) - keep
69
+ ordered: list[str] = []
70
+ while remaining:
71
+ leaves = sorted(
72
+ rev
73
+ for rev in remaining
74
+ if not any(child in remaining for child in graph.children.get(rev, []))
75
+ )
76
+ if not leaves:
77
+ break # a cycle; leave the rest to Alembic
78
+ ordered.extend(leaves)
79
+ remaining -= set(leaves)
80
+ return ordered
81
+
82
+
83
+ def _walk_back(graph: RevisionGraph, heads: set[str], steps: int) -> list[str] | None:
84
+ """Follow a single line of history back `steps` revisions."""
85
+ if len(heads) != 1:
86
+ return None
87
+ current: str | None = next(iter(heads))
88
+ reverted: list[str] = []
89
+ for _ in range(steps):
90
+ migration = graph.migrations.get(current) if current else None
91
+ if migration is None or len(migration.down_revisions) > 1:
92
+ return None
93
+ reverted.append(migration.revision)
94
+ current = migration.down_revisions[0] if migration.down_revisions else None
95
+ return reverted
96
+
97
+
98
+ def _resolve(graph: RevisionGraph, target: str) -> str | None:
99
+ """A full revision id, or the one revision the prefix matches."""
100
+ if target in graph.migrations:
101
+ return target
102
+ matches = [rev for rev in graph.migrations if rev.startswith(target)]
103
+ return matches[0] if len(matches) == 1 else None