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.
- {pgdevkit-0.2.0 → pgdevkit-0.2.2}/.github/workflows/python-test.yml +0 -2
- {pgdevkit-0.2.0 → pgdevkit-0.2.2}/PKG-INFO +13 -7
- {pgdevkit-0.2.0 → pgdevkit-0.2.2}/README.md +10 -5
- pgdevkit-0.2.2/pgdevkit/testdb/container.py +114 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.2}/pgdevkit/testdb/schema.py +49 -55
- {pgdevkit-0.2.0 → pgdevkit-0.2.2}/pyproject.toml +3 -2
- {pgdevkit-0.2.0 → pgdevkit-0.2.2}/skills/pgdevkit/SKILL.md +4 -2
- {pgdevkit-0.2.0 → pgdevkit-0.2.2}/tests/testdb/conftest.py +12 -2
- {pgdevkit-0.2.0 → pgdevkit-0.2.2}/tests/testdb/test_container.py +6 -6
- {pgdevkit-0.2.0 → pgdevkit-0.2.2}/tests/testdb/test_schema.py +7 -42
- {pgdevkit-0.2.0 → pgdevkit-0.2.2}/uv.lock +32 -23
- pgdevkit-0.2.0/pgdevkit/testdb/container.py +0 -89
- pgdevkit-0.2.0/tests/testdb/fixtures/database/app/migrations/002_unsafe_drop_gadget_note.sql +0 -3
- {pgdevkit-0.2.0 → pgdevkit-0.2.2}/.github/workflows/python-publish.yml +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.2}/.gitignore +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.2}/.python-version +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.2}/docs/database-layout.md +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.2}/pgdevkit/__init__.py +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.2}/pgdevkit/cli.py +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.2}/pgdevkit/connection.py +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.2}/pgdevkit/db/__init__.py +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.2}/pgdevkit/db/complex_types.py +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.2}/pgdevkit/db/connection.py +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.2}/pgdevkit/db/crud.py +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.2}/pgdevkit/db/loader.py +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.2}/pgdevkit/db/model.py +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.2}/pgdevkit/diff.py +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.2}/pgdevkit/fetch_missing.py +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.2}/pgdevkit/introspect.py +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.2}/pgdevkit/lakebase.py +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.2}/pgdevkit/models.py +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.2}/pgdevkit/parser.py +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.2}/pgdevkit/testdb/__init__.py +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.2}/pgdevkit/testdb/api.py +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.2}/pgdevkit/testdb/config.py +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.2}/pgdevkit/testdb/constants.py +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.2}/pgdevkit/testdb/naming.py +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.2}/pgdevkit/testdb/query.py +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.2}/skills/pgdevkit/references/dynamic-sql.md +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.2}/skills/pgdevkit/references/temporal-tables.md +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.2}/tests/__init__.py +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.2}/tests/conftest.py +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.2}/tests/db/__init__.py +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.2}/tests/db/test_complex_types.py +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.2}/tests/db/test_connection.py +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.2}/tests/db/test_crud.py +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.2}/tests/db/test_loader.py +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.2}/tests/fixtures/01_schema.sql +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.2}/tests/fixtures/02_types.sql +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.2}/tests/fixtures/03_tables.sql +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.2}/tests/fixtures/04_views.sql +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.2}/tests/fixtures/05_functions.sql +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.2}/tests/fixtures/06_indexes.sql +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.2}/tests/test_cli_compare.py +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.2}/tests/test_compare.py +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.2}/tests/test_connection.py +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.2}/tests/test_fetch_missing.py +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.2}/tests/test_fetch_missing_cli.py +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.2}/tests/test_lakebase.py +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.2}/tests/testdb/__init__.py +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.2}/tests/testdb/fixtures/database/app/migrations/001_add_gadget_note.sql +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.2}/tests/testdb/fixtures/database/app/tables/gadget.sql +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.2}/tests/testdb/fixtures/database/app/tables/gadget.test_data.json +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.2}/tests/testdb/fixtures/database/app/tables/widget.sql +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.2}/tests/testdb/fixtures/database/app/tables/widget.test_data.json +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.2}/tests/testdb/fixtures/database/app/tables/widget_part.sql +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.2}/tests/testdb/fixtures/database/app/tables/widget_part_detail.sql +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.2}/tests/testdb/fixtures/database/app/types/dimensions.sql +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.2}/tests/testdb/fixtures/database/app/types/mood.sql +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.2}/tests/testdb/fixtures/database/app/views/a_wrapper_view.sql +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.2}/tests/testdb/fixtures/database/app/views/b_base_view.sql +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.2}/tests/testdb/fixtures/database/schema/app.sql +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.2}/tests/testdb/test_api.py +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.2}/tests/testdb/test_cli.py +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.2}/tests/testdb/test_config.py +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.2}/tests/testdb/test_constants.py +0 -0
- {pgdevkit-0.2.0 → pgdevkit-0.2.2}/tests/testdb/test_naming.py +0 -0
- {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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
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
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
35
|
+
raise AssertionError("must not touch the Docker API when already available")
|
|
36
36
|
|
|
37
|
-
monkeypatch.setattr(container, "
|
|
38
|
-
ensure_container() # must return without calling
|
|
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, "
|
|
49
|
-
ensure_container() # must return without checking availability or calling
|
|
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() #
|
|
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
|
|
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
|
|
140
|
-
#
|
|
141
|
-
#
|
|
142
|
-
|
|
143
|
-
|
|
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
|
|
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.
|
|
284
|
+
version = "0.2.2"
|
|
271
285
|
source = { editable = "." }
|
|
272
286
|
dependencies = [
|
|
287
|
+
{ name = "docker" },
|
|
273
288
|
{ name = "psycopg", extra = ["binary"] },
|
|
274
|
-
{ name = "sqlglot"
|
|
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",
|
|
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()
|
|
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
|
|
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
|
|
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
|
{pgdevkit-0.2.0 → pgdevkit-0.2.2}/tests/testdb/fixtures/database/app/tables/gadget.test_data.json
RENAMED
|
File without changes
|
|
File without changes
|
{pgdevkit-0.2.0 → pgdevkit-0.2.2}/tests/testdb/fixtures/database/app/tables/widget.test_data.json
RENAMED
|
File without changes
|
|
File without changes
|
{pgdevkit-0.2.0 → pgdevkit-0.2.2}/tests/testdb/fixtures/database/app/tables/widget_part_detail.sql
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pgdevkit-0.2.0 → pgdevkit-0.2.2}/tests/testdb/fixtures/database/app/views/a_wrapper_view.sql
RENAMED
|
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
|