pgdevkit 0.3.2__tar.gz → 0.3.4__tar.gz

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.
Files changed (108) hide show
  1. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/PKG-INFO +57 -2
  2. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/README.md +54 -0
  3. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/pgdevkit/cli.py +197 -1
  4. pgdevkit-0.3.4/pgdevkit/migrate.py +292 -0
  5. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/pgdevkit/testdb/schema.py +19 -1
  6. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/pyproject.toml +2 -1
  7. pgdevkit-0.3.4/tests/test_migrate.py +76 -0
  8. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/uv.lock +15 -1
  9. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/.github/workflows/python-publish.yml +0 -0
  10. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/.github/workflows/python-test.yml +0 -0
  11. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/.gitignore +0 -0
  12. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/.python-version +0 -0
  13. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/docs/database-layout.md +0 -0
  14. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/pgdevkit/__init__.py +0 -0
  15. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/pgdevkit/backends/__init__.py +0 -0
  16. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/pgdevkit/backends/base.py +0 -0
  17. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/pgdevkit/backends/mssql.py +0 -0
  18. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/pgdevkit/backends/postgres.py +0 -0
  19. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/pgdevkit/connection.py +0 -0
  20. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/pgdevkit/db/__init__.py +0 -0
  21. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/pgdevkit/db/complex_types.py +0 -0
  22. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/pgdevkit/db/connection.py +0 -0
  23. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/pgdevkit/db/crud.py +0 -0
  24. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/pgdevkit/db/loader.py +0 -0
  25. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/pgdevkit/db/model.py +0 -0
  26. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/pgdevkit/db/mssql_crud.py +0 -0
  27. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/pgdevkit/db/mssql_sql.py +0 -0
  28. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/pgdevkit/dialect.py +0 -0
  29. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/pgdevkit/diff.py +0 -0
  30. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/pgdevkit/fetch_missing.py +0 -0
  31. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/pgdevkit/introspect.py +0 -0
  32. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/pgdevkit/lakebase.py +0 -0
  33. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/pgdevkit/models.py +0 -0
  34. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/pgdevkit/mssql_introspect.py +0 -0
  35. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/pgdevkit/parser.py +0 -0
  36. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/pgdevkit/testdb/__init__.py +0 -0
  37. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/pgdevkit/testdb/_docker.py +0 -0
  38. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/pgdevkit/testdb/api.py +0 -0
  39. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/pgdevkit/testdb/config.py +0 -0
  40. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/pgdevkit/testdb/constants.py +0 -0
  41. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/pgdevkit/testdb/container.py +0 -0
  42. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/pgdevkit/testdb/mssql/__init__.py +0 -0
  43. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/pgdevkit/testdb/mssql/api.py +0 -0
  44. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/pgdevkit/testdb/mssql/constants.py +0 -0
  45. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/pgdevkit/testdb/mssql/container.py +0 -0
  46. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/pgdevkit/testdb/naming.py +0 -0
  47. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/pgdevkit/testdb/query.py +0 -0
  48. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/skills/pgdevkit/SKILL.md +0 -0
  49. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/skills/pgdevkit/references/dynamic-sql.md +0 -0
  50. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/skills/pgdevkit/references/temporal-tables.md +0 -0
  51. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/tests/__init__.py +0 -0
  52. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/tests/conftest.py +0 -0
  53. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/tests/db/__init__.py +0 -0
  54. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/tests/db/test_complex_types.py +0 -0
  55. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/tests/db/test_connection.py +0 -0
  56. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/tests/db/test_crud.py +0 -0
  57. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/tests/db/test_loader.py +0 -0
  58. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/tests/db/test_mssql_crud_live.py +0 -0
  59. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/tests/db/test_mssql_crud_sql.py +0 -0
  60. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/tests/fixtures/01_schema.sql +0 -0
  61. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/tests/fixtures/02_types.sql +0 -0
  62. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/tests/fixtures/03_tables.sql +0 -0
  63. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/tests/fixtures/04_views.sql +0 -0
  64. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/tests/fixtures/05_functions.sql +0 -0
  65. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/tests/fixtures/06_indexes.sql +0 -0
  66. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/tests/test_cli_compare.py +0 -0
  67. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/tests/test_compare.py +0 -0
  68. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/tests/test_compare_mssql_live.py +0 -0
  69. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/tests/test_connection.py +0 -0
  70. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/tests/test_dialect.py +0 -0
  71. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/tests/test_diff_mssql.py +0 -0
  72. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/tests/test_fetch_missing.py +0 -0
  73. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/tests/test_fetch_missing_cli.py +0 -0
  74. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/tests/test_lakebase.py +0 -0
  75. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/tests/test_mssql_introspect.py +0 -0
  76. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/tests/test_parser_mssql.py +0 -0
  77. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/tests/testdb/__init__.py +0 -0
  78. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/tests/testdb/conftest.py +0 -0
  79. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/tests/testdb/fixtures/database/app/migrations/001_add_gadget_note.sql +0 -0
  80. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/tests/testdb/fixtures/database/app/tables/gadget.sql +0 -0
  81. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/tests/testdb/fixtures/database/app/tables/gadget.test_data.json +0 -0
  82. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/tests/testdb/fixtures/database/app/tables/widget.sql +0 -0
  83. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/tests/testdb/fixtures/database/app/tables/widget.test_data.json +0 -0
  84. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/tests/testdb/fixtures/database/app/tables/widget_part.sql +0 -0
  85. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/tests/testdb/fixtures/database/app/tables/widget_part_detail.sql +0 -0
  86. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/tests/testdb/fixtures/database/app/types/dimensions.sql +0 -0
  87. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/tests/testdb/fixtures/database/app/types/mood.sql +0 -0
  88. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/tests/testdb/fixtures/database/app/views/a_wrapper_view.sql +0 -0
  89. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/tests/testdb/fixtures/database/app/views/b_base_view.sql +0 -0
  90. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/tests/testdb/fixtures/database/schema/app.sql +0 -0
  91. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/tests/testdb/fixtures/database_mssql/app/tables/widget.sql +0 -0
  92. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/tests/testdb/fixtures/database_mssql/app/tables/widget.test_data.json +0 -0
  93. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/tests/testdb/fixtures/database_mssql/app/views/a_wrapper_view.sql +0 -0
  94. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/tests/testdb/fixtures/database_mssql/app/views/b_base_view.sql +0 -0
  95. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/tests/testdb/fixtures/database_mssql/schema/app.sql +0 -0
  96. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/tests/testdb/test_api.py +0 -0
  97. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/tests/testdb/test_api_mssql.py +0 -0
  98. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/tests/testdb/test_api_mssql_live.py +0 -0
  99. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/tests/testdb/test_cli.py +0 -0
  100. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/tests/testdb/test_config.py +0 -0
  101. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/tests/testdb/test_config_mssql.py +0 -0
  102. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/tests/testdb/test_constants.py +0 -0
  103. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/tests/testdb/test_container.py +0 -0
  104. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/tests/testdb/test_mssql_constants.py +0 -0
  105. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/tests/testdb/test_naming.py +0 -0
  106. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/tests/testdb/test_query.py +0 -0
  107. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/tests/testdb/test_schema.py +0 -0
  108. {pgdevkit-0.3.2 → pgdevkit-0.3.4}/tests/testdb/test_schema_mssql.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: pgdevkit
