pgdevkit 0.2.1__py3-none-any.whl → 0.2.2__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.
@@ -73,7 +73,7 @@ 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
77
 
78
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).
79
79
 
@@ -88,7 +88,7 @@ def _testdb_env():
88
88
 
89
89
  ### CI
90
90
 
91
- 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.
92
92
 
93
93
  ---
94
94
 
@@ -1,23 +1,53 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import os
4
- import subprocess
5
4
  import time
6
5
 
6
+ import docker
7
+ import docker.errors
7
8
  import psycopg
8
9
 
9
10
  from . import constants
10
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
+ ]
11
20
 
12
- def _podman(*args: str, check: bool = True) -> subprocess.CompletedProcess:
13
- return subprocess.run(["podman", *args], capture_output=True, text=True, check=check)
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
+ )
14
45
 
15
46
 
16
47
  def _available(timeout: float = 3.0) -> bool:
17
48
  """Quick check (short timeout) for whether Postgres is already reachable
18
49
  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."""
50
+ container from a previous run) doesn't trigger another Docker API call."""
21
51
  # libpq's connect_timeout is whole seconds; anything below 1 means "wait
22
52
  # indefinitely" instead of a short timeout, so it's clamped up to 1.
23
53
  connect_timeout = max(1, round(timeout))
@@ -29,32 +59,24 @@ def _available(timeout: float = 3.0) -> bool:
29
59
  return False
30
60
 
31
61
 
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}")
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
58
80
 
59
81
 
60
82
  def _wait_ready(timeout: float = 30.0) -> None:
@@ -73,17 +95,20 @@ def _wait_ready(timeout: float = 30.0) -> None:
73
95
 
74
96
  def ensure_container() -> None:
75
97
  """Idempotently ensure the shared pgdevkit-postgres container is running
76
- and accepting connections. Never touches podman/docker if Postgres is
98
+ and accepting connections. Never touches the Docker API if Postgres is
77
99
  already reachable, or if PGDEVKIT_SKIP_CONTAINER says to assume it is."""
78
100
  if os.environ.get("PGDEVKIT_SKIP_CONTAINER"):
79
101
  return
80
102
  if _available():
81
103
  return
82
- status = _container_status()
83
- if status == "running":
84
- return
85
- if status is not None:
86
- _podman("start", constants.CONTAINER_NAME)
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()
87
112
  else:
88
- _create_container()
113
+ _create_container(client)
89
114
  _wait_ready()
pgdevkit/testdb/schema.py CHANGED
@@ -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:
@@ -206,5 +229,21 @@ async def apply_schema(
206
229
  logger.warning("Error executing %s (will retry): %s", file, e)
207
230
  failures.append((file, sql))
208
231
 
209
- for file, sql in failures:
210
- await _apply(file, sql)
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
@@ -1,10 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pgdevkit
3
- Version: 0.2.1
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`
@@ -17,15 +17,15 @@ pgdevkit/testdb/__init__.py,sha256=-APwuluorU3iRCUsiJPK7Bc1AS5_IXxCdKB4zuaQ868,1
17
17
  pgdevkit/testdb/api.py,sha256=eNbkxDfWrIzsGs7YZGV9gJaX6hrrnIMqulkbRam0VXc,4796
18
18
  pgdevkit/testdb/config.py,sha256=v0KFSkWhhZ8vy9167nqHqPsuCjilVrIQ6iqo8m_9-Hk,1545
19
19
  pgdevkit/testdb/constants.py,sha256=xi8uIoEgN9LrRkr41j3mmWB9PqqU0r8aCUb2JJXBAs0,1165
20
- pgdevkit/testdb/container.py,sha256=24zl2_0mX24RTa2NCxps8RlJQAzdAif15TWgoVjNtMc,2974
20
+ pgdevkit/testdb/container.py,sha256=R_IhazRw_c0HteDtESO6RY6XWHp995FP5RRA4LK7Fh0,4068
21
21
  pgdevkit/testdb/naming.py,sha256=Kyk5qEfxUkbggUG5bD7U7CJ_kAET332VPQNB-O5kvTY,1318
22
22
  pgdevkit/testdb/query.py,sha256=e4UbgdCKKIot3a8Qixg-OX-Af0rlQ4a8ODhJBD8mtVM,2318
23
- pgdevkit/testdb/schema.py,sha256=Pz5wWiDz9sNyCe-VK_au7A-saTXzTGKVIorGFrbyV5I,7730
23
+ pgdevkit/testdb/schema.py,sha256=-whDO8JTggFPNZtymzQ7dpsqMdQ-wUYAlRdWuyT4CAQ,9590
24
24
  pgdevkit/docs/database-layout.md,sha256=dNvDH956xSXuAnLqkOopyOfaIm_A-RtUd57VTaZ1GQU,6035
25
- pgdevkit/skills/pgdevkit/SKILL.md,sha256=CJS3LC5x8tjb6mJc8q_XQCknOwUd5LGSof3hteLsH2Y,11975
25
+ pgdevkit/skills/pgdevkit/SKILL.md,sha256=6uxe6BQwOLexGiBpf00VTFTHOiWJo5k2Qqzu172rb4I,12156
26
26
  pgdevkit/skills/pgdevkit/references/dynamic-sql.md,sha256=aTR1sx81moiTwaMaiOecaqk-9rT8y5Zxz7W1BoC3vK0,2774
27
27
  pgdevkit/skills/pgdevkit/references/temporal-tables.md,sha256=LwZnItvQWI7bB0oRHxIfUc0ynnq7N0V5Mnv-QHvFN3Q,1364
28
- pgdevkit-0.2.1.dist-info/METADATA,sha256=VFDuRtm67C2UT4kl2QffVrwL0RNuC2U4ySUFU0st8I0,5651
29
- pgdevkit-0.2.1.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
30
- pgdevkit-0.2.1.dist-info/entry_points.txt,sha256=FK2P55lf0WaGlOZJIINOGgCt8WB7ep0OnPsgnrp80bg,42
31
- pgdevkit-0.2.1.dist-info/RECORD,,
28
+ pgdevkit-0.2.2.dist-info/METADATA,sha256=56tCcCKLAnudYqguZtBn60RMnmGmKt-sk0yeFEG-y8c,5915
29
+ pgdevkit-0.2.2.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
30
+ pgdevkit-0.2.2.dist-info/entry_points.txt,sha256=FK2P55lf0WaGlOZJIINOGgCt8WB7ep0OnPsgnrp80bg,42
31
+ pgdevkit-0.2.2.dist-info/RECORD,,