pgdevkit 0.3.4__py3-none-any.whl → 0.3.8__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.
pgdevkit/cli.py CHANGED
@@ -371,24 +371,36 @@ def migrate_apply(
371
371
  break
372
372
  already_done = False
373
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
374
+ already_done = migrate.already_fully_applied(conninfo, path)
375
+ if already_done:
376
+ bar_write(f"Auto: {path.name} is already fully present in the database — marking as already done")
377
+ else:
378
+ # Hold the lock across the whole show-file+prompt step (not just each write) so
379
+ # the worker's bar_write() calls block until the question is answered instead
380
+ # of interleaving with the prompt or the migration text while it's being read.
381
+ with bar_lock:
382
+ bar.write(f"\n=== {path.name} ===")
383
+ bar.write(path.read_text(encoding="utf-8"))
384
+ answer = (
385
+ typer.prompt("[Y]es execute / [n]o skip / [a]lready done / [q]uit", default="y")
386
+ .strip()
387
+ .lower()
388
+ )
389
+ if answer in ("q", "quit"):
390
+ quit_requested = True
391
+ break
392
+ if answer in ("n", "no"):
393
+ bar_write(f"Skipped {path.name}")
394
+ outcomes.append((path.name, "skipped"))
395
+ bar_step()
396
+ continue
397
+ if answer in ("a", "already", "already done"):
398
+ already_done = True
399
+ elif answer not in ("", "y", "yes"):
400
+ bar_write(f"Skipped {path.name}")
401
+ outcomes.append((path.name, "skipped"))
402
+ bar_step()
403
+ continue
392
404
 
393
405
  work_q.put((path, already_done))
394
406
 
pgdevkit/fetch_missing.py CHANGED
@@ -9,7 +9,7 @@ import psycopg
9
9
 
10
10
  from .diff import DiffKind, compute_diff
11
11
  from .introspect import introspect_db
12
- from .parser import parse_directory
12
+ from .parser import IGNORED_DIR_NAMES, parse_directory
13
13
 
14
14
  _LAYER_PREFIX_RE = re.compile(r"^\d+_?")
15
15
 
@@ -41,7 +41,7 @@ def layer_folder_for(scripts_dir: Path, schema: str) -> Path:
41
41
  if no existing folder matches."""
42
42
  if scripts_dir.is_dir():
43
43
  for entry in scripts_dir.iterdir():
44
- if not entry.is_dir() or entry.name in ("migrations", "_migration_scripts"):
44
+ if not entry.is_dir() or entry.name in ("migrations", "_migration_scripts") or entry.name in IGNORED_DIR_NAMES:
45
45
  continue
46
46
  if _LAYER_PREFIX_RE.sub("", entry.name).lower() == schema.lower():
47
47
  return entry
pgdevkit/migrate.py CHANGED
@@ -197,6 +197,83 @@ def _created_table_names(stmts: list[str]) -> list[str]:
197
197
  return names
198
198
 
199
199
 
200
+ _ADD_COLUMN_RE = re.compile(
201
+ rf"ALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?([\w.\"]+)\s+ADD\s+COLUMN\s+"
202
+ rf"(?:IF\s+NOT\s+EXISTS\s+)?\"?({_IDENTIFIER})\"?",
203
+ re.IGNORECASE,
204
+ )
205
+ _CREATE_RELATION_RE = re.compile(
206
+ r"CREATE\s+(?:UNIQUE\s+)?INDEX\s+(?:CONCURRENTLY\s+)?(?:IF\s+NOT\s+EXISTS\s+)?([\w.\"]+)"
207
+ r"|CREATE\s+SEQUENCE\s+(?:IF\s+NOT\s+EXISTS\s+)?([\w.\"]+)"
208
+ r"|CREATE\s+(?:MATERIALIZED\s+)?VIEW\s+(?:IF\s+NOT\s+EXISTS\s+)?([\w.\"]+)",
209
+ re.IGNORECASE,
210
+ )
211
+ _CREATE_SCHEMA_RE = re.compile(r"CREATE\s+SCHEMA\s+(?:IF\s+NOT\s+EXISTS\s+)?([\w\"]+)", re.IGNORECASE)
212
+
213
+
214
+ def _idempotent_target(stmt: str) -> tuple[str, ...] | None:
215
+ """Classify a single statement as one of the create-if-missing DDL shapes this module
216
+ can check for "already applied" with no ambiguity: ("relation", name) for a table,
217
+ index, sequence or view; ("schema", name); or ("column", table, column) for an ADD
218
+ COLUMN. None if the statement isn't one of these shapes — including any CREATE OR
219
+ REPLACE, which is never safe to treat as a no-op just because the object exists, since
220
+ the migration could be replacing it with different content."""
221
+ stripped = _strip_line_comments(stmt).strip()
222
+ if re.search(r"\bOR\s+REPLACE\b", stripped, re.IGNORECASE):
223
+ return None
224
+
225
+ if re.match(r"CREATE\s+TABLE\b", stripped, re.IGNORECASE):
226
+ names = _created_table_names([stmt])
227
+ return ("relation", names[0]) if names else None
228
+
229
+ m = _CREATE_RELATION_RE.match(stripped)
230
+ if m:
231
+ name = next(g for g in m.groups() if g is not None)
232
+ return ("relation", name)
233
+
234
+ m = _CREATE_SCHEMA_RE.match(stripped)
235
+ if m:
236
+ return ("schema", m.group(1))
237
+
238
+ m = _ADD_COLUMN_RE.match(stripped)
239
+ if m:
240
+ return ("column", m.group(1), m.group(2))
241
+
242
+ return None
243
+
244
+
245
+ def _target_exists(con: psycopg.Connection, target: tuple[str, ...]) -> bool:
246
+ kind = target[0]
247
+ if kind == "relation":
248
+ row = con.execute("select to_regclass(%s)", (target[1],)).fetchone()
249
+ elif kind == "schema":
250
+ row = con.execute("select to_regnamespace(%s)", (target[1],)).fetchone()
251
+ else: # column
252
+ row = con.execute(
253
+ "select 1 from pg_attribute where attrelid = to_regclass(%s) "
254
+ "and attname = %s and not attisdropped",
255
+ (target[1], target[2]),
256
+ ).fetchone()
257
+ return bool(row and row[0])
258
+
259
+
260
+ def already_fully_applied(conninfo: str, path: Path) -> bool:
261
+ """Whether every statement in this migration is a recognized create-if-missing shape
262
+ (table/index/sequence/view/schema, or add-column) AND its target already exists in the
263
+ database — i.e. re-running the migration would do nothing. Used by `--ask` to
264
+ auto-answer "already done" without prompting, so migrations that are trivially no-ops
265
+ don't interrupt review. A single unrecognized or not-yet-applied statement means
266
+ False — this never guesses."""
267
+ stmts = _split_sql(path.read_text(encoding="utf-8"))
268
+ if not stmts:
269
+ return False
270
+ targets = [_idempotent_target(s) for s in stmts]
271
+ if any(t is None for t in targets):
272
+ return False
273
+ with psycopg.connect(conninfo) as con:
274
+ return all(_target_exists(con, cast(tuple[str, ...], t)) for t in targets)
275
+
276
+
200
277
  def list_migration_files(migrations_dir: Path) -> list[Path]:
201
278
  return sorted(migrations_dir.glob("*.sql"))
202
279
 
pgdevkit/parser.py CHANGED
@@ -15,6 +15,14 @@ from .models import (
15
15
 
16
16
  logger = logging.getLogger(__name__)
17
17
 
18
+ # Tooling/scratch dirs that can end up inside a database/ tree (e.g. git
19
+ # worktrees checked out under database/.worktree for isolated test DBs) but
20
+ # never hold real schema content.
21
+ IGNORED_DIR_NAMES = {
22
+ ".worktree", ".worktrees", ".claude", ".vscode", "node_modules",
23
+ "__pycache__", ".pytest_cache", ".mypy_cache", ".ruff_cache",
24
+ }
25
+
18
26
  # Regex to extract dollar-quoted body (Postgres-only construct)
19
27
  _DOLLAR_BODY = re.compile(r'\$(\w*)\$(.*?)\$\1\$', re.DOTALL | re.IGNORECASE)
20
28
 
@@ -35,6 +43,8 @@ def parse_directory(scripts_dir: Path, *, dialect: str | Dialect = "postgres") -
35
43
  resolved = resolve_dialect(dialect)
36
44
  db_schema = DatabaseSchema()
37
45
  for sql_file in sorted(scripts_dir.rglob("*.sql")):
46
+ if IGNORED_DIR_NAMES.intersection(sql_file.relative_to(scripts_dir).parts[:-1]):
47
+ continue
38
48
  _parse_file(sql_file, db_schema, resolved)
39
49
  return db_schema
40
50
 
pgdevkit/testdb/schema.py CHANGED
@@ -15,6 +15,7 @@ from psycopg.sql import SQL, Identifier, Placeholder
15
15
 
16
16
  from ..db.complex_types import ComplexHelper
17
17
  from ..dialect import Dialect, POSTGRES
18
+ from ..parser import IGNORED_DIR_NAMES
18
19
 
19
20
  logger = logging.getLogger(__name__)
20
21
  logging.getLogger("sqlglot").setLevel(logging.ERROR)
@@ -121,7 +122,8 @@ def _get_sql_deps(sql: str, dialect: Dialect = POSTGRES) -> set[str]:
121
122
  def _iter_sql_files(database_dir: Path, dialect: Dialect = POSTGRES):
122
123
  """Yield (Path, sql_content) pairs in dependency-safe execution order."""
123
124
  files: list[Path] = []
124
- for root, _, dbfiles in os.walk(database_dir):
125
+ for root, dirs, dbfiles in os.walk(database_dir):
126
+ dirs[:] = [d for d in dirs if d not in IGNORED_DIR_NAMES]
125
127
  if "_migration_scripts" in root or "migrations" in root:
126
128
  continue
127
129
  for file in dbfiles:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pgdevkit
3
- Version: 0.3.4
3
+ Version: 0.3.8
4
4
  Summary: A helper for developing with Postgres
5
5
  Requires-Python: >=3.14
6
6
  Requires-Dist: docker>=7.1.0
@@ -1,15 +1,15 @@
1
1
  pgdevkit/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
- pgdevkit/cli.py,sha256=OKNFAq2PSr_ICWRKRK_9hAvvNnILd_KSUVXH_g2FgiE,16839
2
+ pgdevkit/cli.py,sha256=xP4fZB0Dg3wivhb00DcP2w2Vnp8BRyaR2o1wD7jopFc,17574
3
3
  pgdevkit/connection.py,sha256=x49aXKsJfyOsDTlqURs9idQUWaD-RO-XdtAqsG3guBI,4000
4
4
  pgdevkit/dialect.py,sha256=PL5Y8QPZNnoHIvAPomczGQX7IjZwxMgKGuREX80SwFA,3105
5
5
  pgdevkit/diff.py,sha256=Y2TbjwpFHYik0wZDhoI--9EAlIfp4AUg8BUubkOXyUg,9295
6
- pgdevkit/fetch_missing.py,sha256=1-2NBDCQNtirywO9Ucm775UKSACfAzcnQlp-4Jh2weE,8136
6
+ pgdevkit/fetch_missing.py,sha256=soSNqnbA06A0-b_OI7plf772eurFVfYNz20EGO6BxO8,8190
7
7
  pgdevkit/introspect.py,sha256=QYIlRuZk4lBHGxaW7zb_kFRIuNfi3AxH0Q2F_GKzlVI,7870
8
8
  pgdevkit/lakebase.py,sha256=0PABOk4p-2klLTY8QAUibQf9QeyNHKywUWEWGdkUY8k,3165
9
- pgdevkit/migrate.py,sha256=FmGawqgLZzIPcKvKQABddtn-_-EaiadxRe6mLwxR1Fs,10417
9
+ pgdevkit/migrate.py,sha256=pU7xVssJiAHfFFU60DP2cwpjQ1Tf8Hscc_Q6wIqeSfM,13677
10
10
  pgdevkit/models.py,sha256=dAZ8f7pgDsxoLVrDWsM4h0qwJ0pOzXDdWMGsnjrW80E,2103
11
11
  pgdevkit/mssql_introspect.py,sha256=Fhv1N4zHYEDZ0PEy0OIpawUmoIFeoi-Lm97pmEyDmYY,12031
12
- pgdevkit/parser.py,sha256=X1P8RbogRhsWc-H9w3IPlDo2blKpI33cJTvDzOXze1Y,15367
12
+ pgdevkit/parser.py,sha256=gbXqp5vlCD-JxlvENuJtGIuFvL3U6ndeBOySgkwWPzw,15820
13
13
  pgdevkit/backends/__init__.py,sha256=ys4VBglMImRtnudJBlTPVBNevDMRippuwAJOz0yRKiY,610
14
14
  pgdevkit/backends/base.py,sha256=0i0KLCYVWlpVFnUoq2t0LCRE_abyWPPIhw1bMxmW9KQ,1048
15
15
  pgdevkit/backends/mssql.py,sha256=pocBdoQ14ZnYLutXEOStVmJvmpclXGs3gaxHN0uJCMk,1068
@@ -30,7 +30,7 @@ pgdevkit/testdb/constants.py,sha256=xi8uIoEgN9LrRkr41j3mmWB9PqqU0r8aCUb2JJXBAs0,
30
30
  pgdevkit/testdb/container.py,sha256=kELTb6qaECAO9a5Bwvx1btv6i5QKRFAa9PX6auWymNo,2807
31
31
  pgdevkit/testdb/naming.py,sha256=Kyk5qEfxUkbggUG5bD7U7CJ_kAET332VPQNB-O5kvTY,1318
32
32
  pgdevkit/testdb/query.py,sha256=iYpy_EoYIq5USfwr6pNktmDjcGi_yQx0vHvjHZNGRr0,3854
33
- pgdevkit/testdb/schema.py,sha256=vw75HsiMvZPmMWaD5P2DeOaEKS86qksRXHDtOc8SjJE,11941
33
+ pgdevkit/testdb/schema.py,sha256=0N0Q_BQGh2kuAqa_j-DdbjYlTUOOZSEtaoUOApxQg_Y,12049
34
34
  pgdevkit/testdb/mssql/__init__.py,sha256=U4S_2y3zgLZVfMenHRaJFBW8yqh2mUBuI291LGQVOJ8,35
35
35
  pgdevkit/testdb/mssql/api.py,sha256=42RAuJeiZlK1WJaTIYoj6l8GEAbEyt6LgAoF3G2jxnw,7537
36
36
  pgdevkit/testdb/mssql/constants.py,sha256=7n_NcYuhabbtb18CqG_H_rRbI7Wb_qu_l4nKk3281vI,2847
@@ -39,7 +39,7 @@ pgdevkit/docs/database-layout.md,sha256=4GBwv4PA2cKHl2BxwLd-awLOe9he9ie4X6CpQWjI
39
39
  pgdevkit/skills/pgdevkit/SKILL.md,sha256=6uxe6BQwOLexGiBpf00VTFTHOiWJo5k2Qqzu172rb4I,12156
40
40
  pgdevkit/skills/pgdevkit/references/dynamic-sql.md,sha256=aTR1sx81moiTwaMaiOecaqk-9rT8y5Zxz7W1BoC3vK0,2774
41
41
  pgdevkit/skills/pgdevkit/references/temporal-tables.md,sha256=LwZnItvQWI7bB0oRHxIfUc0ynnq7N0V5Mnv-QHvFN3Q,1364
42
- pgdevkit-0.3.4.dist-info/METADATA,sha256=YPVRf3mQo-OV1PpCL0rNv6SbHiv3y7-ic5Z1BTMrc50,11346
43
- pgdevkit-0.3.4.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
44
- pgdevkit-0.3.4.dist-info/entry_points.txt,sha256=FK2P55lf0WaGlOZJIINOGgCt8WB7ep0OnPsgnrp80bg,42
45
- pgdevkit-0.3.4.dist-info/RECORD,,
42
+ pgdevkit-0.3.8.dist-info/METADATA,sha256=jQlfYkvzEolSPiofscelGtur9rzWQClSvOMQxIuc7Uo,11346
43
+ pgdevkit-0.3.8.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
44
+ pgdevkit-0.3.8.dist-info/entry_points.txt,sha256=FK2P55lf0WaGlOZJIINOGgCt8WB7ep0OnPsgnrp80bg,42
45
+ pgdevkit-0.3.8.dist-info/RECORD,,