3
- Version: 0.3.2
3
+ Version: 0.3.4
4
4
  Summary: A helper for developing with Postgres
5
5
  Requires-Python: >=3.14
6
6
  Requires-Dist: docker>=7.1.0
@@ -10,6 +10,7 @@ Provides-Extra: azure
10
10
  Requires-Dist: azure-identity>=1.19.0; extra == 'azure'
11
11
  Provides-Extra: cli
12
12
  Requires-Dist: rich>=13.0.0; extra == 'cli'
13
+ Requires-Dist: tqdm>=4.66.0; extra == 'cli'
13
14
  Requires-Dist: typer>=0.26.7; extra == 'cli'
14
15
  Provides-Extra: db
15
16
  Requires-Dist: psycopg-pool>=3.3.0; extra == 'db'
@@ -145,6 +146,60 @@ bootstraps the `sa` login — additional logins are a known limitation.
145
146
  the same category as `psql` for the Postgres path) rather than a Python
146
147
  REPL.
147
148
 
149
+ ## `pgdb migrate`
150
+
151
+ Applies numbered, forward-only SQL migration files from a directory to a live
152
+ Postgres database, tracking each one in a `schema.table` (default
153
+ `public.schema_migrations`) so repeat runs only apply what's pending. Postgres only —
154
+ not available for `--dialect mssql`.
155
+
156
+ ```bash
157
+ pgdb migrate check path/to/database/_migration_scripts --url postgresql://user:pass@host:port/db
158
+ pgdb migrate apply path/to/database/_migration_scripts --url postgresql://user:pass@host:port/db
159
+ ```
160
+
161
+ `--entra-user` works the same as `pgdb compare` (see above). The tracking
162
+ table needs `filename text primary key, applied_at timestamptz not null
163
+ default now(), applied_by text not null default current_user` (a migration
164
+ file that creates it, in the same directory, is the usual way to bootstrap
165
+ it — inserting into a not-yet-existing tracking table is tolerated so that
166
+ migration can still run).
167
+
168
+ The tracking table defaults to `public.schema_migrations`. Override it per-project in
169
+ `pyproject.toml`:
170
+
171
+ ```toml
172
+ [tool.pgdevkit]
173
+ migrations_table = "myschema.migrations"
174
+ ```
175
+
176
+ or per-invocation with `--tracking-table`, which takes precedence over the
177
+ pyproject.toml value.
178
+
179
+ `--ask` prints each pending file and asks yes/no/already-done/quit before
180
+ running it. Answering yes queues the file on a background worker and moves
181
+ straight to the next prompt — you can keep reviewing while earlier files are
182
+ still executing, instead of waiting on each one before seeing the next. A
183
+ `tqdm` progress bar tracks the queue; migrations still run one at a time, in
184
+ file order. Without `--ask`, `apply` queues every pending file up front and
185
+ shows the same progress bar. `--file <name>` applies a single file (still
186
+ through the same verify-and-track path) instead of walking all pending ones.
187
+ Pass `--yes` to skip the "about to run migrations against ..." confirmation
188
+ prompt (e.g. in CI).
189
+
190
+ After each file's DDL is applied, `apply` re-checks that every `CREATE TABLE`
191
+ statement's target actually exists (via `to_regclass`) before recording the
192
+ file as applied — catching a migration that silently rolled back. That check
193
+ parses each statement with `sqlglot` and only falls back to a regex (run
194
+ against comment-stripped SQL) for statements sqlglot's postgres dialect can't
195
+ parse, so a `CREATE TABLE` mentioned only in a `--` comment is never mistaken
196
+ for a real one.
197
+
198
+ `pgdevkit.migrate` is also usable directly as a library — `list_migration_files`,
199
+ `applied_migrations`, `pending_migrations`, and `apply_migration` are the same
200
+ functions the CLI calls, so a project can script around them without shelling
201
+ out.
202
+
148
203
  ## `pgdevkit.db` — helpers for application code
149
204
 
150
205
  Install with the `db` extra: `pip install pgdevkit[db]`.
@@ -125,6 +125,60 @@ bootstraps the `sa` login — additional logins are a known limitation.
125
125
  the same category as `psql` for the Postgres path) rather than a Python
