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..3a927f2 --- /dev/null +++ b/tests/testdb/test_constants.py @@ -0,0 +1,39 @@ +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: + # 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) 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}"