pgdevkit 0.2.0__tar.gz → 0.2.2__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 (78) hide show
  1. {pgdevkit-0.2.0 → pgdevkit-0.2.2}/.github/workflows/python-test.yml +0 -2
  2. {pgdevkit-0.2.0 → pgdevkit-0.2.2}/PKG-INFO +13 -7
  3. {pgdevkit-0.2.0 → pgdevkit-0.2.2}/README.md +10 -5
  4. pgdevkit-0.2.2/pgdevkit/testdb/container.py +114 -0
  5. {pgdevkit-0.2.0 → pgdevkit-0.2.2}/pgdevkit/testdb/schema.py +49 -55
  6. {pgdevkit-0.2.0 → pgdevkit-0.2.2}/pyproject.toml +3 -2
  7. {pgdevkit-0.2.0 → pgdevkit-0.2.2}/skills/pgdevkit/SKILL.md +4 -2
  8. {pgdevkit-0.2.0 → pgdevkit-0.2.2}/tests/testdb/conftest.py +12 -2
  9. {pgdevkit-0.2.0 → pgdevkit-0.2.2}/tests/testdb/test_container.py +6 -6
  10. {pgdevkit-0.2.0 → pgdevkit-0.2.2}/tests/testdb/test_schema.py +7 -42
  11. {pgdevkit-0.2.0 → pgdevkit-0.2.2}/uv.lock +32 -23
  12. pgdevkit-0.2.0/pgdevkit/testdb/container.py +0 -89
  13. pgdevkit-0.2.0/tests/testdb/fixtures/database/app/migrations/002_unsafe_drop_gadget_note.sql +0 -3
  14. {pgdevkit-0.2.0 → pgdevkit-0.2.2}/.github/workflows/python-publish.yml +0 -0
  15. {pgdevkit-0.2.0 → pgdevkit-0.2.2}/.gitignore +0 -0
  16. {pgdevkit-0.2.0 → pgdevkit-0.2.2}/.python-version +0 -0
  17. {pgdevkit-0.2.0 → pgdevkit-0.2.2}/docs/database-layout.md +0 -0
  18. {pgdevkit-0.2.0 → pgdevkit-0.2.2}/pgdevkit/__init__.py +0 -0
  19. {pgdevkit-0.2.0 → pgdevkit-0.2.2}/pgdevkit/cli.py +0 -0
  20. {pgdevkit-0.2.0 → pgdevkit-0.2.2}/pgdevkit/connection.py +0 -0
  21. {pgdevkit-0.2.0 → pgdevkit-0.2.2}/pgdevkit/db/__init__.py +0 -0
  22. {pgdevkit-0.2.0 → pgdevkit-0.2.2}/pgdevkit/db/complex_types.py +0 -0
  23. {pgdevkit-0.2.0 → pgdevkit-0.2.2}/pgdevkit/db/connection.py +0 -0
  24. {pgdevkit-0.2.0 → pgdevkit-0.2.2}/pgdevkit/db/crud.py +0 -0
  25. {pgdevkit-0.2.0 → pgdevkit-0.2.2}/pgdevkit/db/loader.py +0 -0
  26. {pgdevkit-0.2.0 → pgdevkit-0.2.2}/pgdevkit/db/model.py +0 -0
  27. {pgdevkit-0.2.0 → pgdevkit-0.2.2}/pgdevkit/diff.py +0 -0
  28. {pgdevkit-0.2.0 → pgdevkit-0.2.2}/pgdevkit/fetch_missing.py +0 -0
  29. {pgdevkit-0.2.0 → pgdevkit-0.2.2}/pgdevkit/introspect.py +0 -0
  30. {pgdevkit-0.2.0 → pgdevkit-0.2.2}/pgdevkit/lakebase.py +0 -0
  31. {pgdevkit-0.2.0 → pgdevkit-0.2.2}/pgdevkit/models.py +0 -0
  32. {pgdevkit-0.2.0 → pgdevkit-0.2.2}/pgdevkit/parser.py +0 -0
  33. {pgdevkit-0.2.0 → pgdevkit-0.2.2}/pgdevkit/testdb/__init__.py +0 -0
  34. {pgdevkit-0.2.0 → pgdevkit-0.2.2}/pgdevkit/testdb/api.py +0 -0
  35. {pgdevkit-0.2.0 → pgdevkit-0.2.2}/pgdevkit/testdb/config.py +0 -0
  36. {pgdevkit-0.2.0 → pgdevkit-0.2.2}/pgdevkit/testdb/constants.py +0 -0
  37. {pgdevkit-0.2.0 → pgdevkit-0.2.2}/pgdevkit/testdb/naming.py +0 -0
  38. {pgdevkit-0.2.0 → pgdevkit-0.2.2}/pgdevkit/testdb/query.py +0 -0
  39. {pgdevkit-0.2.0 → pgdevkit-0.2.2}/skills/pgdevkit/references/dynamic-sql.md +0 -0
  40. {pgdevkit-0.2.0 → pgdevkit-0.2.2}/skills/pgdevkit/references/temporal-tables.md +0 -0
  41. {pgdevkit-0.2.0 → pgdevkit-0.2.2}/tests/__init__.py +0 -0
  42. {pgdevkit-0.2.0 → pgdevkit-0.2.2}/tests/conftest.py +0 -0
  43. {pgdevkit-0.2.0 → pgdevkit-0.2.2}/tests/db/__init__.py +0 -0
  44. {pgdevkit-0.2.0 → pgdevkit-0.2.2}/tests/db/test_complex_types.py +0 -0
  45. {pgdevkit-0.2.0 → pgdevkit-0.2.2}/tests/db/test_connection.py +0 -0
  46. {pgdevkit-0.2.0 → pgdevkit-0.2.2}/tests/db/test_crud.py +0 -0
  47. {pgdevkit-0.2.0 → pgdevkit-0.2.2}/tests/db/test_loader.py +0 -0
  48. {pgdevkit-0.2.0 → pgdevkit-0.2.2}/tests/fixtures/01_schema.sql +0 -0
  49. {pgdevkit-0.2.0 → pgdevkit-0.2.2}/tests/fixtures/02_types.sql +0 -0
  50. {pgdevkit-0.2.0 → pgdevkit-0.2.2}/tests/fixtures/03_tables.sql +0 -0
  51. {pgdevkit-0.2.0 → pgdevkit-0.2.2}/tests/fixtures/04_views.sql +0 -0
  52. {pgdevkit-0.2.0 → pgdevkit-0.2.2}/tests/fixtures/05_functions.sql +0 -0
  53. {pgdevkit-0.2.0 → pgdevkit-0.2.2}/tests/fixtures/06_indexes.sql +0 -0
  54. {pgdevkit-0.2.0 → pgdevkit-0.2.2}/tests/test_cli_compare.py +0 -0
  55. {pgdevkit-0.2.0 → pgdevkit-0.2.2}/tests/test_compare.py +0 -0
  56. {pgdevkit-0.2.0 → pgdevkit-0.2.2}/tests/test_connection.py +0 -0
  57. {pgdevkit-0.2.0 → pgdevkit-0.2.2}/tests/test_fetch_missing.py +0 -0
  58. {pgdevkit-0.2.0 → pgdevkit-0.2.2}/tests/test_fetch_missing_cli.py +0 -0
  59. {pgdevkit-0.2.0 → pgdevkit-0.2.2}/tests/test_lakebase.py +0 -0
  60. {pgdevkit-0.2.0 → pgdevkit-0.2.2}/tests/testdb/__init__.py +0 -0
  61. {pgdevkit-0.2.0 → pgdevkit-0.2.2}/tests/testdb/fixtures/database/app/migrations/001_add_gadget_note.sql +0 -0
  62. {pgdevkit-0.2.0 → pgdevkit-0.2.2}/tests/testdb/fixtures/database/app/tables/gadget.sql +0 -0
  63. {pgdevkit-0.2.0 → pgdevkit-0.2.2}/tests/testdb/fixtures/database/app/tables/gadget.test_data.json +0 -0
  64. {pgdevkit-0.2.0 → pgdevkit-0.2.2}/tests/testdb/fixtures/database/app/tables/widget.sql +0 -0
  65. {pgdevkit-0.2.0 → pgdevkit-0.2.2}/tests/testdb/fixtures/database/app/tables/widget.test_data.json +0 -0
  66. {pgdevkit-0.2.0 → pgdevkit-0.2.2}/tests/testdb/fixtures/database/app/tables/widget_part.sql +0 -0
  67. {pgdevkit-0.2.0 → pgdevkit-0.2.2}/tests/testdb/fixtures/database/app/tables/widget_part_detail.sql +0 -0
  68. {pgdevkit-0.2.0 → pgdevkit-0.2.2}/tests/testdb/fixtures/database/app/types/dimensions.sql +0 -0
  69. {pgdevkit-0.2.0 → pgdevkit-0.2.2}/tests/testdb/fixtures/database/app/types/mood.sql +0 -0
  70. {pgdevkit-0.2.0 → pgdevkit-0.2.2}/tests/testdb/fixtures/database/app/views/a_wrapper_view.sql +0 -0
  71. {pgdevkit-0.2.0 → pgdevkit-0.2.2}/tests/testdb/fixtures/database/app/views/b_base_view.sql +0 -0
  72. {pgdevkit-0.2.0 → pgdevkit-0.2.2}/tests/testdb/fixtures/database/schema/app.sql +0 -0
  73. {pgdevkit-0.2.0 → pgdevkit-0.2.2}/tests/testdb/test_api.py +0 -0
  74. {pgdevkit-0.2.0 → pgdevkit-0.2.2}/tests/testdb/test_cli.py +0 -0
  75. {pgdevkit-0.2.0 → pgdevkit-0.2.2}/tests/testdb/test_config.py +0 -0
  76. {pgdevkit-0.2.0 → pgdevkit-0.2.2}/tests/testdb/test_constants.py +0 -0
  77. {pgdevkit-0.2.0 → pgdevkit-0.2.2}/tests/testdb/test_naming.py +0 -0
  78. {pgdevkit-0.2.0 → pgdevkit-0.2.2}/tests/testdb/test_query.py +0 -0
