alembic-squawk 0.1.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.
@@ -0,0 +1,3 @@
1
+ from .version import __version__
2
+
3
+ __all__ = ["__version__"]
alembic_squawk/cli.py ADDED
@@ -0,0 +1,349 @@
1
+ """
2
+ Alembic SQL Dumper
3
+
4
+ This script generates SQL from Alembic in offline mode and splits the output into
5
+ per-revision SQL files, one file per migration. This makes it easier to run SQL
6
+ linters (e.g. Squawk) against individual migrations rather than the entire dump,
7
+ so lint failures map directly to a specific revision file.
8
+
9
+ Usage
10
+ -----
11
+ The script requires a --out-dir argument specifying where to write the SQL files.
12
+ The directory is wiped and recreated on every run so stale artifacts from previous
13
+ runs are never mixed with current output.
14
+
15
+ Example:
16
+
17
+ alembic-squawk --out-dir /tmp/alembic_sql
18
+
19
+ Options
20
+ -------
21
+ --revisions
22
+ The Alembic revision range to dump. Defaults to "base:head" (all migrations).
23
+ Supports any valid Alembic range, e.g. "origin/main@HEAD:head" to dump only
24
+ migrations not yet on main.
25
+
26
+ --out-dir (required)
27
+ Directory to write SQL files into. Wiped and recreated on each run.
28
+
29
+ --created-after
30
+ Filter to only write migrations created on or after a given date (YYYY-MM-DD).
31
+ Useful for narrowing output to recently added migrations.
32
+
33
+ --lint-preamble
34
+ By default, the SQL preamble emitted by Alembic before the first revision
35
+ marker is skipped. The preamble contains boilerplate SET statements and is
36
+ not associated with any specific migration. Pass this flag to write it to a
37
+ file named "000_preamble.sql" as well.
38
+
39
+ --verbose
40
+ Prints the raw Alembic command being run and any stderr output.
41
+
42
+ Output format
43
+ -------------
44
+ Each migration is written as a single .sql file named after the migration file
45
+ (e.g. "2026_03_16_d4b9da2f705c_move_constants_to_distribution_model.sql").
46
+
47
+ The outer BEGIN/COMMIT that Alembic wraps the entire dump in is stripped before
48
+ splitting so the per-file output contains only the migration's own SQL. Files are
49
+ not re-wrapped in their own transaction — pass --assume-in-transaction to Squawk
50
+ so it treats each file as already inside a transaction (required for linting
51
+ statements that are only valid within a transaction, e.g. ALTER TYPE).
52
+
53
+ Offline mode and data migrations
54
+ ---------------------------------
55
+ Alembic's --sql mode runs migrations against a MockConnection rather than a real
56
+ database. Any migration that performs data migrations (ORM queries, raw SQL
57
+ selects/updates) must guard those code paths using the is_offline_migration()
58
+ helper from migrations/utils.py, which checks op.get_context().as_sql. Only DDL
59
+ operations (CREATE TABLE, ALTER TABLE, DROP COLUMN, etc.) are emitted in offline
60
+ mode — data migration logic is silently skipped.
61
+
62
+ LOG_LEVEL is set to CRITICAL when invoking Alembic to suppress noisy application
63
+ log output that would otherwise contaminate the SQL stdout that this script parses.
64
+
65
+ Squawk ignore directives
66
+ ------------------------
67
+ Squawk supports per-file ignore directives in SQL comments:
68
+
69
+ -- squawk-ignore-file ban-drop-column
70
+
71
+ To attach these to a migration without embedding raw SQL comments in Python source,
72
+ write the directive as a Python comment with a -- prefix:
73
+
74
+ # -- squawk-ignore-file ban-drop-column
75
+
76
+ The script scans each migration's source file for these comments and prepends the
77
+ corresponding SQL directives to the generated .sql file so Squawk honours them.
78
+ Note: directives written inside docstrings (without a leading #) are not detected.
79
+ """
80
+
81
+ import os
82
+ import re
83
+ import shutil
84
+ import subprocess
85
+ from dataclasses import dataclass, field
86
+ from datetime import date, datetime
87
+ from pathlib import Path
88
+
89
+ import click
90
+
91
+ # Matches the Alembic progress comment that appears at the start of each
92
+ # revision block in --sql output, e.g.:
93
+ # -- Running upgrade abc123 -> def456
94
+ # Captures the destination revision ID (the "head" side of the arrow).
95
+ REVISION_RE = re.compile(r"^--\s+Running upgrade .*?->\s+([^\s]+)")
96
+
97
+ # Matches the revision ID assignment in a migration file, handling both plain
98
+ # and annotated forms:
99
+ # revision = 'abc123'
100
+ # revision: str = 'abc123'
101
+ REVISION_ID_RE = re.compile(
102
+ r"^revision(?::\s*str)?\s*=\s*['\"]([^'\"]+)['\"]", re.MULTILINE
103
+ )
104
+
105
+ # Matches the "Create Date:" line written by Alembic into each migration file's
106
+ # header comment, e.g.:
107
+ # # Create Date: 2026-03-16 10:22:01.123456
108
+ # Captures the date portion only (YYYY-MM-DD).
109
+ CREATE_DATE_RE = re.compile(r"^Create Date: (\d{4}-\d{2}-\d{2})", re.MULTILINE)
110
+
111
+ # Matches squawk-ignore-file directives embedded as Python comments in migration
112
+ # source files, e.g.:
113
+ # # -- squawk-ignore-file ban-drop-column
114
+ # These are extracted and re-emitted as plain SQL comments in the generated
115
+ # output so Squawk honours them when linting the split files.
116
+ SQUAWK_IGNORE_RE = re.compile(r"^#\s*(--\s*squawk-ignore-file\s+\S+)", re.MULTILINE)
117
+
118
+
119
+ @dataclass
120
+ class SqlChunk:
121
+ name: str
122
+ created_date: date | None = None
123
+ squawk_ignores: list[str] = field(default_factory=list)
124
+ lines: list[str] = field(default_factory=list)
125
+
126
+ @property
127
+ def has_content(self) -> bool:
128
+ """Return True if this chunk contains any non-blank lines."""
129
+ return any(line.strip() for line in self.lines)
130
+
131
+ @property
132
+ def is_preamble(self) -> bool:
133
+ return self.name == "_preamble"
134
+
135
+
136
+ def check_dependencies() -> None:
137
+ """Ensure required CLI tools are available in PATH."""
138
+ missing = [tool for tool in ("alembic",) if not shutil.which(tool)]
139
+ if missing:
140
+ raise click.ClickException(
141
+ "Missing required executables in PATH: " + ", ".join(missing)
142
+ )
143
+
144
+
145
+ def generate_alembic_sql(revisions: str, verbose: bool = False) -> str:
146
+ """Run Alembic in offline mode to generate raw SQL."""
147
+ cmd = ["alembic", "upgrade", revisions, "--sql"]
148
+ click.secho(f"Generating Alembic SQL dump for {revisions}...", fg="blue", bold=True)
149
+
150
+ if verbose:
151
+ click.echo(f"$ {' '.join(cmd)}")
152
+
153
+ result = subprocess.run(
154
+ cmd,
155
+ capture_output=True,
156
+ text=True,
157
+ check=False,
158
+ env={**os.environ, "LOG_LEVEL": "CRITICAL"},
159
+ )
160
+
161
+ if result.returncode != 0:
162
+ stderr = result.stderr.strip()
163
+ message = f"Alembic failed with exit code {result.returncode}."
164
+ if stderr:
165
+ message += f"\n\n{stderr}"
166
+ raise click.ClickException(message)
167
+
168
+ if verbose and result.stderr.strip():
169
+ click.secho("Alembic stderr:", fg="yellow", bold=True)
170
+ click.echo(result.stderr)
171
+
172
+ return result.stdout
173
+
174
+
175
+ def build_revision_map() -> dict[str, tuple[str, date | None, list[str]]]:
176
+ versions_dir = Path("migrations/versions")
177
+ mapping = {}
178
+ if not versions_dir.exists():
179
+ return mapping
180
+ for py_file in versions_dir.glob("*.py"):
181
+ try:
182
+ content = py_file.read_text(encoding="utf-8")
183
+ rev_match = REVISION_ID_RE.search(content)
184
+ if rev_match:
185
+ rev_id = rev_match.group(1)
186
+ chunk_name = py_file.stem
187
+
188
+ date_match = CREATE_DATE_RE.search(content)
189
+ created_date = (
190
+ date.fromisoformat(date_match.group(1)) if date_match else None
191
+ )
192
+
193
+ squawk_ignores = SQUAWK_IGNORE_RE.findall(content)
194
+
195
+ mapping[rev_id] = (chunk_name, created_date, squawk_ignores)
196
+ except Exception:
197
+ pass
198
+ return mapping
199
+
200
+
201
+ def split_sql_dump(
202
+ sql_dump: str, revision_map: dict[str, tuple[str, date | None, list[str]]]
203
+ ) -> list[SqlChunk]:
204
+ """
205
+ Split a single Alembic SQL dump into ordered per-revision chunks.
206
+
207
+ Any content before the first '-- Running upgrade ...' marker is placed into
208
+ a special '_preamble' chunk.
209
+ """
210
+ lines = sql_dump.splitlines()
211
+
212
+ # Remove the first BEGIN; to avoid unbalanced transactions when split
213
+ for i, line in enumerate(lines):
214
+ if line.strip() == "BEGIN;":
215
+ lines.pop(i)
216
+ break
217
+
218
+ # Remove the last COMMIT;
219
+ for i in range(len(lines) - 1, -1, -1):
220
+ if lines[i].strip() == "COMMIT;":
221
+ lines.pop(i)
222
+ break
223
+
224
+ chunks: list[SqlChunk] = [SqlChunk("_preamble")]
225
+ current_chunk = chunks[0]
226
+
227
+ for line in lines:
228
+ match = REVISION_RE.search(line)
229
+ if match:
230
+ rev_id = match.group(1)
231
+ if rev_id in revision_map:
232
+ chunk_name, created_date, squawk_ignores = revision_map[rev_id]
233
+ else:
234
+ chunk_name, created_date, squawk_ignores = rev_id, None, []
235
+
236
+ current_chunk = SqlChunk(
237
+ name=chunk_name,
238
+ created_date=created_date,
239
+ squawk_ignores=squawk_ignores,
240
+ )
241
+ chunks.append(current_chunk)
242
+ current_chunk.lines.append(line)
243
+
244
+ return [chunk for chunk in chunks if chunk.has_content]
245
+
246
+
247
+ def write_chunks(
248
+ chunks: list[SqlChunk],
249
+ out_dir: Path,
250
+ *,
251
+ skip_preamble: bool = True,
252
+ created_after: date | None = None,
253
+ ) -> tuple[list[Path], int]:
254
+ """Write SQL chunks to disk and return the created file paths."""
255
+ writable = [c for c in chunks if not (skip_preamble and c.is_preamble)]
256
+ if not writable:
257
+ return ([], 0)
258
+
259
+ written_files: list[Path] = []
260
+
261
+ skipped = 0
262
+ for chunk in writable:
263
+ if created_after and chunk.created_date and chunk.created_date < created_after:
264
+ skipped += 1
265
+ continue
266
+
267
+ if chunk.name == "_preamble":
268
+ filename = "000_preamble.sql"
269
+ else:
270
+ filename = f"{chunk.name}.sql"
271
+
272
+ file_path = out_dir / filename
273
+ date_comment = (
274
+ f"-- Created at: {chunk.created_date}\n" if chunk.created_date else ""
275
+ )
276
+ ignore_comments = "".join(f"{d}\n" for d in chunk.squawk_ignores)
277
+ chunk_content = date_comment + ignore_comments + "\n".join(chunk.lines) + "\n"
278
+ file_path.write_text(chunk_content, encoding="utf-8")
279
+ written_files.append(file_path)
280
+
281
+ return written_files, skipped
282
+
283
+
284
+ @click.command()
285
+ @click.option(
286
+ "--revisions",
287
+ default="base:head",
288
+ envvar="ALEMBIC_SQUAWK_REVISIONS",
289
+ show_envvar=True,
290
+ help="Alembic revision range to dump (e.g. base:head, origin/main@HEAD:head).",
291
+ )
292
+ @click.option(
293
+ "--out-dir",
294
+ type=click.Path(file_okay=False, dir_okay=True, path_type=Path),
295
+ required=True,
296
+ help="Output directory for generated SQL files.",
297
+ )
298
+ @click.option(
299
+ "--created-after",
300
+ type=click.DateTime(formats=["%Y-%m-%d"]),
301
+ help="Only output migrations created on or after this date (YYYY-MM-DD).",
302
+ )
303
+ @click.option(
304
+ "--lint-preamble",
305
+ is_flag=True,
306
+ help="Also output the SQL preamble emitted before the first revision marker.",
307
+ )
308
+ @click.option(
309
+ "--verbose",
310
+ is_flag=True,
311
+ help="Print executed commands and additional diagnostic output.",
312
+ )
313
+ def main(
314
+ revisions: str,
315
+ out_dir: Path,
316
+ created_after: datetime | None,
317
+ lint_preamble: bool,
318
+ verbose: bool,
319
+ ) -> None:
320
+ """
321
+ Extracts raw SQL from Alembic, splits it by revision, and saves each file individually.
322
+ """
323
+ click.secho("Starting Alembic SQL Dumper...\n", bold=True)
324
+
325
+ check_dependencies()
326
+ revision_map = build_revision_map()
327
+ sql_dump = generate_alembic_sql(revisions, verbose=verbose)
328
+ chunks = split_sql_dump(sql_dump, revision_map)
329
+
330
+ if out_dir.exists():
331
+ shutil.rmtree(out_dir)
332
+ out_dir.mkdir(parents=True)
333
+
334
+ created_after_date = created_after.date() if created_after else None
335
+ sql_files, skipped = write_chunks(
336
+ chunks,
337
+ out_dir,
338
+ skip_preamble=not lint_preamble,
339
+ created_after=created_after_date,
340
+ )
341
+
342
+ msg = f"Wrote {len(sql_files)} revision file(s) to {out_dir}/"
343
+ if skipped:
344
+ msg += f" ({skipped} skipped by --created-after filter)"
345
+ click.secho(msg, fg="green")
346
+
347
+
348
+ if __name__ == "__main__":
349
+ main()
@@ -0,0 +1,34 @@
1
+ """Version handling for alembic-squawk."""
2
+
3
+ import importlib.metadata
4
+ from pathlib import Path
5
+
6
+
7
+ def is_local_source_checkout() -> bool:
8
+ """Check if the code is running from a local source checkout."""
9
+ package_dir = Path(__file__).resolve().parent
10
+ # Since this is a flat layout (module-root = ""), repo root is the parent of the package dir
11
+ repo_root = package_dir.parent
12
+
13
+ return (repo_root / ".git").exists() and (repo_root / "pyproject.toml").exists()
14
+
15
+
16
+ def get_version() -> str:
17
+ """Get the version string, appending .dev if running from source."""
18
+ try:
19
+ # Try to get the version of the installed package
20
+ version = importlib.metadata.version("alembic-squawk")
21
+ except importlib.metadata.PackageNotFoundError:
22
+ # Fallback for local development if not installed
23
+ version = "0.1.0"
24
+
25
+ if not is_local_source_checkout():
26
+ return version
27
+
28
+ if version.endswith(".dev"):
29
+ return version
30
+
31
+ return f"{version}.dev"
32
+
33
+
34
+ __version__ = get_version()
@@ -0,0 +1,61 @@
1
+ Metadata-Version: 2.3
2
+ Name: alembic-squawk
3
+ Version: 0.1.0
4
+ Summary: Split Alembic migrations into per-revision SQL files for linting with Squawk.
5
+ Keywords: alembic,squawk,sql-linter,migration
6
+ Author: Michael Bianco
7
+ Author-email: Michael Bianco <mike@mikebian.co>
8
+ Requires-Dist: click>=8.3.3
9
+ Requires-Python: >=3.11
10
+ Project-URL: Repository, https://github.com/iloveitaly/alembic-squawk
11
+ Description-Content-Type: text/markdown
12
+
13
+ [![Release Notes](https://img.shields.io/github/release/iloveitaly/alembic-squawk)](https://github.com/iloveitaly/alembic-squawk/releases)
14
+ [![Downloads](https://static.pepy.tech/badge/alembic-squawk/month)](https://pepy.tech/project/alembic-squawk)
15
+ ![GitHub CI Status](https://github.com/iloveitaly/alembic-squawk/actions/workflows/build_and_publish.yml/badge.svg)
16
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
17
+
18
+ # Split Alembic Migrations into Per-Revision SQL Files
19
+
20
+ `alembic-squawk` runs Alembic in offline (SQL) mode and splits the output into one `.sql` file per migration revision. The primary use case is feeding those files to [Squawk](https://github.com/sbdchd/squawk) so lint failures point to a specific migration rather than a monolithic dump.
21
+
22
+ ## Features
23
+
24
+ - Strips the outer `BEGIN`/`COMMIT` Alembic wraps the full dump in, so each file contains only its own migration SQL. Pass `--assume-in-transaction` to Squawk when linting.
25
+ - Maps each revision back to its source `.py` file to preserve the original filename in output.
26
+ - Reads `# -- squawk-ignore-file <rule>` Python comments from migration files and prepends them as SQL directives in the generated output, so you can attach Squawk ignores without embedding raw SQL comments in Python source.
27
+ - Supports filtering by revision range or creation date to narrow linting to recently added migrations.
28
+
29
+ ## Installation
30
+
31
+ ```bash
32
+ uv add alembic-squawk
33
+ ```
34
+
35
+ ## Usage
36
+
37
+ Run from your project root (where `alembic.ini` lives):
38
+
39
+ ```bash
40
+ alembic-squawk --out-dir /tmp/alembic_sql
41
+ ```
42
+
43
+ The output directory is wiped and recreated on every run.
44
+
45
+ ### Options
46
+
47
+ - `--out-dir` (required): Directory to write SQL files into.
48
+ - `--revisions`: Alembic revision range. Defaults to `base:head`. Accepts any valid range, e.g. `origin/main@HEAD:head` to dump only migrations not yet on main.
49
+ - `--created-after YYYY-MM-DD`: Skip migrations created before this date.
50
+ - `--lint-preamble`: Also write the Alembic preamble (boilerplate `SET` statements) to `000_preamble.sql`. Skipped by default.
51
+ - `--verbose`: Print the Alembic command and any stderr output.
52
+
53
+ Each file is named after its migration source file, e.g. `2026_03_16_d4b9da2f705c_add_users.sql`.
54
+
55
+ ## Example
56
+
57
+ [iloveitaly/python-starter-template](https://github.com/iloveitaly/python-starter-template) is a full Python app with a working integration of `alembic-squawk`.
58
+
59
+ ## [MIT License](LICENSE.md)
60
+
61
+ *This project was created from [iloveitaly/python-package-template](https://github.com/iloveitaly/python-package-template)*
@@ -0,0 +1,7 @@
1
+ alembic_squawk/__init__.py,sha256=v3x5oBkxwKJEZLv62QqSmP3iqNKLtZgrWZfH8eFzlQg,60
2
+ alembic_squawk/cli.py,sha256=qmRaHR9l4N2EuUF6fJp360tUD9vS2f5IjfT7Lnv40QY,11719
3
+ alembic_squawk/version.py,sha256=kXODOZaJTKymUh3Wo5wZUmpITqf0g8HOKmY0z-5qdxU,1020
4
+ alembic_squawk-0.1.0.dist-info/WHEEL,sha256=fWriCkzqm-pffF5af4gJC9iI5FMFaJTuN9UxxxzOmdY,81
5
+ alembic_squawk-0.1.0.dist-info/entry_points.txt,sha256=fsaOauCaR_L3U50dWWdaOs-TzrKADWVaAoYn5oXoz6I,60
6
+ alembic_squawk-0.1.0.dist-info/METADATA,sha256=TtYxZQdXlOv0zi4OvYU2X9bPtr3tefCVXpSmPavYJeU,3016
7
+ alembic_squawk-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.11.14
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ alembic-squawk = alembic_squawk.cli:main
3
+