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/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}"