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/__init__.py +68 -0
- ch_migrate/authoring.py +163 -0
- ch_migrate/bootstrap.py +379 -0
- ch_migrate/cli.py +1049 -0
- ch_migrate/config.py +116 -0
- ch_migrate/connection.py +83 -0
- ch_migrate/deps.py +123 -0
- ch_migrate/diff.py +232 -0
- ch_migrate/display.py +450 -0
- ch_migrate/downgrade.py +103 -0
- ch_migrate/env.py +226 -0
- ch_migrate/helpers.py +162 -0
- ch_migrate/hooks.py +74 -0
- ch_migrate/introspect.py +732 -0
- ch_migrate/lint.py +601 -0
- ch_migrate/mv_validate.py +554 -0
- ch_migrate/py.typed +0 -0
- ch_migrate/rebase.py +308 -0
- ch_migrate/runner.py +188 -0
- ch_migrate/scaffold.py +253 -0
- ch_migrate/secrets.py +162 -0
- ch_migrate/skills/ch-migrate/SKILL.md +250 -0
- ch_migrate/sql.py +216 -0
- ch_migrate/statements.py +144 -0
- ch_migrate/templates/bootstrap/init_users.sql +56 -0
- ch_migrate/templates/project/alembic.ini.template +43 -0
- ch_migrate/templates/project/config.yaml.template +55 -0
- ch_migrate/templates/project/env.local.example.template +25 -0
- ch_migrate/templates/project/script.py.mako.template +30 -0
- ch_migrate/ui.py +79 -0
- ch_migrate_cli-0.5.0.dist-info/METADATA +448 -0
- ch_migrate_cli-0.5.0.dist-info/RECORD +36 -0
- ch_migrate_cli-0.5.0.dist-info/WHEEL +4 -0
- ch_migrate_cli-0.5.0.dist-info/entry_points.txt +2 -0
- ch_migrate_cli-0.5.0.dist-info/licenses/LICENSE +21 -0
- clickhouse_alembic/__init__.py +59 -0
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
|
ch_migrate/downgrade.py
ADDED
|
@@ -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
|