126
126
  REPL.
127
127
 
128
+ ## `pgdb migrate`
129
+
130
+ Applies numbered, forward-only SQL migration files from a directory to a live
131
+ Postgres database, tracking each one in a `schema.table` (default
132
+ `public.schema_migrations`) so repeat runs only apply what's pending. Postgres only —
133
+ not available for `--dialect mssql`.
134
+
135
+ ```bash
136
+ pgdb migrate check path/to/database/_migration_scripts --url postgresql://user:pass@host:port/db
137
+ pgdb migrate apply path/to/database/_migration_scripts --url postgresql://user:pass@host:port/db
138
+ ```
139
+
140
+ `--entra-user` works the same as `pgdb compare` (see above). The tracking
141
+ table needs `filename text primary key, applied_at timestamptz not null
142
+ default now(), applied_by text not null default current_user` (a migration
143
+ file that creates it, in the same directory, is the usual way to bootstrap
144
+ it — inserting into a not-yet-existing tracking table is tolerated so that
145
+ migration can still run).
146
+
147
+ The tracking table defaults to `public.schema_migrations`. Override it per-project in
148
+ `pyproject.toml`:
149
+
150
+ ```toml
151
+ [tool.pgdevkit]
152
+ migrations_table = "myschema.migrations"
153
+ ```
154
+
155
+ or per-invocation with `--tracking-table`, which takes precedence over the
156
+ pyproject.toml value.
157
+
158
+ `--ask` prints each pending file and asks yes/no/already-done/quit before
159
+ running it. Answering yes queues the file on a background worker and moves
160
+ straight to the next prompt — you can keep reviewing while earlier files are
161
+ still executing, instead of waiting on each one before seeing the next. A
162
+ `tqdm` progress bar tracks the queue; migrations still run one at a time, in
163
+ file order. Without `--ask`, `apply` queues every pending file up front and
164
+ shows the same progress bar. `--file <name>` applies a single file (still
165
+ through the same verify-and-track path) instead of walking all pending ones.
166
+ Pass `--yes` to skip the "about to run migrations against ..." confirmation
167
+ prompt (e.g. in CI).
168
+
169
+ After each file's DDL is applied, `apply` re-checks that every `CREATE TABLE`
170
+ statement's target actually exists (via `to_regclass`) before recording the
171
+ file as applied — catching a migration that silently rolled back. That check
172
+ parses each statement with `sqlglot` and only falls back to a regex (run
173
+ against comment-stripped SQL) for statements sqlglot's postgres dialect can't
174
+ parse, so a `CREATE TABLE` mentioned only in a `--` comment is never mistaken
175
+ for a real one.
176
+
177
+ `pgdevkit.migrate` is also usable directly as a library — `list_migration_files`,
178
+ `applied_migrations`, `pending_migrations`, and `apply_migration` are the same
179
+ functions the CLI calls, so a project can script around them without shelling
180
+ out.
181
+
128
182
  ## `pgdevkit.db` — helpers for application code
129
183
 
130
184
  Install with the `db` extra: `pip install pgdevkit[db]`.
@@ -1,6 +1,8 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import os
4
+ import queue
5
+ import threading
4
6
  from pathlib import Path
5
7
 
6
8
  import psycopg
@@ -8,8 +10,9 @@ import typer
8
10
  from rich.console import Console
9
11
  from rich.table import Table
10
12
  from rich import box
13
+ from tqdm import tqdm
11
14
 
12
- from . import testdb
15
+ from . import migrate, testdb
13
16
  from .backends import get_backend
14
17
  from .connection import build_conninfo
15
18
  from .diff import DiffKind, compute_diff
@@ -23,6 +26,11 @@ err_console = Console(stderr=True)
23
26
  testdb_app = typer.Typer(name="testdb", help="Manage the shared local Postgres test container")
24
27
  app.add_typer(testdb_app, name="testdb")
25
28
 
29
+ migrate_app = typer.Typer(
30
+ name="migrate", help="Apply numbered, forward-only SQL migration files, tracked in a DB table"
31
+ )
32
+ app.add_typer(migrate_app, name="migrate")
33
+
26
34
 
27
35
  @app.command()