@@ -24,8 +24,6 @@ jobs:
24
24
  uses: actions/setup-python@v5
25
25
  with:
26
26
  python-version: ${{ matrix.python-version }}
27
- - name: Ensure podman is installed
28
- run: command -v podman || (sudo apt-get update && sudo apt-get install -y podman)
29
27
  - name: Install uv
30
28
  run: curl -LsSf https://astral.sh/uv/install.sh | sh
31
29
  - name: Install project dependencies
@@ -1,10 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pgdevkit
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: A helper for developing with Postgres
5
5
  Requires-Python: >=3.14
6
+ Requires-Dist: docker>=7.1.0
6
7
  Requires-Dist: psycopg[binary]>=3.2.0
7
- Requires-Dist: sqlglot[c]>=30.11.0
8
+ Requires-Dist: sqlglot>=30.11.0
8
9
  Provides-Extra: azure
9
10
  Requires-Dist: azure-identity>=1.19.0; extra == 'azure'
10
11
  Provides-Extra: cli
@@ -87,15 +88,20 @@ CLI: `pgdb testdb up|reset|run-sql|status|shell|clean`.
87
88
 
88
89
  Container connection defaults (`localhost:54322`, `postgres`/`testpwd`) can
89
90
  be overridden with `PGDEVKIT_TESTDB_HOST`, `PGDEVKIT_TESTDB_PORT`,
