From 0b872e5ea5310ce25793218d80f029a54197ecc2 Mon Sep 17 00:00:00 2001 From: Cody Fincher Date: Sun, 24 May 2026 20:33:56 +0000 Subject: [PATCH 1/3] feat: move Postgres family to clientless validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the runtime psycopg dependency from the postgres extra. The PostgresService fixtures now validate readiness and create the per-worker database through `psql` invoked via container.exec_run, and the bundled *_connection fixtures (postgres, pgvector, paradedb, alloydb_omni) are removed. Users install their own psycopg client alongside pytest-databases[postgres]. The _make_connection_string helper stays — cockroachdb tests still import it as a pure string formatter. --- docs/getting-started/basic-usage.rst | 35 +- docs/supported-databases/postgres.rst | 71 ++-- pyproject.toml | 2 +- src/pytest_databases/docker/postgres.py | 504 ++++-------------------- tests/test_postgres.py | 211 +++++----- uv.lock | 5 - 6 files changed, 232 insertions(+), 596 deletions(-) diff --git a/docs/getting-started/basic-usage.rst b/docs/getting-started/basic-usage.rst index 976e7c95..d3b20c77 100644 --- a/docs/getting-started/basic-usage.rst +++ b/docs/getting-started/basic-usage.rst @@ -1,34 +1,31 @@ Basic Usage =========== -Once a plugin is enabled (e.g., PostgreSQL), you can use its fixtures directly in your tests. There are typically two main types of fixtures: - -1. **Service Fixture** (e.g., `postgres_service`): Provides details about the running database service (host, port, credentials, etc.). Useful for connecting with your own client. -2. **Connection Fixture** (e.g., `postgres_connection`): Provides a ready-to-use connection object (where applicable) to the database service. +Once a plugin is enabled (e.g., PostgreSQL), you can use its fixtures directly in your tests. The fixture you'll use most often is the **Service Fixture** (e.g., ``postgres_service``), which provides details about the running database service (host, port, credentials, etc.) so you can connect with your own client. .. code-block:: python - # Assuming you have installed pytest-databases[postgres] and enabled the plugin - # Also assuming a client like psycopg is installed: pip install psycopg + # Assuming you have installed pytest-databases[postgres] and enabled the plugin. + # Install your preferred PostgreSQL client alongside it: pip install psycopg import psycopg from pytest_databases.docker.postgres import PostgresService - # Example using the Service Fixture def test_connection_with_service_details(postgres_service: PostgresService) -> None: conn_str = ( f"postgresql://{postgres_service.user}:{postgres_service.password}@" f"{postgres_service.host}:{postgres_service.port}/{postgres_service.database}" ) - with psycopg.connect(conn_str, autocommit=True) as conn: - with conn.cursor() as cursor: - cursor.execute("SELECT 1") - assert cursor.fetchone() == (1,) + with psycopg.connect(conn_str, autocommit=True) as conn, conn.cursor() as cursor: + cursor.execute("SELECT 1") + assert cursor.fetchone() == (1,) - # Example using the Connection Fixture - def test_with_direct_connection(postgres_connection) -> None: - # postgres_connection is often a configured client or connection object - with postgres_connection.cursor() as cursor: - cursor.execute("CREATE TABLE IF NOT EXISTS users (id INT PRIMARY KEY, name TEXT);") - cursor.execute("INSERT INTO users (id, name) VALUES (1, 'Alice');") - cursor.execute("SELECT name FROM users WHERE id = 1;") - assert cursor.fetchone() == ('Alice',) + def test_write_and_read(postgres_service: PostgresService) -> None: + conn_str = ( + f"postgresql://{postgres_service.user}:{postgres_service.password}@" + f"{postgres_service.host}:{postgres_service.port}/{postgres_service.database}" + ) + with psycopg.connect(conn_str, autocommit=True) as conn, conn.cursor() as cursor: + cursor.execute("CREATE TABLE IF NOT EXISTS users (id INT PRIMARY KEY, name TEXT);") + cursor.execute("INSERT INTO users (id, name) VALUES (1, 'Alice');") + cursor.execute("SELECT name FROM users WHERE id = 1;") + assert cursor.fetchone() == ("Alice",) diff --git a/docs/supported-databases/postgres.rst b/docs/supported-databases/postgres.rst index 3115d1d1..19044c23 100644 --- a/docs/supported-databases/postgres.rst +++ b/docs/supported-databases/postgres.rst @@ -1,21 +1,22 @@ PostgreSQL ========== -Integration with `PostgreSQL `_ using the `PostgreSQL Docker Image `_, Google's `AlloyDB Omni `_, `pgvector Docker Image `_, or `ParadeDB Docker Image `_ +Integration with `PostgreSQL `_ using the `PostgreSQL Docker Image `_, Google's `AlloyDB Omni `_, `pgvector Docker Image `_, or `ParadeDB Docker Image `_. Installation ------------ .. code-block:: bash - pip install pytest-databases[postgres] + pip install pytest-databases[postgres] psycopg + +The ``psycopg`` Python client is no longer pulled by ``pytest-databases[postgres]`` — install your preferred PostgreSQL client alongside ``pytest-databases``. Usage Example ------------- .. code-block:: python - import pytest import psycopg from pytest_databases.docker.postgres import PostgresService @@ -23,15 +24,11 @@ Usage Example def test(postgres_service: PostgresService) -> None: with psycopg.connect( - f"postgresql://{postgres_service.user}:{postgres_service.password}@{postgres_service.host}:{postgres_service.port}/{postgres_service.database}" + f"postgresql://{postgres_service.user}:{postgres_service.password}" + f"@{postgres_service.host}:{postgres_service.port}/{postgres_service.database}" ) as conn: - db_open = conn.execute("SELECT 1").fetchone() - assert db_open is not None and db_open[0] == 1 - - def test(postgres_connection: psycopg.Connection) -> None: - postgres_connection.execute("CREATE TABLE if not exists simple_table as SELECT 1") - result = postgres_connection.execute("select * from simple_table").fetchone() - assert result is not None and result[0] == 1 + result = conn.execute("SELECT 1").fetchone() + assert result is not None and result[0] == 1 Available Fixtures ------------------ @@ -39,54 +36,52 @@ Available Fixtures * ``postgres_host``: The PostgreSQL host address (defaults to "127.0.0.1", can be overridden with ``POSTGRES_HOST`` environment variable). * ``postgres_user``: The PostgreSQL user. * ``postgres_password``: The PostgreSQL password. -* ``postgres_database``: The PostgreSQL database name to use. * ``postgres_image``: The Docker image to use for PostgreSQL. * ``postgres_port``: Optional host-side port pin (default ``None``, override via ``POSTGRES_PORT`` env). -* ``postgres_service``: A fixture that provides a PostgreSQL service. -* ``postgres_connection``: A fixture that provides a PostgreSQL connection. +* ``postgres_service``: A fixture that provides a ``PostgresService`` (``host``, ``port``, ``container``, ``database``, ``user``, ``password``). The following version-specific fixtures are also available. Each has its own ``*_port`` fixture and matching env var so multiple versions can be pinned in the same session without colliding: -* ``postgres_11_service``, ``postgres_11_connection``, ``postgres_11_port`` (env: ``POSTGRES_11_PORT``) -* ``postgres_12_service``, ``postgres_12_connection``, ``postgres_12_port`` (env: ``POSTGRES_12_PORT``) -* ``postgres_13_service``, ``postgres_13_connection``, ``postgres_13_port`` (env: ``POSTGRES_13_PORT``) -* ``postgres_14_service``, ``postgres_14_connection``, ``postgres_14_port`` (env: ``POSTGRES_14_PORT``) -* ``postgres_15_service``, ``postgres_15_connection``, ``postgres_15_port`` (env: ``POSTGRES_15_PORT``) -* ``postgres_16_service``, ``postgres_16_connection``, ``postgres_16_port`` (env: ``POSTGRES_16_PORT``) -* ``postgres_17_service``, ``postgres_17_connection``, ``postgres_17_port`` (env: ``POSTGRES_17_PORT``) -* ``postgres_18_service``, ``postgres_18_connection``, ``postgres_18_port`` (env: ``POSTGRES_18_PORT``) +* ``postgres_11_service``, ``postgres_11_port`` (env: ``POSTGRES_11_PORT``) +* ``postgres_12_service``, ``postgres_12_port`` (env: ``POSTGRES_12_PORT``) +* ``postgres_13_service``, ``postgres_13_port`` (env: ``POSTGRES_13_PORT``) +* ``postgres_14_service``, ``postgres_14_port`` (env: ``POSTGRES_14_PORT``) +* ``postgres_15_service``, ``postgres_15_port`` (env: ``POSTGRES_15_PORT``) +* ``postgres_16_service``, ``postgres_16_port`` (env: ``POSTGRES_16_PORT``) +* ``postgres_17_service``, ``postgres_17_port`` (env: ``POSTGRES_17_PORT``) +* ``postgres_18_service``, ``postgres_18_port`` (env: ``POSTGRES_18_PORT``) pgvector ^^^^^^^^ -* ``pgvector_image``, ``pgvector_service``, ``pgvector_connection``, ``pgvector_port`` (env: ``PGVECTOR_PORT``) — default image ``pgvector/pgvector:pg18`` -* ``pgvector_13_service``, ``pgvector_13_connection``, ``pgvector_13_port`` (env: ``PGVECTOR_13_PORT``) -* ``pgvector_14_service``, ``pgvector_14_connection``, ``pgvector_14_port`` (env: ``PGVECTOR_14_PORT``) -* ``pgvector_15_service``, ``pgvector_15_connection``, ``pgvector_15_port`` (env: ``PGVECTOR_15_PORT``) -* ``pgvector_16_service``, ``pgvector_16_connection``, ``pgvector_16_port`` (env: ``PGVECTOR_16_PORT``) -* ``pgvector_17_service``, ``pgvector_17_connection``, ``pgvector_17_port`` (env: ``PGVECTOR_17_PORT``) -* ``pgvector_18_service``, ``pgvector_18_connection``, ``pgvector_18_port`` (env: ``PGVECTOR_18_PORT``) +* ``pgvector_image``, ``pgvector_service``, ``pgvector_port`` (env: ``PGVECTOR_PORT``) — default image ``pgvector/pgvector:pg18`` +* ``pgvector_13_service``, ``pgvector_13_port`` (env: ``PGVECTOR_13_PORT``) +* ``pgvector_14_service``, ``pgvector_14_port`` (env: ``PGVECTOR_14_PORT``) +* ``pgvector_15_service``, ``pgvector_15_port`` (env: ``PGVECTOR_15_PORT``) +* ``pgvector_16_service``, ``pgvector_16_port`` (env: ``PGVECTOR_16_PORT``) +* ``pgvector_17_service``, ``pgvector_17_port`` (env: ``PGVECTOR_17_PORT``) +* ``pgvector_18_service``, ``pgvector_18_port`` (env: ``PGVECTOR_18_PORT``) ParadeDB ^^^^^^^^ ParadeDB extends PostgreSQL with BM25 full-text search and analytics extensions. -* ``paradedb_image``, ``paradedb_service``, ``paradedb_connection``, ``paradedb_port`` (env: ``PARADEDB_PORT``) — default image ``paradedb/paradedb:latest-pg18`` -* ``paradedb_15_service``, ``paradedb_15_connection``, ``paradedb_15_port`` (env: ``PARADEDB_15_PORT``) -* ``paradedb_16_service``, ``paradedb_16_connection``, ``paradedb_16_port`` (env: ``PARADEDB_16_PORT``) -* ``paradedb_17_service``, ``paradedb_17_connection``, ``paradedb_17_port`` (env: ``PARADEDB_17_PORT``) -* ``paradedb_18_service``, ``paradedb_18_connection``, ``paradedb_18_port`` (env: ``PARADEDB_18_PORT``) +* ``paradedb_image``, ``paradedb_service``, ``paradedb_port`` (env: ``PARADEDB_PORT``) — default image ``paradedb/paradedb:latest-pg18`` +* ``paradedb_15_service``, ``paradedb_15_port`` (env: ``PARADEDB_15_PORT``) +* ``paradedb_16_service``, ``paradedb_16_port`` (env: ``PARADEDB_16_PORT``) +* ``paradedb_17_service``, ``paradedb_17_port`` (env: ``PARADEDB_17_PORT``) +* ``paradedb_18_service``, ``paradedb_18_port`` (env: ``PARADEDB_18_PORT``) AlloyDB Omni ^^^^^^^^^^^^ -* ``alloydb_omni_image``, ``alloydb_omni_service``, ``alloydb_omni_connection``, ``alloydb_omni_port`` (env: ``ALLOYDB_OMNI_PORT``) — default image ``google/alloydbomni:17`` -* ``alloydb_omni_15_service``, ``alloydb_omni_15_connection``, ``alloydb_omni_15_port`` (env: ``ALLOYDB_OMNI_15_PORT``) -* ``alloydb_omni_16_service``, ``alloydb_omni_16_connection``, ``alloydb_omni_16_port`` (env: ``ALLOYDB_OMNI_16_PORT``) -* ``alloydb_omni_17_service``, ``alloydb_omni_17_connection``, ``alloydb_omni_17_port`` (env: ``ALLOYDB_OMNI_17_PORT``) +* ``alloydb_omni_image``, ``alloydb_omni_service``, ``alloydb_omni_port`` (env: ``ALLOYDB_OMNI_PORT``) — default image ``google/alloydbomni:17`` +* ``alloydb_omni_15_service``, ``alloydb_omni_15_port`` (env: ``ALLOYDB_OMNI_15_PORT``) +* ``alloydb_omni_16_service``, ``alloydb_omni_16_port`` (env: ``ALLOYDB_OMNI_16_PORT``) +* ``alloydb_omni_17_service``, ``alloydb_omni_17_port`` (env: ``ALLOYDB_OMNI_17_PORT``) Configuration ------------- diff --git a/pyproject.toml b/pyproject.toml index 830b7d5c..6f620685 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -72,7 +72,7 @@ mongodb = [] mssql = [] mysql = [] oracle = [] -postgres = ["psycopg>=3"] +postgres = [] redis = ["redis"] spanner = [] valkey = [] diff --git a/src/pytest_databases/docker/postgres.py b/src/pytest_databases/docker/postgres.py index 465c0171..a401489c 100644 --- a/src/pytest_databases/docker/postgres.py +++ b/src/pytest_databases/docker/postgres.py @@ -2,17 +2,19 @@ import dataclasses import os +import time from contextlib import contextmanager from typing import TYPE_CHECKING -import psycopg import pytest from pytest_databases.helpers import get_xdist_worker_num from pytest_databases.types import ServiceContainer, XdistIsolationLevel if TYPE_CHECKING: - from collections.abc import Generator + from collections.abc import Generator, Iterator + + from docker.models.containers import Container from pytest_databases._service import DockerService @@ -21,6 +23,74 @@ def _make_connection_string(host: str, port: int, user: str, password: str, data return f"dbname={database} user={user} host={host} port={port} password={password}" +def _output_to_bytes(output: bytes | str | Iterator[bytes]) -> bytes: + if isinstance(output, bytes): + return output + if isinstance(output, str): + return output.encode() + return b"".join(output) + + +def _exec_psql( + container: Container, + *, + user: str, + password: str, + database: str, + sql: str, + tuples_only: bool = False, +) -> tuple[int, bytes]: + cmd = ["psql", "-v", "ON_ERROR_STOP=1", "-U", user, "-d", database] + if tuples_only: + cmd.extend(["-tAc", sql]) + else: + cmd.extend(["-c", sql]) + result = container.exec_run(cmd, environment={"PGPASSWORD": password}) + return result.exit_code if result.exit_code is not None else -1, _output_to_bytes(result.output) + + +def _create_worker_database( + container: Container, + *, + user: str, + password: str, + db_name: str, +) -> None: + last_output = b"" + for _ in range(10): + exit_code, output = _exec_psql( + container, + user=user, + password=password, + database="postgres", + sql=f"CREATE DATABASE {db_name};", + ) + if exit_code == 0 or b"already exists" in output: + break + last_output = output + time.sleep(0.5) + else: + message = ( + f"CREATE DATABASE {db_name!r} failed after 10 attempts: " + f"{last_output.decode(errors='replace').strip()}" + ) + raise RuntimeError(message) + exit_code, output = _exec_psql( + container, + user=user, + password=password, + database=db_name, + sql="SELECT 1", + tuples_only=True, + ) + if exit_code != 0 or output.strip() != b"1": + message = ( + f"Verification SELECT against {db_name!r} failed: " + f"{output.decode(errors='replace').strip()}" + ) + raise RuntimeError(message) + + @pytest.fixture(scope="session") def xdist_postgres_isolation_level() -> XdistIsolationLevel: return "database" @@ -114,20 +184,15 @@ def _provide_postgres_service( host_port: int | None = None, ) -> Generator[PostgresService, None, None]: def check(_service: ServiceContainer) -> bool: - try: - with psycopg.connect( - _make_connection_string( - host=_service.host, - port=_service.port, - user=user, - password=password, - database="postgres", - ) - ) as conn: - db_open = conn.execute("SELECT 1").fetchone() - return bool(db_open is not None and db_open[0] == 1) - except Exception: # noqa: BLE001 - return False + exit_code, output = _exec_psql( + _service.container, + user=user, + password=password, + database="postgres", + sql="SELECT 1", + tuples_only=True, + ) + return exit_code == 0 and output.strip() == b"1" worker_num = get_xdist_worker_num() db_name = "pytest_databases" @@ -147,10 +212,15 @@ def check(_service: ServiceContainer) -> bool: env={ "POSTGRES_PASSWORD": password, }, - exec_after_start=f"psql -U postgres -d postgres -c 'CREATE DATABASE {db_name};'", transient=xdist_postgres_isolate == "server", host_port=host_port, ) as service: + _create_worker_database( + service.container, + user=user, + password=password, + db_name=db_name, + ) yield PostgresService( host=service.host, port=service.port, @@ -337,134 +407,6 @@ def postgres_18_service( yield service -@pytest.fixture(autouse=False, scope="session") -def postgres_11_connection( - postgres_11_service: PostgresService, -) -> Generator[psycopg.Connection, None, None]: - with psycopg.connect( - _make_connection_string( - host=postgres_11_service.host, - port=postgres_11_service.port, - user=postgres_11_service.user, - password=postgres_11_service.password, - database=postgres_11_service.database, - ), - ) as conn: - yield conn - - -@pytest.fixture(autouse=False, scope="session") -def postgres_12_connection( - postgres_12_service: PostgresService, -) -> Generator[psycopg.Connection, None, None]: - with psycopg.connect( - _make_connection_string( - host=postgres_12_service.host, - port=postgres_12_service.port, - user=postgres_12_service.user, - password=postgres_12_service.password, - database=postgres_12_service.database, - ), - ) as conn: - yield conn - - -@pytest.fixture(autouse=False, scope="session") -def postgres_13_connection( - postgres_13_service: PostgresService, -) -> Generator[psycopg.Connection, None, None]: - with psycopg.connect( - _make_connection_string( - host=postgres_13_service.host, - port=postgres_13_service.port, - user=postgres_13_service.user, - password=postgres_13_service.password, - database=postgres_13_service.database, - ), - ) as conn: - yield conn - - -@pytest.fixture(autouse=False, scope="session") -def postgres_14_connection( - postgres_14_service: PostgresService, -) -> Generator[psycopg.Connection, None, None]: - with psycopg.connect( - _make_connection_string( - host=postgres_14_service.host, - port=postgres_14_service.port, - user=postgres_14_service.user, - password=postgres_14_service.password, - database=postgres_14_service.database, - ), - ) as conn: - yield conn - - -@pytest.fixture(autouse=False, scope="session") -def postgres_15_connection( - postgres_15_service: PostgresService, -) -> Generator[psycopg.Connection, None, None]: - with psycopg.connect( - _make_connection_string( - host=postgres_15_service.host, - port=postgres_15_service.port, - user=postgres_15_service.user, - password=postgres_15_service.password, - database=postgres_15_service.database, - ), - ) as conn: - yield conn - - -@pytest.fixture(autouse=False, scope="session") -def postgres_16_connection( - postgres_16_service: PostgresService, -) -> Generator[psycopg.Connection, None, None]: - with psycopg.connect( - _make_connection_string( - host=postgres_16_service.host, - port=postgres_16_service.port, - user=postgres_16_service.user, - password=postgres_16_service.password, - database=postgres_16_service.database, - ), - ) as conn: - yield conn - - -@pytest.fixture(autouse=False, scope="session") -def postgres_17_connection( - postgres_17_service: PostgresService, -) -> Generator[psycopg.Connection, None, None]: - with psycopg.connect( - _make_connection_string( - host=postgres_17_service.host, - port=postgres_17_service.port, - user=postgres_17_service.user, - password=postgres_17_service.password, - database=postgres_17_service.database, - ), - ) as conn: - yield conn - - -@pytest.fixture(autouse=False, scope="session") -def postgres_18_connection( - postgres_18_service: PostgresService, -) -> Generator[psycopg.Connection, None, None]: - with psycopg.connect( - _make_connection_string( - host=postgres_18_service.host, - port=postgres_18_service.port, - user=postgres_18_service.user, - password=postgres_18_service.password, - database=postgres_18_service.database, - ), - ) as conn: - yield conn - - @pytest.fixture(autouse=False, scope="session") def postgres_image() -> str: return "postgres:18" @@ -493,22 +435,6 @@ def postgres_service( yield service -@pytest.fixture(autouse=False, scope="session") -def postgres_connection( - postgres_service: PostgresService, -) -> Generator[psycopg.Connection, None, None]: - with psycopg.connect( - _make_connection_string( - host=postgres_service.host, - port=postgres_service.port, - user=postgres_service.user, - password=postgres_service.password, - database=postgres_service.database, - ), - ) as conn: - yield conn - - @pytest.fixture(autouse=False, scope="session") def pgvector_image() -> str: return "pgvector/pgvector:pg18" @@ -711,118 +637,6 @@ def pgvector_18_service( yield service -@pytest.fixture(autouse=False, scope="session") -def pgvector_connection( - pgvector_service: PostgresService, -) -> Generator[psycopg.Connection, None, None]: - with psycopg.connect( - _make_connection_string( - host=pgvector_service.host, - port=pgvector_service.port, - user=pgvector_service.user, - password=pgvector_service.password, - database=pgvector_service.database, - ), - ) as conn: - yield conn - - -@pytest.fixture(autouse=False, scope="session") -def pgvector_13_connection( - pgvector_13_service: PostgresService, -) -> Generator[psycopg.Connection, None, None]: - with psycopg.connect( - _make_connection_string( - host=pgvector_13_service.host, - port=pgvector_13_service.port, - user=pgvector_13_service.user, - password=pgvector_13_service.password, - database=pgvector_13_service.database, - ), - ) as conn: - yield conn - - -@pytest.fixture(autouse=False, scope="session") -def pgvector_14_connection( - pgvector_14_service: PostgresService, -) -> Generator[psycopg.Connection, None, None]: - with psycopg.connect( - _make_connection_string( - host=pgvector_14_service.host, - port=pgvector_14_service.port, - user=pgvector_14_service.user, - password=pgvector_14_service.password, - database=pgvector_14_service.database, - ), - ) as conn: - yield conn - - -@pytest.fixture(autouse=False, scope="session") -def pgvector_15_connection( - pgvector_15_service: PostgresService, -) -> Generator[psycopg.Connection, None, None]: - with psycopg.connect( - _make_connection_string( - host=pgvector_15_service.host, - port=pgvector_15_service.port, - user=pgvector_15_service.user, - password=pgvector_15_service.password, - database=pgvector_15_service.database, - ), - ) as conn: - yield conn - - -@pytest.fixture(autouse=False, scope="session") -def pgvector_16_connection( - pgvector_16_service: PostgresService, -) -> Generator[psycopg.Connection, None, None]: - with psycopg.connect( - _make_connection_string( - host=pgvector_16_service.host, - port=pgvector_16_service.port, - user=pgvector_16_service.user, - password=pgvector_16_service.password, - database=pgvector_16_service.database, - ), - ) as conn: - yield conn - - -@pytest.fixture(autouse=False, scope="session") -def pgvector_17_connection( - pgvector_17_service: PostgresService, -) -> Generator[psycopg.Connection, None, None]: - with psycopg.connect( - _make_connection_string( - host=pgvector_17_service.host, - port=pgvector_17_service.port, - user=pgvector_17_service.user, - password=pgvector_17_service.password, - database=pgvector_17_service.database, - ), - ) as conn: - yield conn - - -@pytest.fixture(autouse=False, scope="session") -def pgvector_18_connection( - pgvector_18_service: PostgresService, -) -> Generator[psycopg.Connection, None, None]: - with psycopg.connect( - _make_connection_string( - host=pgvector_18_service.host, - port=pgvector_18_service.port, - user=pgvector_18_service.user, - password=pgvector_18_service.password, - database=pgvector_18_service.database, - ), - ) as conn: - yield conn - - @pytest.fixture(autouse=False, scope="session") def paradedb_image() -> str: return "paradedb/paradedb:latest-pg18" @@ -969,86 +783,6 @@ def paradedb_18_service( yield service -@pytest.fixture(autouse=False, scope="session") -def paradedb_connection( - paradedb_service: PostgresService, -) -> Generator[psycopg.Connection, None, None]: - with psycopg.connect( - _make_connection_string( - host=paradedb_service.host, - port=paradedb_service.port, - user=paradedb_service.user, - password=paradedb_service.password, - database=paradedb_service.database, - ), - ) as conn: - yield conn - - -@pytest.fixture(autouse=False, scope="session") -def paradedb_15_connection( - paradedb_15_service: PostgresService, -) -> Generator[psycopg.Connection, None, None]: - with psycopg.connect( - _make_connection_string( - host=paradedb_15_service.host, - port=paradedb_15_service.port, - user=paradedb_15_service.user, - password=paradedb_15_service.password, - database=paradedb_15_service.database, - ), - ) as conn: - yield conn - - -@pytest.fixture(autouse=False, scope="session") -def paradedb_16_connection( - paradedb_16_service: PostgresService, -) -> Generator[psycopg.Connection, None, None]: - with psycopg.connect( - _make_connection_string( - host=paradedb_16_service.host, - port=paradedb_16_service.port, - user=paradedb_16_service.user, - password=paradedb_16_service.password, - database=paradedb_16_service.database, - ), - ) as conn: - yield conn - - -@pytest.fixture(autouse=False, scope="session") -def paradedb_17_connection( - paradedb_17_service: PostgresService, -) -> Generator[psycopg.Connection, None, None]: - with psycopg.connect( - _make_connection_string( - host=paradedb_17_service.host, - port=paradedb_17_service.port, - user=paradedb_17_service.user, - password=paradedb_17_service.password, - database=paradedb_17_service.database, - ), - ) as conn: - yield conn - - -@pytest.fixture(autouse=False, scope="session") -def paradedb_18_connection( - paradedb_18_service: PostgresService, -) -> Generator[psycopg.Connection, None, None]: - with psycopg.connect( - _make_connection_string( - host=paradedb_18_service.host, - port=paradedb_18_service.port, - user=paradedb_18_service.user, - password=paradedb_18_service.password, - database=paradedb_18_service.database, - ), - ) as conn: - yield conn - - @pytest.fixture(autouse=False, scope="session") def alloydb_omni_image() -> str: return "google/alloydbomni:17" @@ -1165,67 +899,3 @@ def alloydb_omni_17_service( host_port=alloydb_omni_17_port, ) as service: yield service - - -@pytest.fixture(autouse=False, scope="session") -def alloydb_omni_connection( - alloydb_omni_service: PostgresService, -) -> Generator[psycopg.Connection, None, None]: - with psycopg.connect( - _make_connection_string( - host=alloydb_omni_service.host, - port=alloydb_omni_service.port, - user=alloydb_omni_service.user, - password=alloydb_omni_service.password, - database=alloydb_omni_service.database, - ), - ) as conn: - yield conn - - -@pytest.fixture(autouse=False, scope="session") -def alloydb_omni_15_connection( - alloydb_omni_15_service: PostgresService, -) -> Generator[psycopg.Connection, None, None]: - with psycopg.connect( - _make_connection_string( - host=alloydb_omni_15_service.host, - port=alloydb_omni_15_service.port, - user=alloydb_omni_15_service.user, - password=alloydb_omni_15_service.password, - database=alloydb_omni_15_service.database, - ), - ) as conn: - yield conn - - -@pytest.fixture(autouse=False, scope="session") -def alloydb_omni_16_connection( - alloydb_omni_16_service: PostgresService, -) -> Generator[psycopg.Connection, None, None]: - with psycopg.connect( - _make_connection_string( - host=alloydb_omni_16_service.host, - port=alloydb_omni_16_service.port, - user=alloydb_omni_16_service.user, - password=alloydb_omni_16_service.password, - database=alloydb_omni_16_service.database, - ), - ) as conn: - yield conn - - -@pytest.fixture(autouse=False, scope="session") -def alloydb_omni_17_connection( - alloydb_omni_17_service: PostgresService, -) -> Generator[psycopg.Connection, None, None]: - with psycopg.connect( - _make_connection_string( - host=alloydb_omni_17_service.host, - port=alloydb_omni_17_service.port, - user=alloydb_omni_17_service.user, - password=alloydb_omni_17_service.password, - database=alloydb_omni_17_service.database, - ), - ) as conn: - yield conn diff --git a/tests/test_postgres.py b/tests/test_postgres.py index df0484d3..256602c8 100644 --- a/tests/test_postgres.py +++ b/tests/test_postgres.py @@ -11,6 +11,46 @@ def _pick_free_port() -> int: return sock.getsockname()[1] +def test_plugin_imports_without_psycopg(pytester: pytest.Pytester) -> None: + pytester.makepyfile(""" +import builtins + +def test_import() -> None: + original_import = builtins.__import__ + + def blocked_import(name, globals=None, locals=None, fromlist=(), level=0): + if name == "psycopg" or name.startswith("psycopg."): + raise ModuleNotFoundError(name) + return original_import(name, globals, locals, fromlist, level) + + builtins.__import__ = blocked_import + try: + import pytest_databases.docker.postgres + finally: + builtins.__import__ = original_import +""") + + result = pytester.runpytest_subprocess("-p", "pytest_databases", "-vv") + result.assert_outcomes(passed=1) + + +POSTGRES_TEST_HELPERS = """ +def run_psql(service, sql, *, database=None, tuples_only=False): + cmd = ["psql", "-v", "ON_ERROR_STOP=1", "-U", service.user, "-d", database or service.database] + cmd.extend(["-tAc", sql] if tuples_only else ["-c", sql]) + result = service.container.exec_run(cmd, environment={"PGPASSWORD": service.password}) + output = result.output + if isinstance(output, bytes): + decoded = output.decode(errors="replace") + elif isinstance(output, str): + decoded = output + else: + decoded = b"".join(output).decode(errors="replace") + assert result.exit_code == 0, decoded + return decoded.strip() +""" + + @pytest.mark.parametrize( ("service_fixture", "env_var"), [ @@ -28,13 +68,11 @@ def test_port_pinning_via_env( ) -> None: free_port = _pick_free_port() pytester.makepyfile(f""" - import pytest +pytest_plugins = ["pytest_databases.docker.postgres"] - pytest_plugins = ["pytest_databases.docker.postgres"] - - def test_pinned({service_fixture}) -> None: - assert {service_fixture}.port == {free_port} - """) +def test_pinned({service_fixture}) -> None: + assert {service_fixture}.port == {free_port} +""") monkeypatch.setenv(env_var, str(free_port)) result = pytester.runpytest_subprocess("-p", "pytest_databases") result.assert_outcomes(passed=1) @@ -71,144 +109,85 @@ def test_pinned({service_fixture}) -> None: ) def test_service_fixture(pytester: pytest.Pytester, service_fixture: str) -> None: pytester.makepyfile(f""" - import pytest - import psycopg - from pytest_databases.docker.postgres import _make_connection_string # noqa: PLC2701 - - - pytest_plugins = [ - "pytest_databases.docker.postgres", - ] - - def test({service_fixture}) -> None: - with psycopg.connect( - _make_connection_string( - host={service_fixture}.host, - port={service_fixture}.port, - user={service_fixture}.user, - password={service_fixture}.password, - database={service_fixture}.database, - ) - ) as conn: - db_open = conn.execute("SELECT 1").fetchone() - assert db_open is not None and db_open[0] == 1 - """) +from pytest_databases.docker.postgres import PostgresService + +pytest_plugins = ["pytest_databases.docker.postgres"] + +{POSTGRES_TEST_HELPERS} + +def test({service_fixture}: PostgresService) -> None: + assert run_psql({service_fixture}, "SELECT 1", tuples_only=True) == "1" +""") result = pytester.runpytest_subprocess("-p", "pytest_databases") result.assert_outcomes(passed=1) @pytest.mark.parametrize( - "connection_fixture", + "service_fixture", [ - "postgres_connection", - "postgres_11_connection", - "postgres_12_connection", - "postgres_13_connection", - "postgres_14_connection", - "postgres_15_connection", - "postgres_16_connection", - "postgres_17_connection", - "postgres_18_connection", - "alloydb_omni_connection", - "alloydb_omni_15_connection", - "alloydb_omni_16_connection", - "alloydb_omni_17_connection", - "pgvector_connection", - "pgvector_13_connection", - "pgvector_14_connection", - "pgvector_15_connection", - "pgvector_16_connection", - "pgvector_17_connection", - "pgvector_18_connection", - "paradedb_connection", - "paradedb_15_connection", - "paradedb_16_connection", - "paradedb_17_connection", - "paradedb_18_connection", + "postgres_service", + "postgres_18_service", + "alloydb_omni_service", + "pgvector_service", + "paradedb_service", ], ) -def test_startup_connection_fixture(pytester: pytest.Pytester, connection_fixture: str) -> None: +def test_startup_table_roundtrip(pytester: pytest.Pytester, service_fixture: str) -> None: pytester.makepyfile(f""" - import pytest - import psycopg - from pytest_databases.docker.postgres import _make_connection_string # noqa: PLC2701 +from pytest_databases.docker.postgres import PostgresService +pytest_plugins = ["pytest_databases.docker.postgres"] - pytest_plugins = [ - "pytest_databases.docker.postgres", - ] +{POSTGRES_TEST_HELPERS} - def test({connection_fixture}) -> None: - {connection_fixture}.execute("CREATE TABLE if not exists simple_table as SELECT 1") - result = {connection_fixture}.execute("select * from simple_table").fetchone() - assert result is not None and result[0] == 1 - """) +def test({service_fixture}: PostgresService) -> None: + run_psql({service_fixture}, "CREATE TABLE IF NOT EXISTS simple_table AS SELECT 1 AS x") + assert run_psql({service_fixture}, "SELECT x FROM simple_table", tuples_only=True) == "1" +""") result = pytester.runpytest_subprocess("-p", "pytest_databases") result.assert_outcomes(passed=1) def test_xdist_isolate_db(pytester: pytest.Pytester) -> None: - pytester.makepyfile(""" - import pytest - import psycopg - from pytest_databases.docker.postgres import _make_connection_string # noqa: PLC2701 + pytester.makepyfile(f""" +from pytest_databases.docker.postgres import PostgresService +pytest_plugins = ["pytest_databases.docker.postgres"] - pytest_plugins = ["pytest_databases.docker.postgres"] +{POSTGRES_TEST_HELPERS} - def test_two(postgres_connection) -> None: - postgres_connection.execute("CREATE TABLE foo AS SELECT 1") +def test_one(postgres_service: PostgresService) -> None: + run_psql(postgres_service, "CREATE TABLE foo AS SELECT 1") - def test_two(postgres_connection) -> None: - postgres_connection.execute("CREATE TABLE foo AS SELECT 1") - """) +def test_two(postgres_service: PostgresService) -> None: + run_psql(postgres_service, "CREATE TABLE foo AS SELECT 1") +""") result = pytester.runpytest_subprocess("-p", "pytest_databases", "-n", "2") - result.assert_outcomes(passed=1) + result.assert_outcomes(passed=2) def test_xdist_isolate_server(pytester: pytest.Pytester) -> None: - pytester.makepyfile(""" - import pytest - import psycopg - from pytest_databases.docker.postgres import _make_connection_string - - pytest_plugins = [ - "pytest_databases.docker.postgres", - ] - - @pytest.fixture(scope="session") - def xdist_postgres_isolation_level(): - return "server" - - def test_one(postgres_service) -> None: - with psycopg.connect( - _make_connection_string( - host=postgres_service.host, - port=postgres_service.port, - user=postgres_service.user, - password=postgres_service.password, - database=postgres_service.database, - ), - autocommit=True, - ) as conn: - conn.execute("CREATE DATABASE foo") - - def test_two(postgres_service) -> None: - with psycopg.connect( - _make_connection_string( - host=postgres_service.host, - port=postgres_service.port, - user=postgres_service.user, - password=postgres_service.password, - database=postgres_service.database, - ), - autocommit=True, - ) as conn: - conn.execute("CREATE DATABASE foo") - """) + pytester.makepyfile(f""" +import pytest +from pytest_databases.docker.postgres import PostgresService + +pytest_plugins = ["pytest_databases.docker.postgres"] + +@pytest.fixture(scope="session") +def xdist_postgres_isolation_level(): + return "server" + +{POSTGRES_TEST_HELPERS} + +def test_one(postgres_service: PostgresService) -> None: + run_psql(postgres_service, "CREATE DATABASE foo", database="postgres") + +def test_two(postgres_service: PostgresService) -> None: + run_psql(postgres_service, "CREATE DATABASE foo", database="postgres") +""") result = pytester.runpytest_subprocess("-p", "pytest_databases", "-n", "2") result.assert_outcomes(passed=2) diff --git a/uv.lock b/uv.lock index 3ee06df2..bec83675 100644 --- a/uv.lock +++ b/uv.lock @@ -3786,10 +3786,6 @@ keydb = [ { name = "redis", version = "7.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "redis", version = "7.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, ] -postgres = [ - { name = "psycopg", version = "3.2.13", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "psycopg", version = "3.3.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, -] redis = [ { name = "redis", version = "7.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "redis", version = "7.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, @@ -3938,7 +3934,6 @@ requires-dist = [ { name = "docker" }, { name = "filelock" }, { name = "google-cloud-bigquery", marker = "extra == 'bigquery'" }, - { name = "psycopg", marker = "extra == 'postgres'", specifier = ">=3" }, { name = "pyarrow", marker = "extra == 'gizmosql'" }, { name = "pytest" }, { name = "redis", marker = "extra == 'dragonfly'" }, From bbbf723a7d474ce7303321a4c9bbd5b6b3a7de24 Mon Sep 17 00:00:00 2001 From: Cody Fincher Date: Sun, 24 May 2026 21:01:38 +0000 Subject: [PATCH 2/3] fix(postgres): force TCP in psql exec to dodge unix-socket race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The postgres official image runs a two-phase boot: phase 1 listens on the Unix socket only for initdb, phase 5 restarts with TCP listeners. Calling psql without -h opportunistically uses the socket and the readiness check could pass during phase 1, leaving _create_worker_database to find the socket gone (or never present) after phase 5. Pinning -h localhost -p 5432 forces psql onto the same TCP transport user code uses, so readiness ≡ "TCP ready". Surfaced as a flaky postgres_14_service failure on the Python 3.14 shard. --- src/pytest_databases/docker/postgres.py | 2 +- tests/test_postgres.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pytest_databases/docker/postgres.py b/src/pytest_databases/docker/postgres.py index a401489c..825363e3 100644 --- a/src/pytest_databases/docker/postgres.py +++ b/src/pytest_databases/docker/postgres.py @@ -40,7 +40,7 @@ def _exec_psql( sql: str, tuples_only: bool = False, ) -> tuple[int, bytes]: - cmd = ["psql", "-v", "ON_ERROR_STOP=1", "-U", user, "-d", database] + cmd = ["psql", "-v", "ON_ERROR_STOP=1", "-h", "localhost", "-p", "5432", "-U", user, "-d", database] if tuples_only: cmd.extend(["-tAc", sql]) else: diff --git a/tests/test_postgres.py b/tests/test_postgres.py index 256602c8..a72c85c6 100644 --- a/tests/test_postgres.py +++ b/tests/test_postgres.py @@ -36,7 +36,7 @@ def blocked_import(name, globals=None, locals=None, fromlist=(), level=0): POSTGRES_TEST_HELPERS = """ def run_psql(service, sql, *, database=None, tuples_only=False): - cmd = ["psql", "-v", "ON_ERROR_STOP=1", "-U", service.user, "-d", database or service.database] + cmd = ["psql", "-v", "ON_ERROR_STOP=1", "-h", "localhost", "-p", "5432", "-U", service.user, "-d", database or service.database] cmd.extend(["-tAc", sql] if tuples_only else ["-c", sql]) result = service.container.exec_run(cmd, environment={"PGPASSWORD": service.password}) output = result.output From 61d52a8ae68d567b2fd76a0424e67d8f9b11f411 Mon Sep 17 00:00:00 2001 From: Cody Fincher Date: Wed, 22 Jul 2026 03:51:11 +0000 Subject: [PATCH 3/3] ci(postgres): cover clientless import compatibility --- .github/ci/provider-groups.json | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/ci/provider-groups.json b/.github/ci/provider-groups.json index 63330e1a..0af239ad 100644 --- a/.github/ci/provider-groups.json +++ b/.github/ci/provider-groups.json @@ -27,6 +27,7 @@ "tests/test_elasticsearch.py::test_plugin_imports_without_elasticsearch_clients", "tests/test_mongodb.py::test_plugin_imports_without_pymongo", "tests/test_mssql.py::test_plugin_imports_without_pymssql", + "tests/test_postgres.py::test_plugin_imports_without_psycopg", "tests/test_spanner.py::test_plugin_imports_without_google_cloud_spanner", "tests/test_valkey.py::test_plugin_imports_without_valkey" ],