Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
4 changes: 1 addition & 3 deletions pgdevkit/testdb/api.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
from __future__ import annotations

import asyncio
import os
from pathlib import Path

import psycopg
Expand Down Expand Up @@ -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:
Expand Down
10 changes: 6 additions & 4 deletions pgdevkit/testdb/constants.py
Original file line number Diff line number Diff line change
@@ -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"]
27 changes: 26 additions & 1 deletion pgdevkit/testdb/container.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import os
import subprocess
import time

Expand All @@ -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(
Expand Down Expand Up @@ -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
Expand Down
39 changes: 39 additions & 0 deletions tests/testdb/test_constants.py
Original file line number Diff line number Diff line change
@@ -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)
46 changes: 44 additions & 2 deletions tests/testdb/test_container.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down
Loading