90
- `PGDEVKIT_TESTDB_USER`, `PGDEVKIT_TESTDB_PASSWORD`. Before touching
91
- podman/docker, pgdevkit first checks (with a short timeout) whether Postgres
91
+ `PGDEVKIT_TESTDB_USER`, `PGDEVKIT_TESTDB_PASSWORD`. Before touching the
92
+ Docker API, pgdevkit first checks (with a short timeout) whether Postgres
92
93
  is already reachable at that address and skips container management if so.
93
94
  Set `PGDEVKIT_SKIP_CONTAINER=1` to always assume it's already there and skip
94
95
  that check too.
95
96
 
96
- To point at a local Postgres install instead of the podman container —
97
- useful when podman isn't available, or you'd rather use peer authentication
98
- as the current OS user set `PGDEVKIT_TESTDB_HOST` to the unix socket
97
+ Container management goes through the Docker API (the `docker` package,
98
+ `docker.from_env()`, falling back to Podman's rootful/rootless socket) it
99
+ works against a real Docker daemon or Podman transparently, no CLI binary
100
+ required either way.
101
+
102
+ To point at a local Postgres install instead of the container — useful when
103
+ neither is available, or you'd rather use peer authentication as the
104
+ current OS user — set `PGDEVKIT_TESTDB_HOST` to the unix socket
99
105
  directory (e.g. `/var/run/postgresql`) and `PGDEVKIT_TESTDB_PASSWORD=""`.
100
106
  The role named by `PGDEVKIT_TESTDB_USER` must exist and match your OS user
101
107
  (`CREATE ROLE <user> SUPERUSER LOGIN;`) and `pg_hba.conf` must allow `peer`
@@ -70,15 +70,20 @@ CLI: `pgdb testdb up|reset|run-sql|status|shell|clean`.
70
70
 
71
71
  Container connection defaults (`localhost:54322`, `postgres`/`testpwd`) can
72
72
  be overridden with `PGDEVKIT_TESTDB_HOST`, `PGDEVKIT_TESTDB_PORT`,
73
- `PGDEVKIT_TESTDB_USER`, `PGDEVKIT_TESTDB_PASSWORD`. Before touching
74
- podman/docker, pgdevkit first checks (with a short timeout) whether Postgres
73
+ `PGDEVKIT_TESTDB_USER`, `PGDEVKIT_TESTDB_PASSWORD`. Before touching the
74
+ Docker API, pgdevkit first checks (with a short timeout) whether Postgres
75
75
  is already reachable at that address and skips container management if so.
76
76
  Set `PGDEVKIT_SKIP_CONTAINER=1` to always assume it's already there and skip
77
77
  that check too.
78
78
 
79
- To point at a local Postgres install instead of the podman container —
80
- useful when podman isn't available, or you'd rather use peer authentication
81
- as the current OS user set `PGDEVKIT_TESTDB_HOST` to the unix socket
79
+ Container management goes through the Docker API (the `docker` package,
80
+ `docker.from_env()`, falling back to Podman's rootful/rootless socket) it
81
+ works against a real Docker daemon or Podman transparently, no CLI binary
82
+ required either way.
83
+
84
+ To point at a local Postgres install instead of the container — useful when
85
+ neither is available, or you'd rather use peer authentication as the
86
+ current OS user — set `PGDEVKIT_TESTDB_HOST` to the unix socket
82
87
  directory (e.g. `/var/run/postgresql`) and `PGDEVKIT_TESTDB_PASSWORD=""`.
83
88
  The role named by `PGDEVKIT_TESTDB_USER` must exist and match your OS user
84
89
  (`CREATE ROLE <user> SUPERUSER LOGIN;`) and `pg_hba.conf` must allow `peer`
@@ -0,0 +1,114 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import time
5
+
6
+ import docker
7
+ import docker.errors
8
+ import psycopg
9
+
10
+ from . import constants
11
+
12
+ # Candidate Docker-API-compatible socket URLs tried after plain
13
+ # docker.from_env() (which only looks at DOCKER_HOST / the default Docker
14
+ # socket) fails to connect -- covers rootful and rootless Podman, which
15
+ # speaks the same API but doesn't always advertise itself via DOCKER_HOST.
16
+ _FALLBACK_SOCKET_URLS = [
17
+ f"unix://{os.environ['XDG_RUNTIME_DIR']}/podman/podman.sock" if os.environ.get("XDG_RUNTIME_DIR") else None,
18
+ "unix:///run/podman/podman.sock",
19
+ ]
20
+
21
+
22
+ def _client() -> docker.DockerClient:
23
+ """A Docker-API client, working against a real Docker daemon or a
24
+ Podman one (Podman exposes the same API over its own socket) -- callers
25
+ never need to know or care which one is actually running."""
26
+ try:
27
+ client = docker.from_env()
28
+ client.ping()
29
+ return client
30
+ except Exception: # noqa: BLE001
31
+ pass
32
+ for base_url in _FALLBACK_SOCKET_URLS:
33
+ if base_url is None:
34
+ continue
35
+ try:
36
+ client = docker.DockerClient(base_url=base_url)
37
+ client.ping()
38
+ return client
39
+ except Exception: # noqa: BLE001
40
+ continue
41
+ raise RuntimeError(
42
+ "Could not reach a Docker-compatible API. Set DOCKER_HOST, or make sure "
43
+ "Docker or Podman's API socket is running."
44
+ )
45
+
46
+
47
+ def _available(timeout: float = 3.0) -> bool:
48
+ """Quick check (short timeout) for whether Postgres is already reachable
49
+ at HOST:PORT, so a database started outside pgdevkit's control (or the
50
+ container from a previous run) doesn't trigger another Docker API call."""
51
+ # libpq's connect_timeout is whole seconds; anything below 1 means "wait
52
+ # indefinitely" instead of a short timeout, so it's clamped up to 1.
53
+ connect_timeout = max(1, round(timeout))
54
+ dsn = constants.conninfo("postgres", connect_timeout=connect_timeout)
55
+ try:
56
+ with psycopg.connect(dsn):
57
+ return True
58
+ except Exception: # noqa: BLE001
59
+ return False
60
+
61
+
62
+ def _create_container(client: docker.DockerClient) -> None:
63
+ try:
64
+ client.containers.run(
65
+ constants.IMAGE,
66
+ name=constants.CONTAINER_NAME,
67
+ detach=True,
68
+ ports={"5432/tcp": constants.PORT},
69
+ environment={
70
+ "POSTGRES_USER": constants.USER,
71
+ "POSTGRES_PASSWORD": constants.PASSWORD,
72
+ },
73
+ command=["postgres", *constants.PG_SPEED_FLAGS],
74
+ )
75
+ except docker.errors.APIError as e:
76
+ if getattr(e, "status_code", None) == 409 or "already in use" in str(e):
77
+ client.containers.get(constants.CONTAINER_NAME).start()
78
+ return
79
+ raise RuntimeError(f"Starting the {constants.CONTAINER_NAME} container failed: {e}") from e
80
+
81
+
82
+ def _wait_ready(timeout: float = 30.0) -> None:
83
+ deadline = time.monotonic() + timeout
84
+ dsn = constants.conninfo("postgres", connect_timeout=2)
85
+ last_error: Exception | None = None
86
+ while time.monotonic() < deadline:
87
+ try:
88
+ with psycopg.connect(dsn):
89
+ return
90
+ except Exception as e: # noqa: BLE001
91
+ last_error = e
92
+ time.sleep(0.5)
93
+ raise RuntimeError(f"Postgres did not become ready within {timeout}s: {last_error}")
94
+
95
+
96
+ def ensure_container() -> None:
97
+ """Idempotently ensure the shared pgdevkit-postgres container is running
98
+ and accepting connections. Never touches the Docker API if Postgres is
99
+ already reachable, or if PGDEVKIT_SKIP_CONTAINER says to assume it is."""
100
+ if os.environ.get("PGDEVKIT_SKIP_CONTAINER"):
101
+ return
102
+ if _available():
103
+ return
104
+ client = _client()
105
+ try:
106
+ container = client.containers.get(constants.CONTAINER_NAME)
107
+ except docker.errors.NotFound:
108
+ container = None
109
+ if container is not None:
110
+ if container.status != "running":
111
+ container.start()
112
+ else:
113
+ _create_container(client)
114
+ _wait_ready()
@@ -63,8 +63,31 @@ _SCHEMA_QUALIFIED_TYPES = {
63
63
  }
64
64
 
65
65
 
66
+ _DECLARE_RE = re.compile(
67
+ r"CREATE\s+(?:OR\s+REPLACE\s+)?(?:TABLE|VIEW|FUNCTION|PROCEDURE|TYPE|SCHEMA)\s+(\w+\.\w+)", re.IGNORECASE
68
+ )
69
+ _DEPEND_RE = re.compile(r"(?:FROM|JOIN|INTO|UPDATE|TABLE|ON)\s+(\w+\.\w+)", re.IGNORECASE)
70
+
71
+
72
+ def _get_sql_deps_regex_fallback(sql: str) -> set[str]:
73
+ """Crude regex scan used when sqlglot can't parse a statement even with
74
+ error_level=IGNORE. This only feeds dependency *ordering* (which file to
75
+ apply first), not execution, so an imprecise-but-safe approximation here
76
+ is fine."""
77
+ declares = set(_DECLARE_RE.findall(sql))
78
+ deps = set(_DEPEND_RE.findall(sql))
79
+ return deps - declares
80
+
81
+
66
82
  def _get_sql_deps(sql: str) -> set[str]:
67
- exprs = sqlglot.parse(sql, dialect="postgres")
83
+ try:
84
+ # error_level=IGNORE lets sqlglot recover from statements it can't
85
+ # fully parse (e.g. a schema-qualified `DROP TRIGGER ... ON
86
+ # schema.table`) and keep going, instead of raising and losing every
87
+ # other statement's dependency info in the same file.
88
+ exprs = sqlglot.parse(sql, dialect="postgres", error_level=sqlglot.ErrorLevel.IGNORE)
89
+ except Exception: # noqa: BLE001
90
+ return _get_sql_deps_regex_fallback(sql)
68
91
  deps: set[str] = set()
69
92
  for e in exprs:
70
93
  if e is None:
@@ -172,54 +195,19 @@ async def _insert_test_data(
172
195
  await cur.executemany(insert_sql, rows)
173
196
 
174
197
 
175
- _UNSAFE_MIGRATION_PATTERNS = (
176
- "DROP COLUMN",
177
- "DROP TABLE",
178
- "TRUNCATE",
179
- "RENAME COLUMN",
180
- "RENAME TO",
181
- "DELETE FROM",
182
- "OWNER TO",
183
- )
184
-
185
-
186
- def _is_additive_migration(sql: str) -> bool:
187
- """Only pure-additive migrations (ADD COLUMN/CREATE ... IF NOT EXISTS,
188
- etc.) are safe to (re-)apply against a schema that already reflects
189
- later migrations — skip anything that could drop, rename, truncate, or
190
- delete existing structure/data, alter an existing column, or change
191
- ownership. This is a substring heuristic, not a SQL parser, so it errs
192
- towards skipping (a false negative here means real data loss replayed
193
- on every apply_schema() call) rather than trying to be exhaustive."""
194
- normalized = sql.upper()
195
- has_alter_column = "ALTER COLUMN" in normalized and "ADD COLUMN" not in normalized
196
- has_unsafe_pattern = any(pattern in normalized for pattern in _UNSAFE_MIGRATION_PATTERNS)
197
- return not (has_alter_column or has_unsafe_pattern)
198
-
199
-
200
- def _iter_migration_files(database_dir: Path):
201
- """Yield every `*.sql` file in any `migrations/` subdirectory, in
202
- filename order (the project's naming convention — numeric or date
203
- prefixes — sorts chronologically)."""
204
- for root, _, files in os.walk(database_dir):
205
- if Path(root).name != "migrations":
206
- continue
207
- for file in sorted(files):
208
- if file.endswith(".sql"):
209
- yield Path(root) / file
210
-
211
-
212
198
  async def apply_schema(
213
199
  con: psycopg.AsyncConnection,
214
200
  database_dir: Path,
215
201
  extensions: tuple[str, ...] = (),
216
202
  force_reset: bool = False,
217
203
  ) -> None:
218
- """Apply every .sql file under database_dir (in dependency-safe order),
219
- seed any matching .test_data.json files, then apply purely-additive
220
- `migrations/*.sql` files (see `_is_additive_migration`) so a schema that
221
- ships changes via migration files rather than editing the base object
222
- files stays in sync. Safe to call repeatedly."""
204
+ """Apply every .sql file under database_dir (in dependency-safe order)
205
+ and seed any matching .test_data.json files. Safe to call repeatedly.
206
+
207
+ `migrations/` subdirectories are never applied here they're for
208
+ one-time manual application against real (already-provisioned)
209
+ databases, not for building a fresh schema. The base object files under
210
+ `database_dir` must reflect the current, final schema on their own."""
223
211
  await con.set_autocommit(True)
224
212
  for extension in extensions:
225
213
  await con.execute(SQL("CREATE EXTENSION IF NOT EXISTS {e}").format(e=Identifier(extension)))
@@ -241,15 +229,21 @@ async def apply_schema(
241
229
  logger.warning("Error executing %s (will retry): %s", file, e)
242
230
  failures.append((file, sql))
243
231
 
244
- for file, sql in failures:
245
- await _apply(file, sql)
246
-
247
- for migration_file in _iter_migration_files(database_dir):
248
- content = migration_file.read_text(encoding="utf-8")
249
- if not _is_additive_migration(content):
250
- continue
251
- try:
252
- await con.execute(cast(Any, content))
253
- logger.info("Applied migration %s", migration_file.name)
254
- except Exception as e: # noqa: BLE001
255
- logger.debug("Migration %s skipped (likely already applied): %s", migration_file.name, e)
232
+ # Retry the whole failed set, not just once: a single extra pass in
233
+ # original order can still raise on an item whose dependency is later
234
+ # in the same list and hasn't had its own retry yet. Every CREATE here
235
+ # is idempotent, so looping until a full pass makes no progress
236
+ # converges on any resolvable ordering without re-doing finished work.
237
+ while failures:
238
+ still_failing: list[tuple[Path, str]] = []
239
+ last_error: Exception | None = None
240
+ for file, sql in failures:
241
+ try:
242
+ await _apply(file, sql)
243
+ except Exception as e: # noqa: BLE001
244
+ still_failing.append((file, sql))
245
+ last_error = e
246
+ if len(still_failing) == len(failures):
247
+ assert last_error is not None
248
+ raise last_error
249
+ failures = still_failing
@@ -11,13 +11,14 @@ packages = ["pgdevkit"]
11
11
 
12
12
  [project]
13
13
  name = "pgdevkit"
14
- version = "0.2.0"
14
+ version = "0.2.2"
15
15
  description = "A helper for developing with Postgres"
16
16
  readme = "README.md"
17
17
  requires-python = ">=3.14"
18
18
  dependencies = [
19
+ "docker>=7.1.0",
19
20
  "psycopg[binary]>=3.2.0",
20
- "sqlglot[c]>=30.11.0",
21
+ "sqlglot>=30.11.0",
21
22
  ]
22
23
 
23
24
  [project.optional-dependencies]
@@ -73,7 +73,9 @@ def _testdb_env():
73
73
  os.environ[key] = value
74
74
  ```
75
75
 
76
- `ensure_testdb()` starts the shared `pgdevkit-postgres` container if needed (via `podman`), creates a database scoped to this project+branch (so different worktrees/branches never collide), and applies every `.sql` file under `database_dir` in dependency order, seeding any `.test_data.json` sidecar files.
76
+ `ensure_testdb()` starts the shared `pgdevkit-postgres` container if needed (via the Docker API — works against a real Docker daemon or Podman's socket, no CLI binary required), creates a database scoped to this project+branch (so different worktrees/branches never collide), and applies every `.sql` file under `database_dir` in dependency order, seeding any `.test_data.json` sidecar files.
77
+
78
+ **`migrations/` is never applied here, on purpose.** If the test schema is missing something, that's a sign the base `tables/`/`views`/... file has drifted behind a migration that was only ever run manually against a real database — fix the base file, don't add migration-replay to `apply_schema()` (tried once, reverted: a migration can't be judged "safe to re-run" from its SQL text alone — see `docs/database-layout.md`'s Migrations section).
77
79
 
78
80
  | What do you need? | Command |
79
81
  |---|---|
@@ -86,7 +88,7 @@ def _testdb_env():
86
88
 
87
89
  ### CI
88
90
 
89
- Podman needs to be available on the runner (`apt-get install -y podman` on `ubuntu-latest` if not preinstalled) `ensure_testdb()`/`ensure_container()` shell out to it directly. There's no `PGDEVKIT_SKIP_CONTAINER` + service-container escape hatch wired through every fixture yet — if a project's CI can't run podman, it needs its own workaround for now.
91
+ `ensure_testdb()`/`ensure_container()` talk to whatever Docker-compatible API is reachable (`DOCKER_HOST`, the default Docker socket, or Podman's socket as a fallback) — `ubuntu-latest`'s preinstalled Docker daemon just works, no setup step needed. There's no `PGDEVKIT_SKIP_CONTAINER` + service-container escape hatch wired through every fixture yet — if a project's CI has neither Docker nor Podman reachable, it needs its own workaround for now.
90
92
 
91
93
  ---
92
94
 
@@ -1,15 +1,25 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import os
4
- import shutil
5
4
  import subprocess
6
5
  from pathlib import Path
7
6
  from typing import Callable
8
7
 
9
8
  import pytest
10
9
 
10
+
11
+ def _has_container_runtime() -> bool:
12
+ from pgdevkit.testdb.container import _client
13
+
14
+ try:
15
+ _client()
16
+ return True
17
+ except Exception: # noqa: BLE001
18
+ return False
19
+
20
+
11
21
  requires_podman = pytest.mark.skipif(
12
- shutil.which("podman") is None, reason="podman is not installed"
22
+ not _has_container_runtime(), reason="no Docker-compatible API reachable"
13
23
  )
14
24
 
15
25
  FIXTURES = Path(__file__).parent / "fixtures" / "database"
@@ -32,10 +32,10 @@ def test_ensure_container_skips_podman_when_already_available(monkeypatch):
32
32
  monkeypatch.setattr(container, "_available", lambda timeout=1.0: True)
33
33
 
34
34
  def _fail(*a, **k):
35
- raise AssertionError("must not shell out to podman when already available")
35
+ raise AssertionError("must not touch the Docker API when already available")
36
36
 
37
- monkeypatch.setattr(container, "_podman", _fail)
38
- ensure_container() # must return without calling podman
37
+ monkeypatch.setattr(container, "_client", _fail)
38
+ ensure_container() # must return without calling the Docker API
39
39
 
40
40
 
41
41
  def test_ensure_container_skips_everything_when_skip_env_set(monkeypatch):
@@ -45,8 +45,8 @@ def test_ensure_container_skips_everything_when_skip_env_set(monkeypatch):
45
45
  raise AssertionError("must not run when PGDEVKIT_SKIP_CONTAINER is set")
46
46
 
47
47
  monkeypatch.setattr(container, "_available", _fail)
48
- monkeypatch.setattr(container, "_podman", _fail)
49
- ensure_container() # must return without checking availability or calling podman
48
+ monkeypatch.setattr(container, "_client", _fail)
49
+ ensure_container() # must return without checking availability or calling the Docker API
50
50
 
51
51
 
52
52
  def _admin_dsn() -> str:
@@ -78,7 +78,7 @@ def test_ensure_container_is_idempotent():
78
78
  def test_create_container_falls_back_to_start_when_name_in_use():
79
79
  ensure_container() # container already exists under constants.CONTAINER_NAME
80
80
 
81
- _create_container() # "podman run" will fail with "already in use"; must fall back to "podman start"
81
+ _create_container(container._client()) # run will conflict; must fall back to start()
82
82
 
83
83
  with psycopg.connect(_admin_dsn()) as con:
84
84
  with con.cursor() as cur:
@@ -7,34 +7,13 @@ import pytest
7
7
 
8
8
  from pgdevkit.testdb import constants
9
9
  from pgdevkit.testdb.container import ensure_container
10
- from pgdevkit.testdb.schema import _is_additive_migration, apply_schema
10
+ from pgdevkit.testdb.schema import apply_schema
11
11
  from tests.testdb.conftest import RUN_SUFFIX, requires_podman
12
12
 
13
13
  FIXTURES = Path(__file__).parent / "fixtures" / "database"
14
14
  TEST_DB = f"pgdevkit_schema_selftest_{RUN_SUFFIX}"
15
15
 
16
16
 
17
- @pytest.mark.parametrize(
18
- "sql,expected",
19
- [
20
- ("ALTER TABLE t ADD COLUMN IF NOT EXISTS x text;", True),
21
- ("CREATE TABLE IF NOT EXISTS t (id serial primary key);", True),
22
- ("CREATE INDEX IF NOT EXISTS idx ON t(x);", True),
23
- ("ALTER TABLE t ALTER COLUMN x TYPE text;", False),
24
- ("ALTER TABLE t DROP COLUMN x;", False),
25
- ("DROP TABLE t;", False),
26
- ("DROP TABLE IF EXISTS t;", False),
27
- ("TRUNCATE t;", False),
28
- ("ALTER TABLE t RENAME COLUMN x TO y;", False),
29
- ("ALTER TABLE t RENAME TO t2;", False),
30
- ("DELETE FROM t WHERE id = 1;", False),
31
- ("ALTER TABLE t OWNER TO someone;", False),
32
- ],
33
- )
34
- def test_is_additive_migration(sql, expected):
35
- assert _is_additive_migration(sql) is expected
36
-
37
-
38
17
  def _admin_dsn() -> str:
39
18
  return constants.conninfo("postgres")
40
19
 
@@ -136,25 +115,11 @@ async def test_apply_schema_seeds_composite_enum_and_jsonb_columns(schema_test_d
136
115
 
137
116
 
138
117
  @requires_podman
139
- async def test_apply_schema_applies_additive_migrations(schema_test_db):
140
- # app/migrations/001_add_gadget_note.sql adds a column not present in
141
- # gadget.sql itselfonly reachable via the migrations pass.
142
- async with await psycopg.AsyncConnection.connect(_db_dsn(), autocommit=True) as con:
143
- await apply_schema(con, FIXTURES)
144
- async with con.cursor() as cur:
145
- await cur.execute(
146
- "SELECT column_name FROM information_schema.columns "
147
- "WHERE table_schema = 'app' AND table_name = 'gadget' AND column_name = 'note'"
148
- )
149
- row = await cur.fetchone()
150
- assert row is not None
151
-
152
-
153
- @requires_podman
154
- async def test_apply_schema_skips_unsafe_migrations(schema_test_db):
155
- # app/migrations/002_unsafe_drop_gadget_note.sql drops the column that
156
- # 001 added — apply_schema must skip it (not execute it), so the column
157
- # added by 001 must still be present afterwards.
118
+ async def test_apply_schema_never_applies_migrations_dir(schema_test_db):
119
+ # migrations/ is for one-time manual application against real databases,
120
+ # not for building a fresh schema the base table file is the only
121
+ # source of truth apply_schema uses. app/migrations/001_add_gadget_note.sql
122
+ # adds a column that gadget.sql itself doesn't have; it must NOT appear.
158
123
  async with await psycopg.AsyncConnection.connect(_db_dsn(), autocommit=True) as con:
159
124
  await apply_schema(con, FIXTURES)
160
125
  async with con.cursor() as cur:
@@ -163,4 +128,4 @@ async def test_apply_schema_skips_unsafe_migrations(schema_test_db):
163
128
  "WHERE table_schema = 'app' AND table_name = 'gadget' AND column_name = 'note'"
164
129
  )
165
130
  row = await cur.fetchone()
166
- assert row is not None
131
+ assert row is None
@@ -191,6 +191,20 @@ wheels = [
191
191
  { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" },
192
192
  ]
193
193
 
194
+ [[package]]
195
+ name = "docker"
196
+ version = "7.2.0"
197
+ source = { registry = "https://pypi.org/simple" }
198
+ dependencies = [
199
+ { name = "pywin32", marker = "sys_platform == 'win32'" },
200
+ { name = "requests" },
201
+ { name = "urllib3" },
202
+ ]
203
+ sdist = { url = "https://files.pythonhosted.org/packages/88/7f/731ff914b0255d3d065f45fd4e626d4b8c95dbcbaada049f337a6ac16410/docker-7.2.0.tar.gz", hash = "sha256:cebb93773d334f778e023a7ee352a8d6e13ab1bd3b863a4d4a59dec897df43ac", size = 118731, upload-time = "2026-07-09T14:53:46.39Z" }
204
+ wheels = [
205
+ { url = "https://files.pythonhosted.org/packages/75/23/529140fe1aab80fc6992f93a706deec709140a6397439139a054e1515c45/docker-7.2.0-py3-none-any.whl", hash = "sha256:a3f45fdeb9165e2d25d9a1d02ddf3bc70fb572cf5ebbf9b58558c22caf29b71f", size = 148775, upload-time = "2026-07-09T14:53:45.224Z" },
206
+ ]
207
+
194
208
  [[package]]
195
209
  name = "idna"
196
210
  version = "3.18"
@@ -267,11 +281,12 @@ wheels = [
267
281
 
268
282
  [[package]]
269
283
  name = "pgdevkit"
270
- version = "0.2.0"
284
+ version = "0.2.2"
271
285
  source = { editable = "." }
272
286
  dependencies = [
287
+ { name = "docker" },
273
288
  { name = "psycopg", extra = ["binary"] },
274
- { name = "sqlglot", extra = ["c"] },
289
+ { name = "sqlglot" },
275
290
  ]
276
291
 
277
292
  [package.optional-dependencies]
@@ -301,11 +316,12 @@ test = [
301
316
  [package.metadata]
302
317
  requires-dist = [
303
318
  { name = "azure-identity", marker = "extra == 'azure'", specifier = ">=1.19.0" },
319
+ { name = "docker", specifier = ">=7.1.0" },
304
320
  { name = "psycopg", extras = ["binary"], specifier = ">=3.2.0" },
305
321
  { name = "psycopg-pool", marker = "extra == 'db'", specifier = ">=3.3.0" },
306
322
  { name = "pydantic", marker = "extra == 'db'", specifier = ">=2.0" },
307
323
  { name = "rich", marker = "extra == 'cli'", specifier = ">=13.0.0" },
308
- { name = "sqlglot", extras = ["c"], specifier = ">=30.11.0" },
324
+ { name = "sqlglot", specifier = ">=30.11.0" },
309
325
  { name = "typer", marker = "extra == 'cli'", specifier = ">=0.26.7" },
310
326
  ]
311
327
  provides-extras = ["azure", "cli", "db"]
@@ -513,6 +529,19 @@ wheels = [
513
529
  { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
514
530
  ]
515
531
 
532
+ [[package]]
533
+ name = "pywin32"
534
+ version = "312"
535
+ source = { registry = "https://pypi.org/simple" }
536
+ wheels = [
537
+ { url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" },
538
+ { url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" },
539
+ { url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" },
540
+ { url = "https://files.pythonhosted.org/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed", size = 6471159, upload-time = "2026-06-04T07:49:50.035Z" },
541
+ { url = "https://files.pythonhosted.org/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5", size = 7025293, upload-time = "2026-06-04T07:49:54.857Z" },
542
+ { url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337, upload-time = "2026-06-04T07:49:57.531Z" },
543
+ ]
544
+
516
545
  [[package]]
517
546
  name = "requests"
518
547
  version = "2.34.2"
@@ -559,26 +588,6 @@ wheels = [
559
588
  { 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" },
560
589
  ]
561
590
 
562
- [package.optional-dependencies]
563
- c = [
564
- { name = "sqlglotc" },
565
- ]
566
-
567
- [[package]]
568
- name = "sqlglotc"
569
- version = "30.11.0"
570
- source = { registry = "https://pypi.org/simple" }
571
- dependencies = [
572
- { name = "sqlglot" },
573
- ]
574
- sdist = { url = "https://files.pythonhosted.org/packages/69/af/2e872cabb05e081cc9a642dab24dbd8f49648e239e86b0b4fdb34dc0c588/sqlglotc-30.11.0.tar.gz", hash = "sha256:6ce71f4d31459df966f3752848f397e254ff5d4e2772594e539e23cff0f2bbc8", size = 485462, upload-time = "2026-06-11T17:10:41.918Z" }
575
- wheels = [
576
- { url = "https://files.pythonhosted.org/packages/8f/f2/1d742df787a61d09fe93b9e454a8fa88ca2d73a8a5c8f9a3ad45c7993e39/sqlglotc-30.11.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:d49533b4a851f3603ecba9a724c32f227bca90cc0034b8a3ad66acdbde759a90", size = 31599156, upload-time = "2026-06-11T17:10:31.911Z" },
577
- { url = "https://files.pythonhosted.org/packages/fb/28/81215d24dc14196f6b9f106de6522d871b0bc49666dc25b680b353b6ea11/sqlglotc-30.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2cbeec4ec4591268585e5ad5f55fbee30ccd68227b5d2f77d483cdd63b65963", size = 24982016, upload-time = "2026-06-11T17:10:34.68Z" },
578
- { url = "https://files.pythonhosted.org/packages/ae/4e/528b0d8d8f18edfe2b210983bf81234586b4108220d59c1fcb132bd9b594/sqlglotc-30.11.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f4bcc7b76634171edccc13aabba9c1bea51f97caf2187daacbe433004058dc3", size = 26140612, upload-time = "2026-06-11T17:10:37.406Z" },
579
- { url = "https://files.pythonhosted.org/packages/18/e5/d52b6d4e8e06c1a9771eff5886da6a4381aa624d94b5a8cc846f22a2b270/sqlglotc-30.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:8c6ed84d557772880a082ef29d18b0c2cfee2d177e6528b30df2b7eaf6adb585", size = 10984419, upload-time = "2026-06-11T17:10:39.797Z" },
580
- ]
581
-
582
591
  [[package]]
583
592
  name = "ty"
584
593
  version = "0.0.59"
@@ -1,89 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import os
4
- import subprocess
5
- import time
6
-
7
- import psycopg
8
-
9
- from . import constants
10
-
11
-
12
- def _podman(*args: str, check: bool = True) -> subprocess.CompletedProcess:
13
- return subprocess.run(["podman", *args], capture_output=True, text=True, check=check)
14
-
15
-
16
- def _available(timeout: float = 3.0) -> bool:
17
- """Quick check (short timeout) for whether Postgres is already reachable
18
- at HOST:PORT, so a database started outside pgdevkit's control (or the
19
- container from a previous run) doesn't trigger another podman/docker
20
- lifecycle call."""
21
- # libpq's connect_timeout is whole seconds; anything below 1 means "wait
22
- # indefinitely" instead of a short timeout, so it's clamped up to 1.
23
- connect_timeout = max(1, round(timeout))
24
- dsn = constants.conninfo("postgres", connect_timeout=connect_timeout)
25
- try:
26
- with psycopg.connect(dsn):
27
- return True
28
- except Exception: # noqa: BLE001
29
- return False
30
-
31
-
32
- def _container_status() -> str | None:
33
- """Return 'running', 'exited', etc., or None if the container doesn't exist."""
34
- result = _podman(
35
- "inspect", constants.CONTAINER_NAME, "--format", "{{.State.Status}}", check=False
36
- )
37
- if result.returncode != 0:
38
- return None
39
- return result.stdout.strip()
40
-
41
-
42
- def _create_container() -> None:
43
- result = _podman(
44
- "run", "-d",
45
- "--name", constants.CONTAINER_NAME,
46
- "-p", f"{constants.PORT}:5432",
47
- "-e", f"POSTGRES_USER={constants.USER}",
48
- "-e", f"POSTGRES_PASSWORD={constants.PASSWORD}",
49
- constants.IMAGE,
50
- "postgres", *constants.PG_SPEED_FLAGS,
51
- check=False,
52
- )
53
- if result.returncode != 0 and "already in use" in result.stderr:
54
- _podman("start", constants.CONTAINER_NAME)
55
- return
56
- if result.returncode != 0:
57
- raise RuntimeError(f"podman run failed: {result.stderr}")
58
-
59
-
60
- def _wait_ready(timeout: float = 30.0) -> None:
61
- deadline = time.monotonic() + timeout
62
- dsn = constants.conninfo("postgres", connect_timeout=2)
63
- last_error: Exception | None = None
64
- while time.monotonic() < deadline:
65
- try:
66
- with psycopg.connect(dsn):
67
- return
68
- except Exception as e: # noqa: BLE001
69
- last_error = e
70
- time.sleep(0.5)
71
- raise RuntimeError(f"Postgres did not become ready within {timeout}s: {last_error}")
72
-
73
-
74
- def ensure_container() -> None:
75
- """Idempotently ensure the shared pgdevkit-postgres container is running
76
- and accepting connections. Never touches podman/docker if Postgres is
77
- already reachable, or if PGDEVKIT_SKIP_CONTAINER says to assume it is."""
78
- if os.environ.get("PGDEVKIT_SKIP_CONTAINER"):
79
- return
80
- if _available():
81
- return
82
- status = _container_status()
83
- if status == "running":
84
- return
85
- if status is not None:
86
- _podman("start", constants.CONTAINER_NAME)
87
- else:
88
- _create_container()
89
- _wait_ready()
@@ -1,3 +0,0 @@
1
- -- Deliberately destructive: must be skipped by apply_schema's additive-only
2
- -- filter, not applied. See test_apply_schema_skips_unsafe_migrations.
3
- ALTER TABLE app.gadget DROP COLUMN note;
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