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/rebase.py
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
"""Rebase dangling migration branches onto a new parent revision."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
DownRevisions = tuple[str, ...]
|
|
12
|
+
_MISSING = object()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class Migration:
|
|
17
|
+
"""A parsed Alembic migration file."""
|
|
18
|
+
|
|
19
|
+
path: Path
|
|
20
|
+
revision: str
|
|
21
|
+
down_revision: str | None
|
|
22
|
+
down_revisions: DownRevisions = ()
|
|
23
|
+
description: str | None = None
|
|
24
|
+
create_date: str | None = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class RevisionGraph:
|
|
29
|
+
"""Graph of migration revisions built from file parsing."""
|
|
30
|
+
|
|
31
|
+
migrations: dict[str, Migration] = field(default_factory=dict)
|
|
32
|
+
children: dict[str | None, list[str]] = field(default_factory=lambda: {None: []})
|
|
33
|
+
|
|
34
|
+
def heads(self) -> list[str]:
|
|
35
|
+
"""Find revisions that have no children (tips of branches)."""
|
|
36
|
+
has_children = {parent for parent, kids in self.children.items() if kids}
|
|
37
|
+
return [rev for rev in self.migrations if rev not in has_children]
|
|
38
|
+
|
|
39
|
+
def ancestors(self, revision: str) -> set[str]:
|
|
40
|
+
"""Return all ancestors of a revision (not including itself).
|
|
41
|
+
|
|
42
|
+
Stops if a cycle is detected to prevent infinite loops.
|
|
43
|
+
"""
|
|
44
|
+
result: set[str] = set()
|
|
45
|
+
current = self.migrations.get(revision)
|
|
46
|
+
stack = list(current.down_revisions) if current else []
|
|
47
|
+
|
|
48
|
+
while stack:
|
|
49
|
+
parent = stack.pop()
|
|
50
|
+
if parent in result:
|
|
51
|
+
continue
|
|
52
|
+
result.add(parent)
|
|
53
|
+
|
|
54
|
+
parent_migration = self.migrations.get(parent)
|
|
55
|
+
if parent_migration:
|
|
56
|
+
stack.extend(reversed(parent_migration.down_revisions))
|
|
57
|
+
|
|
58
|
+
return result
|
|
59
|
+
|
|
60
|
+
def walk_to_root(self, revision: str) -> list[str]:
|
|
61
|
+
"""Walk from revision back to root, returning reachable ancestors.
|
|
62
|
+
|
|
63
|
+
Stops if a cycle is detected to prevent infinite loops.
|
|
64
|
+
"""
|
|
65
|
+
chain: list[str] = []
|
|
66
|
+
visited: set[str] = set()
|
|
67
|
+
|
|
68
|
+
def visit(rev: str) -> None:
|
|
69
|
+
if rev in visited:
|
|
70
|
+
return
|
|
71
|
+
visited.add(rev)
|
|
72
|
+
chain.append(rev)
|
|
73
|
+
|
|
74
|
+
migration = self.migrations.get(rev)
|
|
75
|
+
if not migration:
|
|
76
|
+
return
|
|
77
|
+
for parent in migration.down_revisions:
|
|
78
|
+
visit(parent)
|
|
79
|
+
|
|
80
|
+
visit(revision)
|
|
81
|
+
return chain
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _literal_assignment(content: str, name: str) -> Any:
|
|
85
|
+
"""Return a top-level assignment's literal value, or _MISSING."""
|
|
86
|
+
try:
|
|
87
|
+
module = ast.parse(content)
|
|
88
|
+
except SyntaxError:
|
|
89
|
+
return _MISSING
|
|
90
|
+
|
|
91
|
+
for statement in module.body:
|
|
92
|
+
value_node: ast.AST | None = None
|
|
93
|
+
if isinstance(statement, ast.Assign):
|
|
94
|
+
for target in statement.targets:
|
|
95
|
+
if isinstance(target, ast.Name) and target.id == name:
|
|
96
|
+
value_node = statement.value
|
|
97
|
+
break
|
|
98
|
+
elif (
|
|
99
|
+
isinstance(statement, ast.AnnAssign)
|
|
100
|
+
and isinstance(statement.target, ast.Name)
|
|
101
|
+
and statement.target.id == name
|
|
102
|
+
):
|
|
103
|
+
value_node = statement.value
|
|
104
|
+
|
|
105
|
+
if value_node is None:
|
|
106
|
+
continue
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
return ast.literal_eval(value_node)
|
|
110
|
+
except (SyntaxError, ValueError):
|
|
111
|
+
return _MISSING
|
|
112
|
+
|
|
113
|
+
return _MISSING
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _normalize_down_revisions(value: Any) -> DownRevisions:
|
|
117
|
+
if value is _MISSING or value is None:
|
|
118
|
+
return ()
|
|
119
|
+
if isinstance(value, str):
|
|
120
|
+
return (value,)
|
|
121
|
+
if isinstance(value, (list, tuple)):
|
|
122
|
+
if all(isinstance(item, str) for item in value):
|
|
123
|
+
return tuple(value)
|
|
124
|
+
return ()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def parse_migration(path: Path) -> Migration | None:
|
|
128
|
+
"""Extract revision and down_revision from a migration file.
|
|
129
|
+
|
|
130
|
+
Returns None if the file can't be parsed.
|
|
131
|
+
"""
|
|
132
|
+
content = path.read_text()
|
|
133
|
+
|
|
134
|
+
revision_value = _literal_assignment(content, "revision")
|
|
135
|
+
if isinstance(revision_value, str):
|
|
136
|
+
revision = revision_value
|
|
137
|
+
else:
|
|
138
|
+
rev_match = re.search(r"^revision\s*=\s*['\"](\w+)['\"]", content, re.MULTILINE)
|
|
139
|
+
if not rev_match:
|
|
140
|
+
return None
|
|
141
|
+
revision = rev_match.group(1)
|
|
142
|
+
|
|
143
|
+
down_revision_value = _literal_assignment(content, "down_revision")
|
|
144
|
+
down_revisions = _normalize_down_revisions(down_revision_value)
|
|
145
|
+
if down_revision_value is _MISSING:
|
|
146
|
+
down_match = re.search(r"^down_revision\s*=\s*['\"](\w+)['\"]", content, re.MULTILINE)
|
|
147
|
+
down_revisions = (down_match.group(1),) if down_match else ()
|
|
148
|
+
|
|
149
|
+
# Extract description from first line of module docstring
|
|
150
|
+
description = None
|
|
151
|
+
desc_match = re.search(r'^"""(.+?)$', content, re.MULTILINE)
|
|
152
|
+
if desc_match:
|
|
153
|
+
description = desc_match.group(1).strip()
|
|
154
|
+
|
|
155
|
+
# Extract create date
|
|
156
|
+
create_date = None
|
|
157
|
+
date_match = re.search(r"^Create Date:\s*(.+)$", content, re.MULTILINE)
|
|
158
|
+
if date_match:
|
|
159
|
+
create_date = date_match.group(1).strip()
|
|
160
|
+
|
|
161
|
+
return Migration(
|
|
162
|
+
path=path,
|
|
163
|
+
revision=revision,
|
|
164
|
+
down_revision=down_revisions[0] if len(down_revisions) == 1 else None,
|
|
165
|
+
down_revisions=down_revisions,
|
|
166
|
+
description=description,
|
|
167
|
+
create_date=create_date,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def build_revision_graph(versions_dir: Path) -> RevisionGraph:
|
|
172
|
+
"""Scan migration files and build a revision graph."""
|
|
173
|
+
graph = RevisionGraph()
|
|
174
|
+
|
|
175
|
+
for path in sorted(versions_dir.glob("*.py")):
|
|
176
|
+
migration = parse_migration(path)
|
|
177
|
+
if migration:
|
|
178
|
+
graph.migrations[migration.revision] = migration
|
|
179
|
+
parents: tuple[str | None, ...] = migration.down_revisions or (None,)
|
|
180
|
+
for parent in parents:
|
|
181
|
+
if parent not in graph.children:
|
|
182
|
+
graph.children[parent] = []
|
|
183
|
+
graph.children[parent].append(migration.revision)
|
|
184
|
+
|
|
185
|
+
return graph
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def find_branch_roots(graph: RevisionGraph, onto: str) -> list[Migration]:
|
|
189
|
+
"""Find the root migration of each dangling branch.
|
|
190
|
+
|
|
191
|
+
For each head that isn't `onto` (or a descendant of `onto`),
|
|
192
|
+
walk back to find the first migration whose down_revision is
|
|
193
|
+
an ancestor of `onto` (or is `onto` itself). That migration's
|
|
194
|
+
down_revision is what gets rewritten.
|
|
195
|
+
|
|
196
|
+
Returns the list of migrations that need their down_revision changed.
|
|
197
|
+
"""
|
|
198
|
+
heads = graph.heads()
|
|
199
|
+
onto_ancestors = graph.ancestors(onto) | {onto}
|
|
200
|
+
roots = []
|
|
201
|
+
|
|
202
|
+
for head in heads:
|
|
203
|
+
if head == onto:
|
|
204
|
+
continue
|
|
205
|
+
|
|
206
|
+
# Walk back from this head
|
|
207
|
+
chain = graph.walk_to_root(head)
|
|
208
|
+
for rev_id in chain:
|
|
209
|
+
migration = graph.migrations[rev_id]
|
|
210
|
+
if set(migration.down_revisions) & onto_ancestors:
|
|
211
|
+
roots.append(migration)
|
|
212
|
+
break
|
|
213
|
+
|
|
214
|
+
return roots
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def rewrite_down_revision(path: Path, old_rev: str, new_rev: str) -> str:
|
|
218
|
+
"""Rewrite down_revision in a migration file.
|
|
219
|
+
|
|
220
|
+
Updates both:
|
|
221
|
+
- The `down_revision = '...'` variable assignment
|
|
222
|
+
- The `Revises: ...` line in the docstring
|
|
223
|
+
|
|
224
|
+
Returns the new file content (does not write to disk).
|
|
225
|
+
"""
|
|
226
|
+
content = path.read_text()
|
|
227
|
+
|
|
228
|
+
# Replace the variable assignment
|
|
229
|
+
content = re.sub(
|
|
230
|
+
r"(^down_revision\s*=\s*['\"])" + re.escape(old_rev) + r"(['\"])",
|
|
231
|
+
r"\g<1>" + new_rev + r"\2",
|
|
232
|
+
content,
|
|
233
|
+
count=1,
|
|
234
|
+
flags=re.MULTILINE,
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
# Replace the Revises: line in the docstring
|
|
238
|
+
content = re.sub(
|
|
239
|
+
r"(^Revises:\s*)" + re.escape(old_rev),
|
|
240
|
+
r"\g<1>" + new_rev,
|
|
241
|
+
content,
|
|
242
|
+
count=1,
|
|
243
|
+
flags=re.MULTILINE,
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
return content
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
@dataclass
|
|
250
|
+
class RebaseChange:
|
|
251
|
+
"""A planned change to a migration file."""
|
|
252
|
+
|
|
253
|
+
migration: Migration
|
|
254
|
+
old_down_revision: str
|
|
255
|
+
new_down_revision: str
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def plan_rebase(versions_dir: Path, onto: str) -> list[RebaseChange]:
|
|
259
|
+
"""Plan the rebase without making changes.
|
|
260
|
+
|
|
261
|
+
Returns a list of changes that would be made.
|
|
262
|
+
Raises ValueError if onto revision is not found or nothing to do.
|
|
263
|
+
"""
|
|
264
|
+
graph = build_revision_graph(versions_dir)
|
|
265
|
+
|
|
266
|
+
if onto not in graph.migrations:
|
|
267
|
+
raise ValueError(f"Revision '{onto}' not found in migrations")
|
|
268
|
+
|
|
269
|
+
heads = graph.heads()
|
|
270
|
+
if len(heads) <= 1:
|
|
271
|
+
raise ValueError("Only one head found — nothing to rebase")
|
|
272
|
+
|
|
273
|
+
roots = find_branch_roots(graph, onto)
|
|
274
|
+
if not roots:
|
|
275
|
+
raise ValueError("No dangling branches found to rebase")
|
|
276
|
+
|
|
277
|
+
changes: list[RebaseChange] = []
|
|
278
|
+
for root in roots:
|
|
279
|
+
if len(root.down_revisions) != 1:
|
|
280
|
+
raise ValueError(
|
|
281
|
+
f"Cannot safely rebase merge revision '{root.revision}' "
|
|
282
|
+
f"with {len(root.down_revisions)} down revisions"
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
old_down_revision = root.down_revisions[0]
|
|
286
|
+
if old_down_revision == onto:
|
|
287
|
+
continue
|
|
288
|
+
|
|
289
|
+
changes.append(
|
|
290
|
+
RebaseChange(
|
|
291
|
+
migration=root,
|
|
292
|
+
old_down_revision=old_down_revision,
|
|
293
|
+
new_down_revision=onto,
|
|
294
|
+
)
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
return changes
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def apply_rebase(changes: list[RebaseChange]) -> None:
|
|
301
|
+
"""Apply planned rebase changes to disk."""
|
|
302
|
+
for change in changes:
|
|
303
|
+
new_content = rewrite_down_revision(
|
|
304
|
+
change.migration.path,
|
|
305
|
+
change.old_down_revision,
|
|
306
|
+
change.new_down_revision,
|
|
307
|
+
)
|
|
308
|
+
change.migration.path.write_text(new_content)
|
ch_migrate/runner.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Run Alembic for an environment and report what happened in ch-migrate's own words.
|
|
2
|
+
|
|
3
|
+
Alembic runs in a subprocess so each project's env.py and revision files load
|
|
4
|
+
exactly as `alembic` would load them. Its log lines are translated: each
|
|
5
|
+
"Running upgrade" becomes one line naming the migration, setup chatter is dropped,
|
|
6
|
+
and a failure is reduced to the migration, the file and the database error. The
|
|
7
|
+
full traceback is printed only with --verbose.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
import click
|
|
20
|
+
|
|
21
|
+
from ch_migrate import ui
|
|
22
|
+
from ch_migrate.config import get_env_config
|
|
23
|
+
from ch_migrate.sql import clean_database_error
|
|
24
|
+
|
|
25
|
+
_LOG_LINE = re.compile(r"^(?P<level>[A-Z]+)\s+\[(?P<logger>[^\]]+)\]\s?(?P<message>.*)$")
|
|
26
|
+
_RUNNING = re.compile(
|
|
27
|
+
r"^Running (?P<direction>upgrade|downgrade) (?P<source>\S*) -> (?P<target>\S*), ?(?P<name>.*)$"
|
|
28
|
+
)
|
|
29
|
+
_SETUP_CHATTER = ("Context impl ", "Will assume ")
|
|
30
|
+
_EXCEPTION_LINE = re.compile(r"^(?:[\w.]+\.)?(?P<kind>\w+): (?P<message>.*)$", re.DOTALL)
|
|
31
|
+
_SELF_DESCRIBING = {"SqlStatementError", "IrreversibleMigration"}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def run_migrations(environment: str, args: list[str], *, verbose: bool) -> int:
|
|
35
|
+
"""Run `alembic upgrade|downgrade TARGET`, reporting one line per migration.
|
|
36
|
+
|
|
37
|
+
Returns Alembic's exit status.
|
|
38
|
+
"""
|
|
39
|
+
progress = _Progress()
|
|
40
|
+
command = [sys.executable, "-m", "alembic", *args]
|
|
41
|
+
with subprocess.Popen(
|
|
42
|
+
command,
|
|
43
|
+
env=alembic_env(environment),
|
|
44
|
+
cwd=Path.cwd(),
|
|
45
|
+
stdout=subprocess.PIPE,
|
|
46
|
+
stderr=subprocess.STDOUT,
|
|
47
|
+
text=True,
|
|
48
|
+
) as process:
|
|
49
|
+
assert process.stdout is not None
|
|
50
|
+
for line in process.stdout:
|
|
51
|
+
_handle_line(line.rstrip("\n"), progress)
|
|
52
|
+
status = process.wait()
|
|
53
|
+
if status == 0:
|
|
54
|
+
_report_success(environment, args[-1], progress)
|
|
55
|
+
else:
|
|
56
|
+
_report_failure(environment, progress, verbose)
|
|
57
|
+
return status
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def run_alembic(environment: str, args: list[str]) -> subprocess.CompletedProcess[str]:
|
|
61
|
+
"""Run an Alembic command silently and return its captured output."""
|
|
62
|
+
return subprocess.run(
|
|
63
|
+
[sys.executable, "-m", "alembic", *args],
|
|
64
|
+
env=alembic_env(environment),
|
|
65
|
+
cwd=Path.cwd(),
|
|
66
|
+
capture_output=True,
|
|
67
|
+
text=True,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def alembic_failure(result: subprocess.CompletedProcess[str]) -> str:
|
|
72
|
+
"""The one line worth showing from a failed Alembic command."""
|
|
73
|
+
lines = (result.stderr or result.stdout).splitlines()
|
|
74
|
+
for line in lines:
|
|
75
|
+
if line.strip().startswith("FAILED:"):
|
|
76
|
+
return line.strip()[len("FAILED:") :].strip()
|
|
77
|
+
return _exception_message(lines) or f"Alembic exited with status {result.returncode}"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def alembic_env(environment: str) -> dict[str, str]:
|
|
81
|
+
"""Process environment for an Alembic subprocess targeting this environment."""
|
|
82
|
+
try:
|
|
83
|
+
env_config = get_env_config(environment, Path.cwd() / "config.yaml")
|
|
84
|
+
except Exception as exc:
|
|
85
|
+
ui.fail(f"Could not load config: {exc}")
|
|
86
|
+
env = os.environ.copy()
|
|
87
|
+
env.update(
|
|
88
|
+
CH_ENVIRONMENT=environment,
|
|
89
|
+
CH_DATABASE=env_config["database"],
|
|
90
|
+
CH_HOST=env_config["host"],
|
|
91
|
+
CH_PORT=str(env_config.get("port", 8443)),
|
|
92
|
+
CH_USER=env_config.get("migration_user") or env_config.get("user", ""),
|
|
93
|
+
CH_PASSWORD=env_config.get("password", ""),
|
|
94
|
+
CH_SECURE="1" if env_config.get("secure", True) else "0",
|
|
95
|
+
# Keep migration print() output in order with Alembic's log lines.
|
|
96
|
+
PYTHONUNBUFFERED="1",
|
|
97
|
+
)
|
|
98
|
+
return env
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass
|
|
102
|
+
class _Progress:
|
|
103
|
+
direction: str = "upgrade"
|
|
104
|
+
current: str = ""
|
|
105
|
+
count: int = 0
|
|
106
|
+
failure: str = ""
|
|
107
|
+
traceback: list[str] = field(default_factory=list)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _handle_line(line: str, progress: _Progress) -> None:
|
|
111
|
+
if progress.traceback or line.startswith("Traceback (most recent call last):"):
|
|
112
|
+
progress.traceback.append(line)
|
|
113
|
+
return
|
|
114
|
+
if line.strip().startswith("FAILED:"):
|
|
115
|
+
progress.failure = line.strip()[len("FAILED:") :].strip()
|
|
116
|
+
return
|
|
117
|
+
logged = _LOG_LINE.match(line)
|
|
118
|
+
if logged is None:
|
|
119
|
+
click.echo(line) # the migration's own output, shown as written
|
|
120
|
+
return
|
|
121
|
+
message = logged["message"]
|
|
122
|
+
running = _RUNNING.match(message)
|
|
123
|
+
if running:
|
|
124
|
+
_start_migration(running, progress)
|
|
125
|
+
elif message.startswith(_SETUP_CHATTER):
|
|
126
|
+
return
|
|
127
|
+
elif logged["level"] in ("WARNING", "WARN", "ERROR", "CRITICAL"):
|
|
128
|
+
ui.warn(message)
|
|
129
|
+
else:
|
|
130
|
+
ui.detail(message)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _start_migration(running: re.Match[str], progress: _Progress) -> None:
|
|
134
|
+
progress.direction = running["direction"]
|
|
135
|
+
revision = running["target"] if progress.direction == "upgrade" else running["source"]
|
|
136
|
+
progress.current = f"{revision[:8]} {running['name']}".rstrip()
|
|
137
|
+
progress.count += 1
|
|
138
|
+
verb = "Applying" if progress.direction == "upgrade" else "Reverting"
|
|
139
|
+
ui.step(f"{verb} {progress.current}")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _report_success(environment: str, target: str, progress: _Progress) -> None:
|
|
143
|
+
noun = "migration" if progress.count == 1 else "migrations"
|
|
144
|
+
if progress.direction == "upgrade" and progress.count == 0:
|
|
145
|
+
ui.success(f"Nothing to apply; {environment} is up to date.")
|
|
146
|
+
elif progress.direction == "upgrade":
|
|
147
|
+
where = "at head" if target == "head" else f"at {target}"
|
|
148
|
+
ui.success(f"Applied {progress.count} {noun}; {environment} is {where}.")
|
|
149
|
+
elif progress.count == 0:
|
|
150
|
+
ui.success("Nothing to revert.")
|
|
151
|
+
else:
|
|
152
|
+
ui.success(f"Reverted {progress.count} {noun}.")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _report_failure(environment: str, progress: _Progress, verbose: bool) -> None:
|
|
156
|
+
reason = _exception_message(progress.traceback) or progress.failure
|
|
157
|
+
if progress.current:
|
|
158
|
+
ui.error(f"{progress.current} failed")
|
|
159
|
+
else:
|
|
160
|
+
ui.error(f"{progress.direction.capitalize()} failed")
|
|
161
|
+
if reason:
|
|
162
|
+
ui.detail(reason, stderr=True)
|
|
163
|
+
if progress.current:
|
|
164
|
+
recorded = "applied" if progress.direction == "upgrade" else "reverted"
|
|
165
|
+
ui.hint(
|
|
166
|
+
f"It was not recorded as {recorded}. Anything it ran before the failure stays "
|
|
167
|
+
f"in place, so make the SQL safe to re-run, then run `ch-migrate "
|
|
168
|
+
f"{'up' if progress.direction == 'upgrade' else 'down'} {environment}` again.",
|
|
169
|
+
stderr=True,
|
|
170
|
+
)
|
|
171
|
+
if verbose:
|
|
172
|
+
for line in progress.traceback:
|
|
173
|
+
click.echo(line, err=True)
|
|
174
|
+
elif progress.traceback:
|
|
175
|
+
ui.hint("Add `--verbose` to see the full traceback.", stderr=True)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _exception_message(lines: list[str]) -> str:
|
|
179
|
+
"""The final exception of a Python traceback, without its module path."""
|
|
180
|
+
last_frame = max((i for i, line in enumerate(lines) if line.startswith(" ")), default=-1)
|
|
181
|
+
tail = "\n".join(line for line in lines[last_frame + 1 :] if line.strip()).strip()
|
|
182
|
+
if not tail:
|
|
183
|
+
return ""
|
|
184
|
+
found = _EXCEPTION_LINE.match(tail)
|
|
185
|
+
if found is None:
|
|
186
|
+
return clean_database_error(tail)
|
|
187
|
+
message = clean_database_error(found["message"])
|
|
188
|
+
return message if found["kind"] in _SELF_DESCRIBING else f"{found['kind']}: {message}"
|