From d2bddde7a7b6de2d3f8af9b7b3e67ce832ae4f0f Mon Sep 17 00:00:00 2001 From: Adrian Ehrsam Date: Thu, 16 Jul 2026 09:57:21 +0200 Subject: [PATCH 1/2] Allow overriding testdb Postgres connection via env vars, skip podman when DB already reachable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PGDEVKIT_TESTDB_HOST/PORT/USER/PASSWORD now override the container defaults, and ensure_container() first probes the target with a short (3s) timeout before ever shelling out to podman — plus the existing PGDEVKIT_SKIP_CONTAINER escape hatch now lives in one place. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01KZ9GdQRrr1e1EHcZHb7496 --- README.md | 8 ++++++ pgdevkit/testdb/api.py | 4 +-- pgdevkit/testdb/constants.py | 10 +++++--- pgdevkit/testdb/container.py | 27 +++++++++++++++++++- tests/testdb/test_constants.py | 35 ++++++++++++++++++++++++++ tests/testdb/test_container.py | 46 ++++++++++++++++++++++++++++++++-- 6 files changed, 120 insertions(+), 10 deletions(-) create mode 100644 tests/testdb/test_constants.py diff --git a/README.md b/README.md index 8aaf6e2..6b7d91a 100644 --- a/README.md +++ b/README.md @@ -40,3 +40,11 @@ def ensure_test_postgres(): ``` CLI: `pgdb testdb up|reset|run-sql|status|shell|clean`. + +Container connection defaults (`localhost:54322`, `postgres`/`testpwd`) can +be overridden with `PGDEVKIT_TESTDB_HOST`, `PGDEVKIT_TESTDB_PORT`, +`PGDEVKIT_TESTDB_USER`, `PGDEVKIT_TESTDB_PASSWORD`. Before touching +podman/docker, pgdevkit first checks (with a short timeout) whether Postgres +is already reachable at that address and skips container management if so. +Set `PGDEVKIT_SKIP_CONTAINER=1` to always assume it's already there and skip +that check too. diff --git a/pgdevkit/testdb/api.py b/pgdevkit/testdb/api.py index b1cbbbb..2337c9d 100644 --- a/pgdevkit/testdb/api.py +++ b/pgdevkit/testdb/api.py @@ -1,7 +1,6 @@ from __future__ import annotations import asyncio -import os from pathlib import Path import psycopg @@ -74,8 +73,7 @@ def ensure_testdb(project_root: Path | None = None, force_reset: bool = False) - """Ensure the shared container is running, this workspace's database exists, and its schema is applied. Returns the {PREFIX}POSTGRES_* env vars for this workspace.""" - if not os.environ.get("PGDEVKIT_SKIP_CONTAINER"): - ensure_container() + ensure_container() config, db_name = _resolve(project_root) async def _run() -> None: diff --git a/pgdevkit/testdb/constants.py b/pgdevkit/testdb/constants.py index 09f37a6..a9c6a64 100644 --- a/pgdevkit/testdb/constants.py +++ b/pgdevkit/testdb/constants.py @@ -1,9 +1,11 @@ from __future__ import annotations +import os + CONTAINER_NAME = "pgdevkit-postgres" IMAGE = "pgvector/pgvector:pg18-trixie" -HOST = "localhost" -PORT = 54322 -USER = "postgres" -PASSWORD = "testpwd" +HOST = os.environ.get("PGDEVKIT_TESTDB_HOST", "localhost") +PORT = int(os.environ.get("PGDEVKIT_TESTDB_PORT", "54322")) +USER = os.environ.get("PGDEVKIT_TESTDB_USER", "postgres") +PASSWORD = os.environ.get("PGDEVKIT_TESTDB_PASSWORD", "testpwd") PG_SPEED_FLAGS = ["-c", "fsync=off", "-c", "synchronous_commit=off", "-c", "full_page_writes=off"] diff --git a/pgdevkit/testdb/container.py b/pgdevkit/testdb/container.py index f38a60e..48fa9e7 100644 --- a/pgdevkit/testdb/container.py +++ b/pgdevkit/testdb/container.py @@ -1,5 +1,6 @@ from __future__ import annotations +import os import subprocess import time @@ -12,6 +13,25 @@ def _podman(*args: str, check: bool = True) -> subprocess.CompletedProcess: return subprocess.run(["podman", *args], capture_output=True, text=True, check=check) +def _available(timeout: float = 3.0) -> bool: + """Quick check (short timeout) for whether Postgres is already reachable + at HOST:PORT, so a database started outside pgdevkit's control (or the + container from a previous run) doesn't trigger another podman/docker + lifecycle call.""" + # libpq's connect_timeout is whole seconds; anything below 1 means "wait + # indefinitely" instead of a short timeout, so it's clamped up to 1. + connect_timeout = max(1, round(timeout)) + dsn = ( + f"postgresql://{constants.USER}:{constants.PASSWORD}" + f"@{constants.HOST}:{constants.PORT}/postgres?connect_timeout={connect_timeout}" + ) + try: + with psycopg.connect(dsn): + return True + except Exception: # noqa: BLE001 + return False + + def _container_status() -> str | None: """Return 'running', 'exited', etc., or None if the container doesn't exist.""" result = _podman( @@ -59,7 +79,12 @@ def _wait_ready(timeout: float = 30.0) -> None: def ensure_container() -> None: """Idempotently ensure the shared pgdevkit-postgres container is running - and accepting connections.""" + and accepting connections. Never touches podman/docker if Postgres is + already reachable, or if PGDEVKIT_SKIP_CONTAINER says to assume it is.""" + if os.environ.get("PGDEVKIT_SKIP_CONTAINER"): + return + if _available(): + return status = _container_status() if status == "running": return diff --git a/tests/testdb/test_constants.py b/tests/testdb/test_constants.py new file mode 100644 index 0000000..86889ba --- /dev/null +++ b/tests/testdb/test_constants.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import importlib + +from pgdevkit.testdb import constants + + +def test_defaults_when_env_vars_unset(monkeypatch): + monkeypatch.delenv("PGDEVKIT_TESTDB_HOST", raising=False) + monkeypatch.delenv("PGDEVKIT_TESTDB_PORT", raising=False) + monkeypatch.delenv("PGDEVKIT_TESTDB_USER", raising=False) + monkeypatch.delenv("PGDEVKIT_TESTDB_PASSWORD", raising=False) + try: + importlib.reload(constants) + assert constants.HOST == "localhost" + assert constants.PORT == 54322 + assert constants.USER == "postgres" + assert constants.PASSWORD == "testpwd" + finally: + importlib.reload(constants) + + +def test_env_vars_override_connection_defaults(monkeypatch): + monkeypatch.setenv("PGDEVKIT_TESTDB_HOST", "otherhost") + monkeypatch.setenv("PGDEVKIT_TESTDB_PORT", "9999") + monkeypatch.setenv("PGDEVKIT_TESTDB_USER", "otheruser") + monkeypatch.setenv("PGDEVKIT_TESTDB_PASSWORD", "otherpass") + try: + importlib.reload(constants) + assert constants.HOST == "otherhost" + assert constants.PORT == 9999 + assert constants.USER == "otheruser" + assert constants.PASSWORD == "otherpass" + finally: + importlib.reload(constants) diff --git a/tests/testdb/test_container.py b/tests/testdb/test_container.py index cc09196..69279fb 100644 --- a/tests/testdb/test_container.py +++ b/tests/testdb/test_container.py @@ -2,11 +2,53 @@ import psycopg -from pgdevkit.testdb import constants -from pgdevkit.testdb.container import _create_container, ensure_container +from pgdevkit.testdb import constants, container +from pgdevkit.testdb.container import _available, _create_container, ensure_container from tests.testdb.conftest import requires_podman +class _FakeConnection: + def __enter__(self): + return self + + def __exit__(self, *exc_info): + return False + + +def test_available_returns_true_when_connection_succeeds(monkeypatch): + monkeypatch.setattr(container.psycopg, "connect", lambda *a, **k: _FakeConnection()) + assert _available(timeout=0.1) is True + + +def test_available_returns_false_when_connection_fails(monkeypatch): + def _raise(*a, **k): + raise psycopg.OperationalError("connection refused") + + monkeypatch.setattr(container.psycopg, "connect", _raise) + assert _available(timeout=0.1) is False + + +def test_ensure_container_skips_podman_when_already_available(monkeypatch): + monkeypatch.setattr(container, "_available", lambda timeout=1.0: True) + + def _fail(*a, **k): + raise AssertionError("must not shell out to podman when already available") + + monkeypatch.setattr(container, "_podman", _fail) + ensure_container() # must return without calling podman + + +def test_ensure_container_skips_everything_when_skip_env_set(monkeypatch): + monkeypatch.setenv("PGDEVKIT_SKIP_CONTAINER", "1") + + def _fail(*a, **k): + raise AssertionError("must not run when PGDEVKIT_SKIP_CONTAINER is set") + + monkeypatch.setattr(container, "_available", _fail) + monkeypatch.setattr(container, "_podman", _fail) + ensure_container() # must return without checking availability or calling podman + + def _admin_dsn() -> str: return ( f"postgresql://{constants.USER}:{constants.PASSWORD}" From 34e54a74d9be43313e4d6491e4f771a2910bd73f Mon Sep 17 00:00:00 2001 From: Adrian Ehrsam Date: Thu, 16 Jul 2026 10:05:43 +0200 Subject: [PATCH 2/2] Fix test pollution: reload constants after undoing monkeypatched env vars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The finally block reloaded pgdevkit.testdb.constants while the overridden PGDEVKIT_TESTDB_* env vars were still set (monkeypatch only restores them after the test function returns), so the module stayed loaded with otherhost/otheruser/otherpass for the rest of the test session — breaking the real podman/postgres integration tests that ran afterward in CI. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01KZ9GdQRrr1e1EHcZHb7496 --- tests/testdb/test_constants.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/testdb/test_constants.py b/tests/testdb/test_constants.py index 86889ba..3a927f2 100644 --- a/tests/testdb/test_constants.py +++ b/tests/testdb/test_constants.py @@ -32,4 +32,8 @@ def test_env_vars_override_connection_defaults(monkeypatch): assert constants.USER == "otheruser" assert constants.PASSWORD == "otherpass" finally: + # monkeypatch only unsets the env vars after this function returns, + # so undo them now — otherwise this reload just reloads the + # override values right back in, leaking them into every later test. + monkeypatch.undo() importlib.reload(constants)