28
36
  def compare(
@@ -224,3 +232,191 @@ def testdb_clean(
224
232
  """Drop this workspace's database (or every database of this project with --all)."""
225
233
  testdb.clean_testdb(all=all)
226
234
  console.print("[green]Cleaned.[/green]")
235
+
236
+
237
+ @migrate_app.command("check")
238
+ def migrate_check(
239
+ migrations_dir: Path = typer.Argument(..., help="Directory of numbered .sql migration files"),
240
+ url: str = typer.Option(..., "--url", help="PostgreSQL DSN (postgresql://user:pass@host:port/db)"),
241
+ entra_user: str | None = typer.Option(None, "--entra-user", help="Azure Entra user (triggers token auth)"),
242
+ tracking_table: str | None = typer.Option(
243
+ None,
244
+ "--tracking-table",
245
+ help="schema.table recording applied migrations "
246
+ "(default: tool.pgdevkit.migrations_table in pyproject.toml, else public.schema_migrations)",
247
+ ),
248
+ ) -> None:
249
+ """List which migration files under migrations_dir are applied vs. pending."""
250
+ if not migrations_dir.is_dir():
251
+ err_console.print(f"[red]Error:[/red] {migrations_dir} is not a directory")
252
+ raise typer.Exit(2)
253
+
254
+ conninfo = build_conninfo(url, entra_user)
255
+ tracking_table = tracking_table or migrate.default_tracking_table(migrations_dir)
256
+ local_files = migrate.list_migration_files(migrations_dir)
257
+ try:
258
+ applied = migrate.applied_migrations(conninfo, tracking_table)
259
+ except migrate.TrackingTableMissing:
260
+ err_console.print(f"[yellow]⚠[/yellow] {tracking_table} not found — nothing recorded as applied yet")
261
+ applied = {}
262
+
263
+ table = Table(box=box.SIMPLE, show_header=True, header_style="bold")
264
+ table.add_column("File")
265
+ table.add_column("Status")
266
+ table.add_column("Applied at", style="dim")
267
+ pending = []
268
+ for f in local_files:
269
+ if f.name in applied:
270
+ applied_at, applied_by = applied[f.name]
271
+ table.add_row(f.name, "[green]applied[/green]", f"{applied_at:%Y-%m-%d %H:%M} ({applied_by})")
272
+ else:
273
+ table.add_row(f.name, "[yellow]pending[/yellow]", "")
274
+ pending.append(f.name)
275
+ console.print(table)
276
+ console.print(f"\n{len(pending)} pending, {len(applied)} applied, {len(local_files)} total")
277
+
278
+
279
+ @migrate_app.command("apply")
280
+ def migrate_apply(
281
+ migrations_dir: Path = typer.Argument(..., help="Directory of numbered .sql migration files"),
282
+ url: str = typer.Option(..., "--url", help="PostgreSQL DSN (postgresql://user:pass@host:port/db)"),
283
+ entra_user: str | None = typer.Option(None, "--entra-user", help="Azure Entra user (triggers token auth)"),
284
+ tracking_table: str | None = typer.Option(
285
+ None,
286
+ "--tracking-table",
287
+ help="schema.table recording applied migrations "
288
+ "(default: tool.pgdevkit.migrations_table in pyproject.toml, else public.schema_migrations)",
289
+ ),
290
+ file: str | None = typer.Option(
291
+ None, "--file", help="Apply only this one filename (relative to migrations_dir) instead of all pending"
292
+ ),
293
+ ask: bool = typer.Option(False, "--ask", help="Show and confirm each migration before running it"),
294
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirm-target prompt"),
295
+ ) -> None:
296
+ """Apply pending migration files, in filename order, tracking each in tracking_table."""
297
+ if not migrations_dir.is_dir():
298
+ err_console.print(f"[red]Error:[/red] {migrations_dir} is not a directory")
299
+ raise typer.Exit(2)
300
+
301
+ conninfo = build_conninfo(url, entra_user)
302
+ tracking_table = tracking_table or migrate.default_tracking_table(migrations_dir)
303
+ target_desc = url.rsplit("@", 1)[-1] if "@" in url else url
304
+ if not yes:
305
+ typer.confirm(f"About to run migrations against {target_desc}. Continue?", abort=True)
306
+
307
+ if file:
308
+ targets = [migrations_dir / file]
309
+ else:
310
+ try:
311
+ targets = migrate.pending_migrations(migrations_dir, conninfo, tracking_table)
312
+ except migrate.TrackingTableMissing:
313
+ err_console.print(
314
+ f"[yellow]⚠[/yellow] {tracking_table} not found — treating every migration as pending"
315
+ )
316
+ targets = migrate.list_migration_files(migrations_dir)
317
+
318
+ if not targets:
319
+ console.print("No pending migrations.")
320
+ return
321
+
322
+ # A single background worker applies queued migrations in file order (each still waits
323
+ # for the previous one to land) while --ask keeps prompting for the *next* file, instead
324
+ # of the review blocking on every execution.
325
+ work_q: queue.Queue[tuple[Path, bool] | None] = queue.Queue()
326
+ failure: Exception | None = None
327
+ stop = threading.Event()
328
+ outcomes: list[tuple[str, str]] = []
329
+ bar = tqdm(total=len(targets), unit="migration", desc="Applying")
330
+ # tqdm isn't guaranteed thread-safe without an explicit lock, and both the worker
331
+ # thread and this (the main/--ask) thread call bar.update()/bar.write().
332
+ bar_lock = threading.Lock()
333
+
334
+ def bar_write(msg: str) -> None:
335
+ with bar_lock:
336
+ bar.write(msg)
337
+
338
+ def bar_step() -> None:
339
+ with bar_lock:
340
+ bar.update(1)
341
+
342
+ def worker() -> None:
343
+ nonlocal failure
344
+ for path, already_done in iter(work_q.get, None):
345
+ if not stop.is_set():
346
+ try:
347
+ result = migrate.apply_migration(conninfo, path, tracking_table, already_done=already_done)
348
+ except Exception as e: # noqa: BLE001
349
+ failure = e
350
+ stop.set()
351
+ bar_write(f"FAILED {path.name}: {e}")
352
+ outcomes.append((path.name, "failed"))
353
+ else:
354
+ if result.executed:
355
+ for tbl in result.verified_tables:
356
+ bar_write(f" table {tbl} exists")
357
+ bar_write(f"Applied {path.name}")
358
+ outcomes.append((path.name, "applied"))
359
+ else:
360
+ bar_write(f"Recorded {path.name} as already applied (not executed)")
361
+ outcomes.append((path.name, "recorded"))
362
+ bar_step()
363
+ work_q.task_done()
364
+
365
+ thread = threading.Thread(target=worker, daemon=True)
366
+ thread.start()
367
+
368
+ quit_requested = False
369
+ for path in targets:
370
+ if stop.is_set():
371
+ break
372
+ already_done = False
373
+ if ask:
374
+ bar_write(f"\n=== {path.name} ===")
375
+ bar_write(path.read_text(encoding="utf-8"))
376
+ answer = typer.prompt("[Y]es execute / [n]o skip / [a]lready done / [q]uit", default="y").strip().lower()
377
+ if answer in ("q", "quit"):
378
+ quit_requested = True
379
+ break
380
+ if answer in ("n", "no"):
381
+ bar_write(f"Skipped {path.name}")
382
+ outcomes.append((path.name, "skipped"))
383
+ bar_step()
384
+ continue
385
+ if answer in ("a", "already", "already done"):
386
+ already_done = True
387
+ elif answer not in ("", "y", "yes"):
388
+ bar_write(f"Skipped {path.name}")
389
+ outcomes.append((path.name, "skipped"))
390
+ bar_step()
391
+ continue
392
+
393
+ work_q.put((path, already_done))
394
+
395
+ work_q.put(None)
396
+ thread.join()
397
+ bar.close()
398
+
399
+ processed = {name for name, _ in outcomes}
400
+ for path in targets:
401
+ if stop.is_set() and path.name not in processed:
402
+ outcomes.append((path.name, "not run (stopped after earlier failure)"))
403
+
404
+ table = Table(box=box.SIMPLE, show_header=True, header_style="bold")
405
+ table.add_column("File")
406
+ table.add_column("Result")
407
+ status_style = {
408
+ "applied": "[green]applied[/green]",
409
+ "recorded": "[green]recorded (already done)[/green]",
410
+ "skipped": "[yellow]skipped[/yellow]",
411
+ "failed": "[red]failed[/red]",
412
+ }
413
+ for name, status in outcomes:
414
+ table.add_row(name, status_style.get(status, status))
415
+ console.print(table)
416
+
417
+ if quit_requested:
418
+ console.print("Aborted.")
419
+ raise typer.Exit(1)
420
+ if failure is not None:
421
+ err_console.print(f"[red]✗[/red] {failure}")
422
+ raise typer.Exit(1)
@@ -0,0 +1,292 @@
1
+ """Apply numbered, forward-only SQL migration files to a live Postgres database,
2
+ tracked in a `schema.table` (default `public.schema_migrations`) so re-runs only
3
+ apply what's pending. Ported from a hand-rolled per-project script — MSSQL is not
4
+ supported yet.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ import tomllib
11
+ from dataclasses import dataclass
12
+ from datetime import datetime
13
+ from pathlib import Path
14
+ from typing import LiteralString, cast
15
+
16
+ import psycopg
17
+ import sqlglot
18
+ from psycopg import errors as pg_errors
19
+ from psycopg import sql as pg_sql
20
+
21
+ _IDENTIFIER = r"[A-Za-z_][A-Za-z0-9_]*"
22
+ _DEFAULT_TRACKING_TABLE = "public.schema_migrations"
23
+
24
+
25
+ class MigrationVerificationError(RuntimeError):
26
+ """A CREATE TABLE statement's target doesn't exist after applying the migration."""
27
+
28
+
29
+ class TrackingTableMissing(RuntimeError):
30
+ """The migration-tracking table doesn't exist yet (e.g. before it's bootstrapped)."""
31
+
32
+ def __init__(self, tracking_table: str) -> None:
33
+ super().__init__(tracking_table)
34
+ self.tracking_table = tracking_table
35
+
36
+
37
+ def _tracking_table_identifier(tracking_table: str) -> pg_sql.Identifier:
38
+ """Parse 'schema.table' into a safely-quoted, injection-proof identifier."""
39
+ if not re.fullmatch(rf"{_IDENTIFIER}\.{_IDENTIFIER}", tracking_table):
40
+ raise ValueError(f"tracking_table must look like schema.table, got {tracking_table!r}")
41
+ schema, _, table = tracking_table.partition(".")
42
+ return pg_sql.Identifier(schema, table)
43
+
44
+
45
+ def _find_pyproject(start: Path) -> Path | None:
46
+ for directory in [start, *start.parents]:
47
+ candidate = directory / "pyproject.toml"
48
+ if candidate.exists():
49
+ return candidate
50
+ return None
51
+
52
+
53
+ def default_tracking_table(start: Path | None = None) -> str:
54
+ """The project's configured tracking table: `[tool.pgdevkit].migrations_table` in the
55
+ nearest pyproject.toml at or above `start` (default: cwd), or "public.schema_migrations"
56
+ if neither is set. Lets a project fix its tracking table once instead of passing
57
+ --tracking-table on every `pgdb migrate` invocation."""
58
+ pyproject = _find_pyproject((start or Path.cwd()).resolve())
59
+ if pyproject is None:
60
+ return _DEFAULT_TRACKING_TABLE
61
+ data = tomllib.loads(pyproject.read_text(encoding="utf-8"))
62
+ section = data.get("tool", {}).get("pgdevkit", {})
63
+ return section.get("migrations_table", _DEFAULT_TRACKING_TABLE)
64
+
65
+
66
+ def _split_sql(sql: str) -> list[str]:
67
+ """Split SQL on semicolons, ignoring those inside comments, '...' strings, or $$...$$ blocks."""
68
+ stmts: list[str] = []
69
+ buf: list[str] = []
70
+ i = 0
71
+ in_string = False
72
+ in_line_comment = False
73
+ dollar_tag: str | None = None
74
+
75
+ while i < len(sql):
76
+ c = sql[i]
77
+ if in_line_comment:
78
+ if c == "\n":
79
+ in_line_comment = False
80
+ buf.append(c)
81
+ elif dollar_tag is not None:
82
+ buf.append(c)
83
+ if c == "$" and sql[i:i + len(dollar_tag)] == dollar_tag:
84
+ buf.extend(list(dollar_tag[1:]))
85
+ i += len(dollar_tag)
86
+ dollar_tag = None
87
+ continue
88
+ elif in_string:
89
+ if c == "'" and i + 1 < len(sql) and sql[i + 1] == "'":
90
+ buf.append(c)
91
+ buf.append(sql[i + 1])
92
+ i += 2
93
+ continue
94
+ elif c == "'":
95
+ in_string = False
96
+ buf.append(c)
97
+ elif c == "-" and i + 1 < len(sql) and sql[i + 1] == "-":
98
+ in_line_comment = True
99
+ buf.append(c)
100
+ elif c == "$":
101
+ m = re.match(r"\$([A-Za-z0-9_]*)\$", sql[i:])
102
+ if m:
103
+ dollar_tag = m.group(0)
104
+ buf.extend(list(dollar_tag))
105
+ i += len(dollar_tag)
106
+ continue
107
+ buf.append(c)
108
+ elif c == "'":
109
+ in_string = True
110
+ buf.append(c)
111
+ elif c == ";":
112
+ stmt = "".join(buf).strip()
113
+ if stmt:
114
+ stmts.append(stmt)
115
+ buf = []
116
+ else:
117
+ buf.append(c)
118
+ i += 1
119
+ remainder = "".join(buf).strip()
120
+ if remainder:
121
+ stmts.append(remainder)
122
+ return stmts
123
+
124
+
125
+ def _strip_line_comments(sql: str) -> str:
126
+ """Drop '--' line comments, respecting string literals and $$...$$ blocks."""
127
+ buf: list[str] = []
128
+ i = 0
129
+ in_string = False
130
+ in_line_comment = False
131
+ dollar_tag: str | None = None
132
+
133
+ while i < len(sql):
134
+ c = sql[i]
135
+ if in_line_comment:
136
+ if c == "\n":
137
+ in_line_comment = False
138
+ buf.append(c)
139
+ elif dollar_tag is not None:
140
+ buf.append(c)
141
+ if c == "$" and sql[i:i + len(dollar_tag)] == dollar_tag:
142
+ buf.extend(list(dollar_tag[1:]))
143
+ i += len(dollar_tag)
144
+ dollar_tag = None
145
+ continue
146
+ elif in_string:
147
+ if c == "'" and i + 1 < len(sql) and sql[i + 1] == "'":
148
+ buf.append(c)
149
+ buf.append(sql[i + 1])
150
+ i += 2
151
+ continue
152
+ elif c == "'":
153
+ in_string = False
154
+ buf.append(c)
155
+ elif c == "-" and i + 1 < len(sql) and sql[i + 1] == "-":
156
+ in_line_comment = True
157
+ elif c == "$":
158
+ m = re.match(r"\$([A-Za-z0-9_]*)\$", sql[i:])
159
+ if m:
160
+ dollar_tag = m.group(0)
161
+ buf.extend(list(dollar_tag))
162
+ i += len(dollar_tag)
163
+ continue
164
+ buf.append(c)
165
+ elif c == "'":
166
+ in_string = True
167
+ buf.append(c)
168
+ else:
169
+ buf.append(c)
170
+ i += 1
171
+ return "".join(buf)
172
+
173
+
174
+ def _created_table_names(stmts: list[str]) -> list[str]:
175
+ """Names of tables any CREATE TABLE statement targets, parsed via sqlglot (falls back to
176
+ regex on comment-stripped text for statements sqlglot's postgres dialect can't parse)."""
177
+ names: list[str] = []
178
+ for stmt in stmts:
179
+ stripped = _strip_line_comments(stmt)
180
+ if not re.search(r"CREATE\s+TABLE", stripped, re.IGNORECASE):
181
+ continue # skip sqlglot entirely for statements that can't be a CREATE TABLE
182
+ try:
183
+ parsed = sqlglot.parse_one(stmt, dialect="postgres")
184
+ except Exception:
185
+ parsed = None
186
+ if parsed is not None and isinstance(parsed, sqlglot.exp.Create) and parsed.kind == "TABLE":
187
+ table = parsed.this.this if isinstance(parsed.this, sqlglot.exp.Schema) else parsed.this
188
+ if isinstance(table, sqlglot.exp.Table):
189
+ names.append(table.sql(dialect="postgres"))
190
+ continue
191
+ names.extend(
192
+ m.group(1)
193
+ for m in re.finditer(
194
+ r'CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?([\w."]+)', stripped, re.IGNORECASE
195
+ )
196
+ )
197
+ return names
198
+
199
+
200
+ def list_migration_files(migrations_dir: Path) -> list[Path]:
201
+ return sorted(migrations_dir.glob("*.sql"))
202
+
203
+
204
+ def applied_migrations(conninfo: str, tracking_table: str) -> dict[str, tuple[datetime, str]]:
205
+ """Filename -> (applied_at, applied_by) for every migration recorded in the tracking table."""
206
+ table = _tracking_table_identifier(tracking_table)
207
+ with psycopg.connect(conninfo) as con:
208
+ try:
209
+ rows = con.execute(
210
+ pg_sql.SQL("select filename, applied_at, applied_by from {} order by applied_at").format(table)
211
+ ).fetchall()
212
+ except pg_errors.UndefinedTable as e:
213
+ raise TrackingTableMissing(tracking_table) from e
214
+ return {r[0]: (r[1], r[2]) for r in rows}
215
+
216
+
217
+ def pending_migrations(migrations_dir: Path, conninfo: str, tracking_table: str) -> list[Path]:
218
+ applied = applied_migrations(conninfo, tracking_table)
219
+ return [p for p in list_migration_files(migrations_dir) if p.name not in applied]
220
+
221
+
222
+ def record_applied(conninfo: str, tracking_table: str, filename: str) -> bool:
223
+ """Best-effort insert into the tracking table. Returns False without raising if the
224
+ tracking table doesn't exist yet — e.g. this migration is the one that creates it."""
225
+ table = _tracking_table_identifier(tracking_table)
226
+ with psycopg.connect(conninfo) as con:
227
+ try:
228
+ con.execute(
229
+ pg_sql.SQL("insert into {} (filename) values (%s) on conflict do nothing").format(table),
230
+ (filename,),
231
+ )
232
+ con.commit()
233
+ return True
234
+ except pg_errors.UndefinedTable:
235
+ con.rollback()
236
+ return False
237
+
238
+
239
+ def verify_created_tables(conninfo: str, stmts: list[str]) -> list[str]:
240
+ """Table names from this migration's CREATE TABLE statements that do NOT exist in the
241
+ database. Empty means everything landed."""
242
+ tables = _created_table_names(stmts)
243
+ if not tables:
244
+ return []
245
+ missing = []
246
+ with psycopg.connect(conninfo) as con:
247
+ for tbl in tables:
248
+ row = con.execute("select to_regclass(%s)", (tbl,)).fetchone()
249
+ if not (row and row[0]):
250
+ missing.append(tbl)
251
+ return missing
252
+
253
+
254
+ @dataclass
255
+ class ApplyResult:
256
+ filename: str
257
+ executed: bool # False if the caller marked it "already done" instead of running it
258
+ verified_tables: list[str]
259
+
260
+
261
+ def apply_migration(
262
+ conninfo: str,
263
+ path: Path,
264
+ tracking_table: str,
265
+ *,
266
+ already_done: bool = False,
267
+ ) -> ApplyResult:
268
+ sql = path.read_text(encoding="utf-8")
269
+ filename = path.name
270
+ stmts = _split_sql(sql)
271
+
272
+ if not already_done:
273
+ # DDL in its own committed transaction.
274
+ with psycopg.connect(conninfo) as con:
275
+ for stmt in stmts:
276
+ con.execute(cast(LiteralString, stmt))
277
+ con.commit()
278
+
279
+ # Tracking insert is a separate connection/transaction so a missing tracking table
280
+ # never rolls back the DDL that was just applied.
281
+ record_applied(conninfo, tracking_table, filename)
282
+
283
+ if already_done:
284
+ return ApplyResult(filename, executed=False, verified_tables=[])
285
+
286
+ missing = verify_created_tables(conninfo, stmts)
287
+ if missing:
288
+ raise MigrationVerificationError(
289
+ f"{filename}: table(s) not found after apply — migration may have rolled back: "
290
+ + ", ".join(missing)
291
+ )
292
+ return ApplyResult(filename, executed=True, verified_tables=_created_table_names(stmts))
@@ -195,6 +195,25 @@ async def _insert_test_data(
195
195
  # Silently drop JSON keys that don't correspond to a real column —
196
196
  # e.g. a stale fixture left over from a since-renamed/removed column.
197
197
  col_names = [c for c in rows[0] if c in complex_types]
198
+ has_complex_col = any(complex_types.get(col) is not None for col in col_names)
199
+
200
+ await cur.execute(SQL("DELETE FROM {t}").format(t=Identifier(schema, table_name)))
201
+
202
+ if not has_complex_col:
203
+ # No composite/enum/JSONB column on this table -- every value is a
204
+ # plain scalar or a native Postgres array, so COPY can take the
205
+ # rows as-is. COPY sends the whole batch as one stream instead of
206
+ # a parse/bind/execute round trip per row, which matters a lot for
207
+ # fixtures with tens of thousands of rows.
208
+ copy_sql = SQL("COPY {t} ({cols}) FROM STDIN").format(
209
+ t=Identifier(schema, table_name),
210
+ cols=SQL(", ").join(Identifier(c) for c in col_names),
211
+ )
212
+ async with cur.copy(copy_sql) as copy:
213
+ for row in rows:
214
+ await copy.write_row(tuple(row[c] for c in col_names))
215
+ return
216
+
198
217
  for row in rows:
199
218
  for col in col_names:
200
219
  info = complex_types.get(col)
@@ -206,7 +225,6 @@ async def _insert_test_data(
206
225
  # is left untouched.
207
226
  row[col] = await complex_helper.recursive_convert(row[col], info, con)
208
227
 
209
- await cur.execute(SQL("DELETE FROM {t}").format(t=Identifier(schema, table_name)))
210
228
  insert_sql = SQL("INSERT INTO {t} ({cols}) VALUES ({vals})").format(
211
229
  t=Identifier(schema, table_name),
212
230
  cols=SQL(", ").join(Identifier(c) for c in col_names),
@@ -11,7 +11,7 @@ packages = ["pgdevkit"]
11
11
 
12
12
  [project]
13
13
  name = "pgdevkit"
14
- version = "0.3.2"
14
+ version = "0.3.4"
15
15
  description = "A helper for developing with Postgres"
16
16
  readme = "README.md"
17
17
  requires-python = ">=3.14"
@@ -25,6 +25,7 @@ dependencies = [
25
25
  azure = ["azure-identity>=1.19.0"]
26
26
  cli = [
27
27
  "rich>=13.0.0",
28
+ "tqdm>=4.66.0",
28
29
  "typer>=0.26.7",
29
30
  ]
30
31
  db = [
@@ -0,0 +1,76 @@
1
+ from __future__ import annotations
2
+
3
+ from pgdevkit.migrate import _created_table_names, _split_sql, _strip_line_comments, default_tracking_table
4
+
5
+
6
+ def test_created_table_names_ignores_create_table_mentioned_in_a_comment():
7
+ sql = """
8
+ -- with no CREATE TABLE checked in under database/ or _migration_scripts/. Adding
9
+ -- it here (and as schema-as-code) so a fresh/reset test DB actually has it.
10
+ CREATE TABLE IF NOT EXISTS app.widgets (
11
+ id text NOT NULL,
12
+ CONSTRAINT widgets_pkey PRIMARY KEY (id)
13
+ );
14
+ """
15
+ assert _created_table_names(_split_sql(sql)) == ["app.widgets"]
16
+
17
+
18
+ def test_created_table_names_finds_multiple_tables():
19
+ sql = """
20
+ CREATE TABLE a.b (id int);
21
+ CREATE TABLE IF NOT EXISTS c.d (id int);
22
+ """
23
+ assert _created_table_names(_split_sql(sql)) == ["a.b", "c.d"]
24
+
25
+
26
+ def test_created_table_names_ignores_non_create_table_statements():
27
+ sql = """
28
+ GRANT SELECT ON ALL TABLES IN SCHEMA app TO some_role;
29
+ ALTER TABLE app.widgets ADD COLUMN name text;
30
+ """
31
+ assert _created_table_names(_split_sql(sql)) == []
32
+
33
+
34
+ def test_created_table_names_falls_back_to_regex_for_unparseable_statements():
35
+ # sqlglot's postgres dialect can't parse DO $$ ... $$ blocks — the fallback
36
+ # regex must still run against comment-stripped text, not raw text.
37
+ sql = """
38
+ -- DO NOT CREATE TABLE this manually, use the migration
39
+ DO $$
40
+ BEGIN
41
+ CREATE TABLE app.dynamic_table (id int);
42
+ END $$;
43
+ """
44
+ assert _created_table_names(_split_sql(sql)) == ["app.dynamic_table"]
45
+
46
+
47
+ def test_strip_line_comments_preserves_string_literals_containing_dashes():
48
+ sql = "SELECT '--not-a-comment' AS x -- a real comment\nFROM t;"
49
+ stripped = _strip_line_comments(sql)
50
+ assert "--not-a-comment" in stripped
51
+ assert "a real comment" not in stripped
52
+
53
+
54
+ def test_default_tracking_table_falls_back_when_no_pyproject_section(tmp_path):
55
+ (tmp_path / "pyproject.toml").write_text("[project]\nname = 'x'\n")
56
+ assert default_tracking_table(tmp_path) == "public.schema_migrations"
57
+
58
+
59
+ def test_default_tracking_table_reads_pyproject_override(tmp_path):
60
+ (tmp_path / "pyproject.toml").write_text(
61
+ "[tool.pgdevkit]\nmigrations_table = 'myschema.migrations'\n"
62
+ )
63
+ assert default_tracking_table(tmp_path) == "myschema.migrations"
64
+
65
+
66
+ def test_default_tracking_table_searches_parent_directories(tmp_path):
67
+ (tmp_path / "pyproject.toml").write_text(
68
+ "[tool.pgdevkit]\nmigrations_table = 'myschema.migrations'\n"
69
+ )
70
+ nested = tmp_path / "database" / "_migration_scripts"
71
+ nested.mkdir(parents=True)
72
+ assert default_tracking_table(nested) == "myschema.migrations"
73
+
74
+
75
+ def test_default_tracking_table_falls_back_with_no_pyproject_at_all(tmp_path):
76
+ assert default_tracking_table(tmp_path / "nonexistent") == "public.schema_migrations"
@@ -313,7 +313,7 @@ wheels = [
313
313
 
314
314
  [[package]]
315
315
  name = "pgdevkit"
316
- version = "0.3.2"
316
+ version = "0.3.4"
317
317
  source = { editable = "." }
318
318
  dependencies = [
319
319
  { name = "docker" },
@@ -327,6 +327,7 @@ azure = [
327
327
  ]
328
328
  cli = [
329
329
  { name = "rich" },
330
+ { name = "tqdm" },
330
331
  { name = "typer" },
331
332
  ]
332
333
  db = [
@@ -358,6 +359,7 @@ requires-dist = [
358
359
  { name = "pydantic", marker = "extra == 'db'", specifier = ">=2.0" },
359
360
  { name = "rich", marker = "extra == 'cli'", specifier = ">=13.0.0" },
360
361
  { name = "sqlglot", specifier = ">=30.11.0" },
362
+ { name = "tqdm", marker = "extra == 'cli'", specifier = ">=4.66.0" },
361
363
  { name = "typer", marker = "extra == 'cli'", specifier = ">=0.26.7" },
362
364
  ]
363
365
  provides-extras = ["azure", "cli", "db", "mssql"]
@@ -624,6 +626,18 @@ wheels = [
624
626
  { url = "https://files.pythonhosted.org/packages/e3/86/53edf106e8cd3c883ccd0c6b470bf00ddf877a86e667665343b2d597329d/sqlglot-30.11.0-py3-none-any.whl", hash = "sha256:cffdee57d1f2f5472dc9f13087e618cf795841172b7d5ef78b63a051a52d2710", size = 698721, upload-time = "2026-06-11T17:11:35.737Z" },
625
627
  ]
626
628
 
629
+ [[package]]
630
+ name = "tqdm"
631
+ version = "4.70.0"
632
+ source = { registry = "https://pypi.org/simple" }
633
+ dependencies = [
634
+ { name = "colorama", marker = "sys_platform == 'win32'" },
635
+ ]
636
+ sdist = { url = "https://files.pythonhosted.org/packages/21/3b/6c24bec5be5e743ffd99576daa5cc077722fc7d5bbc00bd133fa0c698dc6/tqdm-4.70.0.tar.gz", hash = "sha256:55b0b0dbd97462d06ebee91e4dac24ed4d4702be82b24f07e6c1d27e08cea220", size = 795438, upload-time = "2026-07-27T11:33:15.271Z" }
637
+ wheels = [
638
+ { url = "https://files.pythonhosted.org/packages/f9/1c/01bfd571a64e7f270e6bab5e33777debe0edc56759233ce84f27dec92d14/tqdm-4.70.0-py3-none-any.whl", hash = "sha256:7f585706bfddbdebf89daac705b2dfcc16890130727d3197ca62c732b4310953", size = 80184, upload-time = "2026-07-27T11:33:13.167Z" },
639
+ ]
640
+
627
641
  [[package]]
628
642
  name = "ty"
629
643
  version = "0.0.59"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes