From 2a9f84e6d3aa6483903e1c0c2b7f0a900ec0f58b Mon Sep 17 00:00:00 2001 From: tboy1337 Date: Fri, 13 Mar 2026 20:17:50 +0700 Subject: [PATCH 01/42] feat: add async support for PostgreSQL janitor and loader - Introduced AsyncDatabaseJanitor for managing database state asynchronously. - Added async loading capabilities with build_loader_async and sql_async functions. - Updated factories to include async versions of PostgreSQL fixtures. - Enhanced tests to cover async functionality for janitor and loader. - Updated pyproject.toml to include optional dependencies for async support. --- newsfragments/1235.feature.rst | 3 + pyproject.toml | 6 + pytest_postgresql/factories/__init__.py | 4 +- pytest_postgresql/factories/client.py | 79 ++++++++++- pytest_postgresql/factories/noprocess.py | 5 +- pytest_postgresql/factories/process.py | 5 +- pytest_postgresql/janitor.py | 161 ++++++++++++++++++++++- pytest_postgresql/loader.py | 38 +++++- pytest_postgresql/retry.py | 35 ++++- pytest_postgresql/types.py | 5 + tests/conftest.py | 2 + tests/docker/test_noproc_docker.py | 29 +++- tests/test_janitor.py | 84 +++++++++++- tests/test_loader.py | 25 +++- tests/test_postgresql.py | 63 ++++++++- tests/test_template_database.py | 24 +++- 16 files changed, 541 insertions(+), 27 deletions(-) create mode 100644 newsfragments/1235.feature.rst create mode 100644 pytest_postgresql/types.py diff --git a/newsfragments/1235.feature.rst b/newsfragments/1235.feature.rst new file mode 100644 index 00000000..6c047099 --- /dev/null +++ b/newsfragments/1235.feature.rst @@ -0,0 +1,3 @@ +Added async PostgreSQL fixture support via ``postgresql_async`` factory and ``AsyncDatabaseJanitor``. +Added configurable fixture ``scope`` parameter to ``postgresql``, ``postgresql_async``, ``postgresql_proc``, and ``postgresql_noproc`` factories (defaults preserved: ``"function"`` for client fixtures, ``"session"`` for process fixtures). +Added optional ``async`` extra (``pip install pytest-postgresql[async]``) providing ``pytest-asyncio`` and ``aiofiles`` dependencies. diff --git a/pyproject.toml b/pyproject.toml index a9cb59a1..06e162b0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,12 @@ dependencies = [ ] requires-python = ">= 3.10" +[project.optional-dependencies] +async = [ + "pytest-asyncio >= 0.21", + "aiofiles >= 23.0" +] + [project.urls] "Source" = "https://github.com/dbfixtures/pytest-postgresql" "Bug Tracker" = "https://github.com/dbfixtures/pytest-postgresql/issues" diff --git a/pytest_postgresql/factories/__init__.py b/pytest_postgresql/factories/__init__.py index d6bd2f64..002304cb 100644 --- a/pytest_postgresql/factories/__init__.py +++ b/pytest_postgresql/factories/__init__.py @@ -17,8 +17,8 @@ # along with pytest-postgresql. If not, see . """Fixture factories for postgresql fixtures.""" -from pytest_postgresql.factories.client import postgresql +from pytest_postgresql.factories.client import postgresql, postgresql_async from pytest_postgresql.factories.noprocess import postgresql_noproc from pytest_postgresql.factories.process import PortType, postgresql_proc -__all__ = ("postgresql_proc", "postgresql_noproc", "postgresql", "PortType") +__all__ = ("postgresql_proc", "postgresql_noproc", "postgresql", "postgresql_async", "PortType") diff --git a/pytest_postgresql/factories/client.py b/pytest_postgresql/factories/client.py index 5fb5a5be..ebdc75a5 100644 --- a/pytest_postgresql/factories/client.py +++ b/pytest_postgresql/factories/client.py @@ -17,23 +17,25 @@ # along with pytest-postgresql. If not, see . """Fixture factory for postgresql client.""" -from typing import Callable, Iterator +from typing import AsyncIterator, Callable, Iterator import psycopg import pytest -from psycopg import Connection +from psycopg import AsyncConnection, Connection from pytest import FixtureRequest from pytest_postgresql.config import get_config from pytest_postgresql.executor import PostgreSQLExecutor from pytest_postgresql.executor_noop import NoopExecutor -from pytest_postgresql.janitor import DatabaseJanitor +from pytest_postgresql.janitor import AsyncDatabaseJanitor, DatabaseJanitor +from pytest_postgresql.types import FixtureScopeT def postgresql( process_fixture_name: str, dbname: str | None = None, isolation_level: "psycopg.IsolationLevel | None" = None, + scope: FixtureScopeT = "function", ) -> Callable[[FixtureRequest], Iterator[Connection]]: """Return connection fixture factory for PostgreSQL. @@ -41,12 +43,13 @@ def postgresql( :param dbname: database name :param isolation_level: optional postgresql isolation level defaults to server's default + :param scope: fixture scope; by default "function" which is recommended. :returns: function which makes a connection to postgresql """ - @pytest.fixture + @pytest.fixture(scope=scope) def postgresql_factory(request: FixtureRequest) -> Iterator[Connection]: - """Fixture factory for PostgreSQL. + """Fixture connection factory for PostgreSQL. :param request: fixture request object :returns: postgresql client @@ -85,3 +88,69 @@ def postgresql_factory(request: FixtureRequest) -> Iterator[Connection]: db_connection.close() return postgresql_factory + + +def postgresql_async( + process_fixture_name: str, + dbname: str | None = None, + isolation_level: "psycopg.IsolationLevel | None" = None, + scope: FixtureScopeT = "function", +) -> Callable[[FixtureRequest], AsyncIterator[AsyncConnection]]: + """Return async connection fixture factory for PostgreSQL. + + :param process_fixture_name: name of the process fixture + :param dbname: database name + :param isolation_level: optional postgresql isolation level + defaults to server's default + :param scope: fixture scope; by default "function" which is recommended. + :returns: function which makes an async connection to postgresql + """ + try: + import pytest_asyncio + except ImportError as exc: + raise ImportError( + "pytest-asyncio is required for async fixtures. " + "Install it with: pip install pytest-postgresql[async]" + ) from exc + + @pytest_asyncio.fixture(scope=scope) + async def postgresql_async_factory(request: FixtureRequest) -> AsyncIterator[AsyncConnection]: + """Async connection fixture factory for PostgreSQL. + + :param request: fixture request object + :returns: postgresql async client + """ + proc_fixture: PostgreSQLExecutor | NoopExecutor = request.getfixturevalue(process_fixture_name) + config = get_config(request) + + pg_host = proc_fixture.host + pg_port = proc_fixture.port + pg_user = proc_fixture.user + pg_password = proc_fixture.password + pg_options = proc_fixture.options + pg_db = dbname or proc_fixture.dbname + janitor = AsyncDatabaseJanitor( + user=pg_user, + host=pg_host, + port=pg_port, + dbname=pg_db, + template_dbname=proc_fixture.template_dbname, + version=proc_fixture.version, + password=pg_password, + isolation_level=isolation_level, + ) + if config.drop_test_database: + await janitor.drop() + async with janitor: + db_connection: AsyncConnection = await AsyncConnection.connect( + dbname=pg_db, + user=pg_user, + password=pg_password, + host=pg_host, + port=pg_port, + options=pg_options, + ) + yield db_connection + await db_connection.close() + + return postgresql_async_factory diff --git a/pytest_postgresql/factories/noprocess.py b/pytest_postgresql/factories/noprocess.py index 2d7f8b49..8af27c37 100644 --- a/pytest_postgresql/factories/noprocess.py +++ b/pytest_postgresql/factories/noprocess.py @@ -27,6 +27,7 @@ from pytest_postgresql.config import get_config from pytest_postgresql.executor_noop import NoopExecutor from pytest_postgresql.janitor import DatabaseJanitor +from pytest_postgresql.types import FixtureScopeT def xdistify_dbname(dbname: str) -> str: @@ -46,6 +47,7 @@ def postgresql_noproc( options: str = "", load: list[Callable | str | Path] | None = None, depends_on: str | None = None, + scope: FixtureScopeT = "session", ) -> Callable[[FixtureRequest], Iterator[NoopExecutor]]: """Postgresql noprocess factory. @@ -57,10 +59,11 @@ def postgresql_noproc( :param options: Postgresql connection options :param load: List of functions used to initialize database's template. :param depends_on: Optional name of the fixture to depend on. + :param scope: fixture scope; by default "session" which is recommended. :returns: function which makes a postgresql process """ - @pytest.fixture(scope="session") + @pytest.fixture(scope=scope) def postgresql_noproc_fixture(request: FixtureRequest) -> Iterator[NoopExecutor]: """Noop Process fixture for PostgreSQL. diff --git a/pytest_postgresql/factories/process.py b/pytest_postgresql/factories/process.py index 27fab57f..cbfc0111 100644 --- a/pytest_postgresql/factories/process.py +++ b/pytest_postgresql/factories/process.py @@ -32,6 +32,7 @@ from pytest_postgresql.exceptions import ExecutableMissingException from pytest_postgresql.executor import PostgreSQLExecutor from pytest_postgresql.janitor import DatabaseJanitor +from pytest_postgresql.types import FixtureScopeT PortType = port_for.PortType # mypy requires explicit export @@ -81,6 +82,7 @@ def postgresql_proc( unixsocketdir: str | None = None, postgres_options: str | None = None, load: list[Callable | str | Path] | None = None, + scope: FixtureScopeT = "session", ) -> Callable[[FixtureRequest, TempPathFactory], Iterator[PostgreSQLExecutor]]: """Postgresql process factory. @@ -101,10 +103,11 @@ def postgresql_proc( :param unixsocketdir: directory to create postgresql's unixsockets :param postgres_options: Postgres executable options for use by pg_ctl :param load: List of functions used to initialize database's template. + :param scope: fixture scope; by default "session" which is recommended. :returns: function which makes a postgresql process """ - @pytest.fixture(scope="session") + @pytest.fixture(scope=scope) def postgresql_proc_fixture( request: FixtureRequest, tmp_path_factory: TempPathFactory ) -> Iterator[PostgreSQLExecutor]: diff --git a/pytest_postgresql/janitor.py b/pytest_postgresql/janitor.py index f602372e..ccb10d41 100644 --- a/pytest_postgresql/janitor.py +++ b/pytest_postgresql/janitor.py @@ -1,21 +1,23 @@ """Database Janitor.""" -from contextlib import contextmanager +import inspect +from contextlib import asynccontextmanager, contextmanager from pathlib import Path from types import TracebackType -from typing import Callable, Iterator, Type, TypeVar +from typing import AsyncIterator, Callable, Iterator, Type, TypeVar import psycopg from packaging.version import parse -from psycopg import Connection, Cursor +from psycopg import AsyncCursor, Connection, Cursor -from pytest_postgresql.loader import build_loader -from pytest_postgresql.retry import retry +from pytest_postgresql.loader import build_loader, build_loader_async +from pytest_postgresql.retry import retry, retry_async Version = type(parse("1")) DatabaseJanitorType = TypeVar("DatabaseJanitorType", bound="DatabaseJanitor") +AsyncDatabaseJanitorType = TypeVar("AsyncDatabaseJanitorType", bound="AsyncDatabaseJanitor") class DatabaseJanitor: @@ -164,3 +166,152 @@ def __exit__( ) -> None: """Exit from Database janitor context cleaning after itself.""" self.drop() + + +class AsyncDatabaseJanitor: + """Manage database state asynchronously for specific tasks.""" + + def __init__( + self, + *, + user: str, + host: str, + port: str | int, + version: str | float | Version, # type: ignore[valid-type] + dbname: str, + template_dbname: str | None = None, + as_template: bool = False, + password: str | None = None, + isolation_level: "psycopg.IsolationLevel | None" = None, + connection_timeout: int = 60, + ) -> None: + """Initialize async janitor. + + :param user: postgresql username + :param host: postgresql host + :param port: postgresql port + :param dbname: database name + :param template_dbname: template database name to clone from + :param as_template: whether to mark the database as a template + :param version: postgresql version number + :param password: optional postgresql password + :param isolation_level: optional postgresql isolation level + defaults to server's default + :param connection_timeout: how long to retry connection before + raising a TimeoutError + """ + self.user = user + self.password = password + self.host = host + self.port = port + self.dbname = dbname + self.template_dbname = template_dbname + self.as_template = as_template + self._connection_timeout = connection_timeout + self.isolation_level = isolation_level + if not isinstance(version, Version): + self.version = parse(str(version)) + else: + self.version = version + + async def init(self) -> None: + """Create database in postgresql.""" + async with self.cursor() as cur: + if self.template_dbname: + # And make sure no-one is left connected to the template database. + # Otherwise, Creating database from template will fail + await self._terminate_connection(cur, self.template_dbname) + query = f'CREATE DATABASE "{self.dbname}" TEMPLATE "{self.template_dbname}"' + else: + query = f'CREATE DATABASE "{self.dbname}"' + + if self.as_template: + query += " IS_TEMPLATE = true" + + await cur.execute(f"{query};") + + def is_template(self) -> bool: + """Determine whether the AsyncDatabaseJanitor maintains template or database.""" + return self.as_template + + async def drop(self) -> None: + """Drop database in postgresql.""" + # We cannot drop the database while there are connections to it, so we + # terminate all connections first while not allowing new connections. + async with self.cursor() as cur: + await self._dont_datallowconn(cur, self.dbname) + await self._terminate_connection(cur, self.dbname) + if self.as_template: + await cur.execute(f'ALTER DATABASE "{self.dbname}" with is_template false;') + await cur.execute(f'DROP DATABASE IF EXISTS "{self.dbname}";') + + @staticmethod + async def _dont_datallowconn(cur: AsyncCursor, dbname: str) -> None: # type: ignore[type-arg] + await cur.execute(f'ALTER DATABASE "{dbname}" with allow_connections false;') + + @staticmethod + async def _terminate_connection(cur: AsyncCursor, dbname: str) -> None: # type: ignore[type-arg] + await cur.execute( + "SELECT pg_terminate_backend(pg_stat_activity.pid)" + "FROM pg_stat_activity " + "WHERE pg_stat_activity.datname = %s;", + (dbname,), + ) + + async def load(self, load: Callable | str | Path) -> None: + """Load data into a database. + + Expects: + + * a Path to sql file, that'll be loaded + * an import path to import callable + * a callable that expects: host, port, user, dbname and password arguments. + + """ + _loader = build_loader_async(load) + result = _loader( + host=self.host, + port=self.port, + user=self.user, + dbname=self.dbname, + password=self.password, + ) + if inspect.isawaitable(result): + await result + + @asynccontextmanager + async def cursor(self, dbname: str = "postgres") -> AsyncIterator[AsyncCursor]: # type: ignore[type-arg] + """Return postgresql async cursor.""" + + async def connect() -> psycopg.AsyncConnection: + return await psycopg.AsyncConnection.connect( + dbname=dbname, + user=self.user, + password=self.password, + host=self.host, + port=self.port, + ) + + conn = await retry_async(connect, timeout=self._connection_timeout, possible_exception=psycopg.OperationalError) + try: + await conn.set_isolation_level(self.isolation_level) + await conn.set_autocommit(True) + # We must not run a transaction since we create a database. + async with conn.cursor() as cur: + yield cur + finally: + await conn.close() + + async def __aenter__(self: AsyncDatabaseJanitorType) -> AsyncDatabaseJanitorType: + """Initialize Async Database Janitor.""" + await self.init() + return self + + async def __aexit__( + self: AsyncDatabaseJanitorType, + exc_type: Type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + """Exit from Async Database Janitor context cleaning after itself.""" + await self.drop() diff --git a/pytest_postgresql/loader.py b/pytest_postgresql/loader.py index c9b28cbd..cd42b876 100644 --- a/pytest_postgresql/loader.py +++ b/pytest_postgresql/loader.py @@ -1,5 +1,6 @@ """Loader helper functions.""" +import importlib import re from functools import partial from pathlib import Path @@ -16,7 +17,7 @@ def build_loader(load: Callable | str | Path) -> Callable: loader_parts = re.split("[.:]", load, maxsplit=2) import_path = ".".join(loader_parts[:-1]) loader_name = loader_parts[-1] - _temp_import = __import__(import_path, globals(), locals(), fromlist=[loader_name]) + _temp_import = importlib.import_module(import_path) _loader: Callable = getattr(_temp_import, loader_name) return _loader else: @@ -30,3 +31,38 @@ def sql(sql_filename: Path, **kwargs: Any) -> None: with db_connection.cursor() as cur: cur.execute(_fd.read()) db_connection.commit() + + +def build_loader_async(load: Callable | str | Path) -> Callable: + """Build an async loader callable.""" + if isinstance(load, Path): + return partial(sql_async, load) + elif isinstance(load, str): + loader_parts = re.split("[.:]", load, maxsplit=2) + import_path = ".".join(loader_parts[:-1]) + loader_name = loader_parts[-1] + _temp_import = importlib.import_module(import_path) + _loader: Callable = getattr(_temp_import, loader_name) + return _loader + else: + return load + + +async def sql_async(sql_filename: Path, **kwargs: Any) -> None: + """Async database loader for sql files. + + Requires the optional ``async`` extra: ``pip install pytest-postgresql[async]``. + """ + try: + import aiofiles + except ImportError as exc: + raise ImportError( + "aiofiles is required for async SQL loading. " + "Install it with: pip install pytest-postgresql[async]" + ) from exc + + async with await psycopg.AsyncConnection.connect(**kwargs) as db_connection: + async with db_connection.cursor() as cur: + async with aiofiles.open(sql_filename, "r") as _fd: + await cur.execute(await _fd.read()) + await db_connection.commit() diff --git a/pytest_postgresql/retry.py b/pytest_postgresql/retry.py index ea25fa2e..078db5bc 100644 --- a/pytest_postgresql/retry.py +++ b/pytest_postgresql/retry.py @@ -1,9 +1,10 @@ """Small retry callable in case of specific error occurred.""" +import asyncio import datetime import sys from time import sleep -from typing import Callable, Type, TypeVar +from typing import Awaitable, Callable, Type, TypeVar T = TypeVar("T") @@ -29,11 +30,41 @@ def retry( i += 1 try: res = func() - return res except possible_exception as e: if time + timeout_diff < get_current_datetime(): raise TimeoutError(f"Failed after {i} attempts") from e sleep(1) + else: + return res + + +async def retry_async( + func: Callable[[], Awaitable[T]], + timeout: int = 60, + possible_exception: Type[Exception] = Exception, +) -> T: + """Attempt to retry the async function for timeout time. + + Most often used for connecting to postgresql database as, + especially on macos on github-actions, first few tries fails + with this message: + + ... :: + FATAL: the database system is starting up + """ + time: datetime.datetime = get_current_datetime() + timeout_diff: datetime.timedelta = datetime.timedelta(seconds=timeout) + i = 0 + while True: + i += 1 + try: + res = await func() + except possible_exception as e: + if time + timeout_diff < get_current_datetime(): + raise TimeoutError(f"Failed after {i} attempts") from e + await asyncio.sleep(1) + else: + return res def get_current_datetime() -> datetime.datetime: diff --git a/pytest_postgresql/types.py b/pytest_postgresql/types.py new file mode 100644 index 00000000..e5f35043 --- /dev/null +++ b/pytest_postgresql/types.py @@ -0,0 +1,5 @@ +"""Pytest PostgreSQL types.""" + +from typing import Literal + +FixtureScopeT = Literal["session", "package", "module", "class", "function"] diff --git a/tests/conftest.py b/tests/conftest.py index 784b8905..483437af 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -17,3 +17,5 @@ postgresql_proc2 = factories.postgresql_proc(port=None, load=[TEST_SQL_FILE, TEST_SQL_FILE2]) postgresql2 = factories.postgresql("postgresql_proc2", dbname="test-db") postgresql_load_1 = factories.postgresql("postgresql_proc2") +postgresql2_async = factories.postgresql_async("postgresql_proc2", dbname="test-db") +postgresql_load_1_async = factories.postgresql_async("postgresql_proc2") diff --git a/tests/docker/test_noproc_docker.py b/tests/docker/test_noproc_docker.py index ae25307a..1d0fbb73 100644 --- a/tests/docker/test_noproc_docker.py +++ b/tests/docker/test_noproc_docker.py @@ -3,7 +3,7 @@ import pathlib import pytest -from psycopg import Connection +from psycopg import AsyncConnection, Connection import pytest_postgresql.factories.client import pytest_postgresql.factories.noprocess @@ -14,12 +14,17 @@ ) postgres_with_schema = pytest_postgresql.factories.client.postgresql("postgresql_my_proc") +async_postgres_with_schema = pytest_postgresql.factories.client.postgresql_async("postgresql_my_proc") + postgresql_my_proc_template = pytest_postgresql.factories.noprocess.postgresql_noproc( dbname="stories_templated", load=[load_database] ) postgres_with_template = pytest_postgresql.factories.client.postgresql( "postgresql_my_proc_template", dbname="stories_templated" ) +async_postgres_with_template = pytest_postgresql.factories.client.postgresql_async( + "postgresql_my_proc_template", dbname="stories_templated" +) def test_postgres_docker_load(postgres_with_schema: Connection) -> None: @@ -32,6 +37,14 @@ def test_postgres_docker_load(postgres_with_schema: Connection) -> None: print(cur.fetchall()) +@pytest.mark.asyncio +async def test_postgres_docker_load_async(async_postgres_with_schema: AsyncConnection) -> None: + """Async check main postgres fixture.""" + async with async_postgres_with_schema.cursor() as cur: + await cur.execute("select * from public.tokens") + print(await cur.fetchall()) + + @pytest.mark.parametrize("_", range(5)) def test_template_database(postgres_with_template: Connection, _: int) -> None: """Check that the database structure gets recreated out of a template.""" @@ -43,3 +56,17 @@ def test_template_database(postgres_with_template: Connection, _: int) -> None: cur.execute("SELECT * FROM stories") res = cur.fetchall() assert len(res) == 0 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("_", range(5)) +async def test_template_database_async(async_postgres_with_template: AsyncConnection, _: int) -> None: + """Async check that the database structure gets recreated out of a template.""" + async with async_postgres_with_template.cursor() as cur: + await cur.execute("SELECT * FROM stories") + rows = await cur.fetchall() + assert len(rows) == 4 + await cur.execute("TRUNCATE stories") + await cur.execute("SELECT * FROM stories") + rows = await cur.fetchall() + assert len(rows) == 0 diff --git a/tests/test_janitor.py b/tests/test_janitor.py index fd1fca2a..95e4bc51 100644 --- a/tests/test_janitor.py +++ b/tests/test_janitor.py @@ -2,12 +2,12 @@ import sys from typing import Any -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest from packaging.version import parse -from pytest_postgresql.janitor import DatabaseJanitor +from pytest_postgresql.janitor import AsyncDatabaseJanitor, DatabaseJanitor VERSION = parse("10") @@ -19,6 +19,14 @@ def test_version_cast(version: Any) -> None: assert janitor.version == VERSION +@pytest.mark.parametrize("version", (VERSION, 10, "10")) +@pytest.mark.asyncio +async def test_version_cast_async(version: Any) -> None: + """Async test that version is cast to Version object.""" + janitor = AsyncDatabaseJanitor(user="user", host="host", port="1234", dbname="database_name", version=version) + assert janitor.version == VERSION + + @patch("pytest_postgresql.janitor.psycopg.connect") def test_cursor_selects_postgres_database(connect_mock: MagicMock) -> None: """Test that the cursor requests the postgres database.""" @@ -27,6 +35,19 @@ def test_cursor_selects_postgres_database(connect_mock: MagicMock) -> None: connect_mock.assert_called_once_with(dbname="postgres", user="user", password=None, host="host", port="1234") +@pytest.mark.asyncio +async def test_cursor_selects_postgres_database_async() -> None: + """Async test that the cursor requests the postgres database.""" + conn_mock = _make_async_conn_mock() + connect_mock = AsyncMock(return_value=conn_mock) + with patch("pytest_postgresql.janitor.psycopg.AsyncConnection.connect", connect_mock): + janitor = AsyncDatabaseJanitor(user="user", host="host", port="1234", dbname="database_name", version=10) + async with janitor.cursor(): + connect_mock.assert_called_once_with( + dbname="postgres", user="user", password=None, host="host", port="1234" + ) + + @patch("pytest_postgresql.janitor.psycopg.connect") def test_cursor_connects_with_password(connect_mock: MagicMock) -> None: """Test that the cursor requests the postgres database.""" @@ -36,7 +57,7 @@ def test_cursor_connects_with_password(connect_mock: MagicMock) -> None: port="1234", dbname="database_name", version=10, - password="some_password", + password="some_password", # noqa: S106 ) with janitor.cursor(): connect_mock.assert_called_once_with( @@ -44,6 +65,26 @@ def test_cursor_connects_with_password(connect_mock: MagicMock) -> None: ) +@pytest.mark.asyncio +async def test_cursor_connects_with_password_async() -> None: + """Async test that the cursor requests the postgres database with password.""" + conn_mock = _make_async_conn_mock() + connect_mock = AsyncMock(return_value=conn_mock) + with patch("pytest_postgresql.janitor.psycopg.AsyncConnection.connect", connect_mock): + janitor = AsyncDatabaseJanitor( + user="user", + host="host", + port="1234", + dbname="database_name", + version=10, + password="some_password", # noqa: S106 + ) + async with janitor.cursor(): + connect_mock.assert_called_once_with( + dbname="postgres", user="user", password="some_password", host="host", port="1234" + ) + + @pytest.mark.skipif(sys.version_info < (3, 8), reason="Unittest call_args.kwargs was introduced since python 3.8") @pytest.mark.parametrize("load_database", ("tests.loader.load_database", "tests.loader:load_database")) @patch("pytest_postgresql.janitor.psycopg.connect") @@ -57,9 +98,44 @@ def test_janitor_populate(connect_mock: MagicMock, load_database: str) -> None: "port": "1234", "user": "user", "dbname": "database_name", - "password": "some_password", + "password": "some_password", # noqa: S106 } janitor = DatabaseJanitor(version=10, **call_kwargs) # type: ignore[arg-type] janitor.load(load_database) assert connect_mock.called assert connect_mock.call_args.kwargs == call_kwargs + + +@pytest.mark.skipif(sys.version_info < (3, 8), reason="Unittest call_args.kwargs was introduced since python 3.8") +@pytest.mark.parametrize("load_database", ("tests.loader.load_database", "tests.loader:load_database")) +@patch("tests.loader.psycopg.connect") +@pytest.mark.asyncio +async def test_janitor_populate_async(connect_mock: MagicMock, load_database: str) -> None: + """Async test that the cursor requests the postgres database and populates. + + load_database (synchronous) uses psycopg.connect, so we mock that. + """ + call_kwargs = { + "host": "host", + "port": "1234", + "user": "user", + "dbname": "database_name", + "password": "some_password", # noqa: S106 + } + janitor = AsyncDatabaseJanitor(version=10, **call_kwargs) # type: ignore[arg-type] + await janitor.load(load_database) + assert connect_mock.called + assert connect_mock.call_args.kwargs == call_kwargs + + +def _make_async_conn_mock() -> MagicMock: + """Create a MagicMock that behaves like a psycopg3 AsyncConnection.""" + conn = MagicMock() + conn.set_isolation_level = AsyncMock() + conn.set_autocommit = AsyncMock() + conn.close = AsyncMock() + cursor_mock = MagicMock() + cursor_mock.__aenter__ = AsyncMock(return_value=MagicMock()) + cursor_mock.__aexit__ = AsyncMock(return_value=False) + conn.cursor = MagicMock(return_value=cursor_mock) + return conn diff --git a/tests/test_loader.py b/tests/test_loader.py index c03f8a55..fe33d466 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -2,7 +2,9 @@ from pathlib import Path -from pytest_postgresql.loader import build_loader, sql +import pytest + +from pytest_postgresql.loader import build_loader, build_loader_async, sql, sql_async from tests.loader import load_database @@ -12,9 +14,30 @@ def test_loader_callables() -> None: assert load_database == build_loader("tests.loader:load_database") +@pytest.mark.asyncio +async def test_loader_callables_async() -> None: + """Async test handling callables in build_loader_async.""" + assert load_database == build_loader_async(load_database) + assert load_database == build_loader_async("tests.loader:load_database") + + async def afun(*_args: object, **_kwargs: object) -> int: + return 0 + + assert afun == build_loader_async(afun) + + def test_loader_sql() -> None: """Test returning partial running sql for the sql file path.""" sql_path = Path("test_sql/eidastats.sql") loader_func = build_loader(sql_path) assert loader_func.args == (sql_path,) # type: ignore assert loader_func.func == sql # type: ignore + + +@pytest.mark.asyncio +async def test_loader_sql_async() -> None: + """Async test returning partial running sql_async for the sql file path.""" + sql_path = Path("test_sql/eidastats.sql") + loader_func = build_loader_async(sql_path) + assert loader_func.args == (sql_path,) # type: ignore + assert loader_func.func == sql_async # type: ignore diff --git a/tests/test_postgresql.py b/tests/test_postgresql.py index 1b86beaf..da5c5b2f 100644 --- a/tests/test_postgresql.py +++ b/tests/test_postgresql.py @@ -1,11 +1,14 @@ """All tests for pytest-postgresql.""" +import decimal + import pytest -from psycopg import Connection +from psycopg import AsyncConnection, Connection from psycopg.pq import ConnStatus from pytest_postgresql.executor import PostgreSQLExecutor -from pytest_postgresql.retry import retry +from pytest_postgresql.retry import retry, retry_async +from tests.conftest import POSTGRESQL_VERSION MAKE_Q = "CREATE TABLE test (id serial PRIMARY KEY, num integer, data varchar);" SELECT_Q = "SELECT * FROM test_load;" @@ -66,3 +69,59 @@ def check_if_one_connection() -> None: assert len(existing_connections) == 1, f"there is always only one connection, {existing_connections}" retry(check_if_one_connection, timeout=120, possible_exception=AssertionError) + + +@pytest.mark.asyncio +async def test_main_postgres_async(postgresql_async: AsyncConnection) -> None: + """Async check main postgresql fixture.""" + async with postgresql_async.cursor() as cur: + await cur.execute(MAKE_Q) + await postgresql_async.commit() + + +@pytest.mark.asyncio +async def test_two_postgreses_async(postgresql_async: AsyncConnection, postgresql2_async: AsyncConnection) -> None: + """Async check two postgresql fixtures on one test.""" + async with postgresql_async.cursor() as cur: + await cur.execute(MAKE_Q) + await postgresql_async.commit() + + async with postgresql2_async.cursor() as cur: + await cur.execute(MAKE_Q) + await postgresql2_async.commit() + + +@pytest.mark.asyncio +async def test_postgres_load_two_files_async(postgresql_load_1_async: AsyncConnection) -> None: + """Async check postgresql fixture can load two files.""" + async with postgresql_load_1_async.cursor() as cur: + await cur.execute(SELECT_Q) + results = await cur.fetchall() + assert len(results) == 2 + + +@pytest.mark.asyncio +async def test_rand_postgres_port_async(postgresql2_async: AsyncConnection) -> None: + """Async check if postgres fixture can be started on random port.""" + assert postgresql2_async.info.status == ConnStatus.OK + + +@pytest.mark.skipif( + decimal.Decimal(POSTGRESQL_VERSION) < 10, + reason="Test query not supported in those postgresql versions, and soon will not be supported.", +) +@pytest.mark.asyncio +@pytest.mark.parametrize("_", range(2)) +async def test_postgres_terminate_connection_async(postgresql2_async: AsyncConnection, _: int) -> None: + """Async test that connections are terminated between tests. + + And check that only one exists at a time. + """ + async with postgresql2_async.cursor() as cur: + + async def check_if_one_connection() -> None: + await cur.execute("SELECT * FROM pg_stat_activity WHERE backend_type = 'client backend';") + existing_connections = await cur.fetchall() + assert len(existing_connections) == 1, f"there is always only one connection, {existing_connections}" + + await retry_async(check_if_one_connection, timeout=120, possible_exception=AssertionError) diff --git a/tests/test_template_database.py b/tests/test_template_database.py index 64631779..fc64442e 100644 --- a/tests/test_template_database.py +++ b/tests/test_template_database.py @@ -1,9 +1,9 @@ """Template database tests.""" import pytest -from psycopg import Connection +from psycopg import AsyncConnection, Connection -from pytest_postgresql.factories import postgresql, postgresql_proc +from pytest_postgresql.factories import postgresql, postgresql_async, postgresql_proc from tests.loader import load_database postgresql_proc_with_template = postgresql_proc( @@ -17,6 +17,11 @@ dbname="stories_templated", ) +async_postgresql_template = postgresql_async( + "postgresql_proc_with_template", + dbname="stories_templated", +) + @pytest.mark.xdist_group(name="template_database") @pytest.mark.parametrize("_", range(5)) @@ -30,3 +35,18 @@ def test_template_database(postgresql_template: Connection, _: int) -> None: cur.execute("SELECT * FROM stories") res = cur.fetchall() assert len(res) == 0 + + +@pytest.mark.xdist_group(name="template_database_async") +@pytest.mark.asyncio +@pytest.mark.parametrize("_", range(5)) +async def test_template_database_async(async_postgresql_template: AsyncConnection, _: int) -> None: + """Async check that the database structure gets recreated out of a template.""" + async with async_postgresql_template.cursor() as cur: + await cur.execute("SELECT * FROM stories") + res = await cur.fetchall() + assert len(res) == 4 + await cur.execute("TRUNCATE stories") + await cur.execute("SELECT * FROM stories") + res = await cur.fetchall() + assert len(res) == 0 From 526ee6cdc2a59715c50c9ba56c8a3d4b7b10e72a Mon Sep 17 00:00:00 2001 From: tboy1337 Date: Fri, 13 Mar 2026 20:36:45 +0700 Subject: [PATCH 02/42] test: enhance async tests for Database Janitor and Loader - Added tests for custom database name handling in AsyncDatabaseJanitor. - Implemented tests for database initialization and dropping with various configurations. - Included tests for callable resolution in build_loader and build_loader_async with dot-separated paths. - Improved error handling tests for sql_async when aiofiles is not installed. --- tests/test_factory_errors.py | 15 ++++ tests/test_janitor.py | 160 ++++++++++++++++++++++++++++++++++- tests/test_loader.py | 27 ++++++ tests/test_retry.py | 54 ++++++++++++ 4 files changed, 255 insertions(+), 1 deletion(-) create mode 100644 tests/test_factory_errors.py create mode 100644 tests/test_retry.py diff --git a/tests/test_factory_errors.py b/tests/test_factory_errors.py new file mode 100644 index 00000000..fce26679 --- /dev/null +++ b/tests/test_factory_errors.py @@ -0,0 +1,15 @@ +"""Tests for factory error paths (missing optional dependencies).""" + +import sys +from unittest.mock import patch + +import pytest + + +def test_postgresql_async_raises_without_pytest_asyncio() -> None: + """postgresql_async() raises ImportError with a helpful message when pytest_asyncio is not installed.""" + with patch.dict(sys.modules, {"pytest_asyncio": None}): + from pytest_postgresql.factories.client import postgresql_async # noqa: PLC0415 + + with pytest.raises(ImportError, match="pytest-asyncio"): + postgresql_async("some_proc_fixture") diff --git a/tests/test_janitor.py b/tests/test_janitor.py index 95e4bc51..35c2a518 100644 --- a/tests/test_janitor.py +++ b/tests/test_janitor.py @@ -1,11 +1,13 @@ """Database Janitor tests.""" import sys -from typing import Any +from contextlib import asynccontextmanager +from typing import Any, AsyncIterator from unittest.mock import AsyncMock, MagicMock, patch import pytest from packaging.version import parse +from psycopg import AsyncCursor from pytest_postgresql.janitor import AsyncDatabaseJanitor, DatabaseJanitor @@ -85,6 +87,19 @@ async def test_cursor_connects_with_password_async() -> None: ) +@pytest.mark.asyncio +async def test_cursor_custom_dbname_async() -> None: + """Test that a custom dbname is forwarded to the connection in AsyncDatabaseJanitor.cursor.""" + conn_mock = _make_async_conn_mock() + connect_mock = AsyncMock(return_value=conn_mock) + with patch("pytest_postgresql.janitor.psycopg.AsyncConnection.connect", connect_mock): + janitor = AsyncDatabaseJanitor(user="user", host="host", port="1234", dbname="database_name", version=10) + async with janitor.cursor(dbname="custom_db"): + connect_mock.assert_called_once_with( + dbname="custom_db", user="user", password=None, host="host", port="1234" + ) + + @pytest.mark.skipif(sys.version_info < (3, 8), reason="Unittest call_args.kwargs was introduced since python 3.8") @pytest.mark.parametrize("load_database", ("tests.loader.load_database", "tests.loader:load_database")) @patch("pytest_postgresql.janitor.psycopg.connect") @@ -128,6 +143,149 @@ async def test_janitor_populate_async(connect_mock: MagicMock, load_database: st assert connect_mock.call_args.kwargs == call_kwargs +# --------------------------------------------------------------------------- +# AsyncDatabaseJanitor -- init() / drop() / helper method tests +# --------------------------------------------------------------------------- + + +def _make_cursor_mock() -> MagicMock: + """Create a mock async cursor that records execute() calls.""" + cur = AsyncMock(spec=AsyncCursor) + return cur + + +def _make_cursor_context(cur: AsyncMock) -> Any: + """Return an async context manager that yields the given cursor mock.""" + + @asynccontextmanager + async def _ctx(dbname: str = "postgres") -> AsyncIterator[AsyncMock]: + yield cur + + return _ctx + + +@pytest.mark.asyncio +async def test_async_janitor_init_creates_database() -> None: + """init() executes CREATE DATABASE with the configured dbname.""" + cur = _make_cursor_mock() + janitor = AsyncDatabaseJanitor(user="user", host="host", port="1234", dbname="mydb", version=10) + with patch.object(AsyncDatabaseJanitor, "cursor", _make_cursor_context(cur)): + await janitor.init() + + executed_sql = " ".join(str(c.args[0]) for c in cur.execute.call_args_list) + assert 'CREATE DATABASE "mydb"' in executed_sql + + +@pytest.mark.asyncio +async def test_async_janitor_init_with_template() -> None: + """init() uses TEMPLATE clause when template_dbname is set.""" + cur = _make_cursor_mock() + janitor = AsyncDatabaseJanitor( + user="user", host="host", port="1234", dbname="mydb", template_dbname="tmpl", version=10 + ) + with patch.object(AsyncDatabaseJanitor, "cursor", _make_cursor_context(cur)): + await janitor.init() + + executed_sql = " ".join(str(c.args[0]) for c in cur.execute.call_args_list) + assert 'CREATE DATABASE "mydb" TEMPLATE "tmpl"' in executed_sql + + +@pytest.mark.asyncio +async def test_async_janitor_init_as_template() -> None: + """init() appends IS_TEMPLATE = true when as_template is True.""" + cur = _make_cursor_mock() + janitor = AsyncDatabaseJanitor( + user="user", host="host", port="1234", dbname="mydb", as_template=True, version=10 + ) + with patch.object(AsyncDatabaseJanitor, "cursor", _make_cursor_context(cur)): + await janitor.init() + + executed_sql = " ".join(str(c.args[0]) for c in cur.execute.call_args_list) + assert "IS_TEMPLATE = true" in executed_sql + + +@pytest.mark.asyncio +async def test_async_janitor_drop_drops_database() -> None: + """drop() executes DROP DATABASE IF EXISTS for the configured dbname.""" + cur = _make_cursor_mock() + janitor = AsyncDatabaseJanitor(user="user", host="host", port="1234", dbname="mydb", version=10) + with patch.object(AsyncDatabaseJanitor, "cursor", _make_cursor_context(cur)): + await janitor.drop() + + executed_sql = " ".join(str(c.args[0]) for c in cur.execute.call_args_list) + assert 'DROP DATABASE IF EXISTS "mydb"' in executed_sql + + +@pytest.mark.asyncio +async def test_async_janitor_drop_as_template() -> None: + """drop() resets is_template before dropping when as_template is True.""" + cur = _make_cursor_mock() + janitor = AsyncDatabaseJanitor( + user="user", host="host", port="1234", dbname="mydb", as_template=True, version=10 + ) + with patch.object(AsyncDatabaseJanitor, "cursor", _make_cursor_context(cur)): + await janitor.drop() + + executed_sql = [str(c.args[0]) for c in cur.execute.call_args_list] + assert any("is_template false" in s for s in executed_sql) + assert any('DROP DATABASE IF EXISTS "mydb"' in s for s in executed_sql) + # is_template false must come before DROP + template_idx = next(i for i, s in enumerate(executed_sql) if "is_template false" in s) + drop_idx = next(i for i, s in enumerate(executed_sql) if "DROP DATABASE" in s) + assert template_idx < drop_idx + + +def test_async_janitor_is_template_false() -> None: + """is_template() returns False when as_template is not set.""" + janitor = AsyncDatabaseJanitor(user="user", host="host", port="1234", dbname="mydb", version=10) + assert janitor.is_template() is False + + +def test_async_janitor_is_template_true() -> None: + """is_template() returns True when as_template=True.""" + janitor = AsyncDatabaseJanitor( + user="user", host="host", port="1234", dbname="mydb", as_template=True, version=10 + ) + assert janitor.is_template() is True + + +@pytest.mark.asyncio +async def test_async_janitor_context_manager_calls_init_and_drop() -> None: + """__aenter__ calls init() and __aexit__ calls drop().""" + janitor = AsyncDatabaseJanitor(user="user", host="host", port="1234", dbname="mydb", version=10) + init_mock = AsyncMock() + drop_mock = AsyncMock() + with patch.object(AsyncDatabaseJanitor, "init", init_mock), patch.object(AsyncDatabaseJanitor, "drop", drop_mock): + async with janitor: + init_mock.assert_called_once() + drop_mock.assert_not_called() + drop_mock.assert_called_once() + + +@pytest.mark.asyncio +async def test_async_janitor_terminate_connection_sql() -> None: + """_terminate_connection() executes pg_terminate_backend query with correct dbname.""" + cur = AsyncMock(spec=AsyncCursor) + await AsyncDatabaseJanitor._terminate_connection(cur, "target_db") + + cur.execute.assert_called_once() + sql_str, params = cur.execute.call_args.args + assert "pg_terminate_backend" in sql_str + assert params == ("target_db",) + + +@pytest.mark.asyncio +async def test_async_janitor_dont_datallowconn_sql() -> None: + """_dont_datallowconn() executes ALTER DATABASE allow_connections false for the dbname.""" + cur = AsyncMock(spec=AsyncCursor) + await AsyncDatabaseJanitor._dont_datallowconn(cur, "target_db") + + cur.execute.assert_called_once() + sql_str = cur.execute.call_args.args[0] + assert "allow_connections false" in sql_str + assert '"target_db"' in sql_str + + def _make_async_conn_mock() -> MagicMock: """Create a MagicMock that behaves like a psycopg3 AsyncConnection.""" conn = MagicMock() diff --git a/tests/test_loader.py b/tests/test_loader.py index fe33d466..31c4407d 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -1,6 +1,7 @@ """Tests for the `build_loader` function.""" from pathlib import Path +from unittest.mock import patch import pytest @@ -14,6 +15,11 @@ def test_loader_callables() -> None: assert load_database == build_loader("tests.loader:load_database") +def test_loader_callables_dot_separator() -> None: + """Test dot-separated import path resolves the same callable as colon-separated.""" + assert build_loader("tests.loader.load_database") == load_database + + @pytest.mark.asyncio async def test_loader_callables_async() -> None: """Async test handling callables in build_loader_async.""" @@ -26,6 +32,12 @@ async def afun(*_args: object, **_kwargs: object) -> int: assert afun == build_loader_async(afun) +@pytest.mark.asyncio +async def test_loader_callables_async_dot_separator() -> None: + """Dot-separated import path is resolved identically by build_loader_async.""" + assert build_loader_async("tests.loader.load_database") == load_database + + def test_loader_sql() -> None: """Test returning partial running sql for the sql file path.""" sql_path = Path("test_sql/eidastats.sql") @@ -41,3 +53,18 @@ async def test_loader_sql_async() -> None: loader_func = build_loader_async(sql_path) assert loader_func.args == (sql_path,) # type: ignore assert loader_func.func == sql_async # type: ignore + + +@pytest.mark.asyncio +async def test_sql_async_raises_without_aiofiles() -> None: + """sql_async raises ImportError with a helpful message when aiofiles is not installed.""" + real_import = __builtins__.__import__ if hasattr(__builtins__, "__import__") else __import__ # type: ignore[union-attr] + + def _block_aiofiles(name: str, *args: object, **kwargs: object) -> object: + if name == "aiofiles": + raise ImportError("No module named 'aiofiles'") + return real_import(name, *args, **kwargs) # type: ignore[arg-type] + + with patch("builtins.__import__", side_effect=_block_aiofiles): + with pytest.raises(ImportError, match="aiofiles"): + await sql_async(Path("dummy.sql"), host="h", port=5432, user="u", dbname="d") diff --git a/tests/test_retry.py b/tests/test_retry.py new file mode 100644 index 00000000..1aea3cb5 --- /dev/null +++ b/tests/test_retry.py @@ -0,0 +1,54 @@ +"""Unit tests for retry and retry_async.""" + +import pytest + +from pytest_postgresql.retry import retry_async + + +@pytest.mark.asyncio +async def test_retry_async_immediate_success() -> None: + """Test that retry_async returns immediately when function succeeds on first call.""" + + async def ok() -> int: + return 42 + + assert await retry_async(ok, timeout=5) == 42 + + +@pytest.mark.asyncio +async def test_retry_async_succeeds_after_failures() -> None: + """Test that retry_async retries on the expected exception and returns on success.""" + attempts = 0 + + async def flaky() -> str: + nonlocal attempts + attempts += 1 + if attempts < 3: + raise ConnectionError("transient") + return "ok" + + result = await retry_async(flaky, timeout=10, possible_exception=ConnectionError) + assert result == "ok" + assert attempts == 3 + + +@pytest.mark.asyncio +async def test_retry_async_timeout() -> None: + """Test that retry_async raises TimeoutError after the timeout elapses.""" + + async def always_fail() -> None: + raise ValueError("boom") + + with pytest.raises(TimeoutError, match="Failed after"): + await retry_async(always_fail, timeout=1, possible_exception=ValueError) + + +@pytest.mark.asyncio +async def test_retry_async_unmatched_exception_propagates() -> None: + """Test that an exception not matching possible_exception propagates immediately.""" + + async def wrong_exc() -> None: + raise TypeError("unexpected") + + with pytest.raises(TypeError, match="unexpected"): + await retry_async(wrong_exc, timeout=5, possible_exception=ValueError) From 2258c819add2a21191ff6e40a2644ff63cc8c427 Mon Sep 17 00:00:00 2001 From: tboy1337 Date: Fri, 13 Mar 2026 20:50:26 +0700 Subject: [PATCH 03/42] fix: improve error handling for missing optional dependencies in async functions - Updated the loader and client to check for aiofiles and pytest_asyncio imports at runtime, raising ImportError with clear messages if they are not installed. - Refactored tests to mock the absence of these dependencies more effectively, ensuring proper error handling is validated. --- pytest_postgresql/factories/client.py | 11 +++++++---- pytest_postgresql/loader.py | 11 +++++++---- tests/test_factory_errors.py | 7 +++---- tests/test_loader.py | 9 +-------- 4 files changed, 18 insertions(+), 20 deletions(-) diff --git a/pytest_postgresql/factories/client.py b/pytest_postgresql/factories/client.py index ebdc75a5..0bd38c1b 100644 --- a/pytest_postgresql/factories/client.py +++ b/pytest_postgresql/factories/client.py @@ -24,6 +24,11 @@ from psycopg import AsyncConnection, Connection from pytest import FixtureRequest +try: + import pytest_asyncio +except ImportError: + pytest_asyncio = None # type: ignore[assignment] + from pytest_postgresql.config import get_config from pytest_postgresql.executor import PostgreSQLExecutor from pytest_postgresql.executor_noop import NoopExecutor @@ -105,13 +110,11 @@ def postgresql_async( :param scope: fixture scope; by default "function" which is recommended. :returns: function which makes an async connection to postgresql """ - try: - import pytest_asyncio - except ImportError as exc: + if pytest_asyncio is None: raise ImportError( "pytest-asyncio is required for async fixtures. " "Install it with: pip install pytest-postgresql[async]" - ) from exc + ) @pytest_asyncio.fixture(scope=scope) async def postgresql_async_factory(request: FixtureRequest) -> AsyncIterator[AsyncConnection]: diff --git a/pytest_postgresql/loader.py b/pytest_postgresql/loader.py index cd42b876..ab25a5d6 100644 --- a/pytest_postgresql/loader.py +++ b/pytest_postgresql/loader.py @@ -8,6 +8,11 @@ import psycopg +try: + import aiofiles +except ImportError: + aiofiles = None # type: ignore[assignment] + def build_loader(load: Callable | str | Path) -> Callable: """Build a loader callable.""" @@ -53,13 +58,11 @@ async def sql_async(sql_filename: Path, **kwargs: Any) -> None: Requires the optional ``async`` extra: ``pip install pytest-postgresql[async]``. """ - try: - import aiofiles - except ImportError as exc: + if aiofiles is None: raise ImportError( "aiofiles is required for async SQL loading. " "Install it with: pip install pytest-postgresql[async]" - ) from exc + ) async with await psycopg.AsyncConnection.connect(**kwargs) as db_connection: async with db_connection.cursor() as cur: diff --git a/tests/test_factory_errors.py b/tests/test_factory_errors.py index fce26679..238ae142 100644 --- a/tests/test_factory_errors.py +++ b/tests/test_factory_errors.py @@ -1,15 +1,14 @@ """Tests for factory error paths (missing optional dependencies).""" -import sys from unittest.mock import patch import pytest +from pytest_postgresql.factories.client import postgresql_async + def test_postgresql_async_raises_without_pytest_asyncio() -> None: """postgresql_async() raises ImportError with a helpful message when pytest_asyncio is not installed.""" - with patch.dict(sys.modules, {"pytest_asyncio": None}): - from pytest_postgresql.factories.client import postgresql_async # noqa: PLC0415 - + with patch("pytest_postgresql.factories.client.pytest_asyncio", None): with pytest.raises(ImportError, match="pytest-asyncio"): postgresql_async("some_proc_fixture") diff --git a/tests/test_loader.py b/tests/test_loader.py index 31c4407d..5d69cdbb 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -58,13 +58,6 @@ async def test_loader_sql_async() -> None: @pytest.mark.asyncio async def test_sql_async_raises_without_aiofiles() -> None: """sql_async raises ImportError with a helpful message when aiofiles is not installed.""" - real_import = __builtins__.__import__ if hasattr(__builtins__, "__import__") else __import__ # type: ignore[union-attr] - - def _block_aiofiles(name: str, *args: object, **kwargs: object) -> object: - if name == "aiofiles": - raise ImportError("No module named 'aiofiles'") - return real_import(name, *args, **kwargs) # type: ignore[arg-type] - - with patch("builtins.__import__", side_effect=_block_aiofiles): + with patch("pytest_postgresql.loader.aiofiles", None): with pytest.raises(ImportError, match="aiofiles"): await sql_async(Path("dummy.sql"), host="h", port=5432, user="u", dbname="d") From 5c92310ab28c9b818b117f0430ce0719d96c2a73 Mon Sep 17 00:00:00 2001 From: tboy1337 Date: Fri, 13 Mar 2026 21:15:03 +0700 Subject: [PATCH 04/42] fix: resolve double plugin registration, add asyncio_mode and default postgresql_async fixture Made-with: Cursor --- pyproject.toml | 5 +++-- pytest_postgresql/plugin.py | 5 +++++ tests/conftest.py | 7 ++++++- tests/test_postgresql.py | 1 + 4 files changed, 15 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 06e162b0..ee53acc0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,12 +62,13 @@ include = ["pytest_postgresql*"] exclude = ["tests*"] namespaces = false -[tool.pytest] +[tool.pytest.ini_options] strict_xfail=true -addopts = ["--showlocals", "--verbose", "--cov"] +addopts = ["--showlocals", "--verbose"] testpaths = ["tests"] pytester_example_dir = "tests/examples" norecursedirs = ["examples"] +asyncio_mode = "auto" [tool.ruff] line-length = 120 diff --git a/pytest_postgresql/plugin.py b/pytest_postgresql/plugin.py index 612e408a..704c3bf7 100644 --- a/pytest_postgresql/plugin.py +++ b/pytest_postgresql/plugin.py @@ -135,3 +135,8 @@ def pytest_addoption(parser: Parser) -> None: postgresql_proc = factories.postgresql_proc() postgresql_noproc = factories.postgresql_noproc() postgresql = factories.postgresql("postgresql_proc") + +try: + postgresql_async = factories.postgresql_async("postgresql_proc") +except ImportError: + pass diff --git a/tests/conftest.py b/tests/conftest.py index 483437af..b8f2725d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,7 +4,12 @@ from pathlib import Path from pytest_postgresql import factories -from pytest_postgresql.plugin import * # noqa: F403,F401 +from pytest_postgresql.plugin import postgresql, postgresql_noproc, postgresql_proc + +try: + from pytest_postgresql.plugin import postgresql_async # noqa: F401 +except ImportError: + pass pytest_plugins = ["pytester"] POSTGRESQL_VERSION = os.environ.get("POSTGRES", "13") diff --git a/tests/test_postgresql.py b/tests/test_postgresql.py index da5c5b2f..bb17e228 100644 --- a/tests/test_postgresql.py +++ b/tests/test_postgresql.py @@ -111,6 +111,7 @@ async def test_rand_postgres_port_async(postgresql2_async: AsyncConnection) -> N reason="Test query not supported in those postgresql versions, and soon will not be supported.", ) @pytest.mark.asyncio +@pytest.mark.xdist_group(name="terminate_connection") @pytest.mark.parametrize("_", range(2)) async def test_postgres_terminate_connection_async(postgresql2_async: AsyncConnection, _: int) -> None: """Async test that connections are terminated between tests. From ea71a077388897c7d3b85d9972f5ac1cddb85d3a Mon Sep 17 00:00:00 2001 From: tboy1337 Date: Fri, 13 Mar 2026 21:17:00 +0700 Subject: [PATCH 05/42] Revert "fix: resolve double plugin registration, add asyncio_mode and default postgresql_async fixture" This reverts commit 5c92310ab28c9b818b117f0430ce0719d96c2a73. --- pyproject.toml | 5 ++--- pytest_postgresql/plugin.py | 5 ----- tests/conftest.py | 7 +------ tests/test_postgresql.py | 1 - 4 files changed, 3 insertions(+), 15 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ee53acc0..06e162b0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,13 +62,12 @@ include = ["pytest_postgresql*"] exclude = ["tests*"] namespaces = false -[tool.pytest.ini_options] +[tool.pytest] strict_xfail=true -addopts = ["--showlocals", "--verbose"] +addopts = ["--showlocals", "--verbose", "--cov"] testpaths = ["tests"] pytester_example_dir = "tests/examples" norecursedirs = ["examples"] -asyncio_mode = "auto" [tool.ruff] line-length = 120 diff --git a/pytest_postgresql/plugin.py b/pytest_postgresql/plugin.py index 704c3bf7..612e408a 100644 --- a/pytest_postgresql/plugin.py +++ b/pytest_postgresql/plugin.py @@ -135,8 +135,3 @@ def pytest_addoption(parser: Parser) -> None: postgresql_proc = factories.postgresql_proc() postgresql_noproc = factories.postgresql_noproc() postgresql = factories.postgresql("postgresql_proc") - -try: - postgresql_async = factories.postgresql_async("postgresql_proc") -except ImportError: - pass diff --git a/tests/conftest.py b/tests/conftest.py index b8f2725d..483437af 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,12 +4,7 @@ from pathlib import Path from pytest_postgresql import factories -from pytest_postgresql.plugin import postgresql, postgresql_noproc, postgresql_proc - -try: - from pytest_postgresql.plugin import postgresql_async # noqa: F401 -except ImportError: - pass +from pytest_postgresql.plugin import * # noqa: F403,F401 pytest_plugins = ["pytester"] POSTGRESQL_VERSION = os.environ.get("POSTGRES", "13") diff --git a/tests/test_postgresql.py b/tests/test_postgresql.py index bb17e228..da5c5b2f 100644 --- a/tests/test_postgresql.py +++ b/tests/test_postgresql.py @@ -111,7 +111,6 @@ async def test_rand_postgres_port_async(postgresql2_async: AsyncConnection) -> N reason="Test query not supported in those postgresql versions, and soon will not be supported.", ) @pytest.mark.asyncio -@pytest.mark.xdist_group(name="terminate_connection") @pytest.mark.parametrize("_", range(2)) async def test_postgres_terminate_connection_async(postgresql2_async: AsyncConnection, _: int) -> None: """Async test that connections are terminated between tests. From 990496a83d63adfc7a432b2e6b8f7bbf097f16c5 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 13 Mar 2026 14:22:10 +0000 Subject: [PATCH 06/42] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- pytest_postgresql/factories/client.py | 3 +-- pytest_postgresql/loader.py | 3 +-- tests/test_janitor.py | 12 +++--------- 3 files changed, 5 insertions(+), 13 deletions(-) diff --git a/pytest_postgresql/factories/client.py b/pytest_postgresql/factories/client.py index 0bd38c1b..4d451204 100644 --- a/pytest_postgresql/factories/client.py +++ b/pytest_postgresql/factories/client.py @@ -112,8 +112,7 @@ def postgresql_async( """ if pytest_asyncio is None: raise ImportError( - "pytest-asyncio is required for async fixtures. " - "Install it with: pip install pytest-postgresql[async]" + "pytest-asyncio is required for async fixtures. Install it with: pip install pytest-postgresql[async]" ) @pytest_asyncio.fixture(scope=scope) diff --git a/pytest_postgresql/loader.py b/pytest_postgresql/loader.py index ab25a5d6..63f025ba 100644 --- a/pytest_postgresql/loader.py +++ b/pytest_postgresql/loader.py @@ -60,8 +60,7 @@ async def sql_async(sql_filename: Path, **kwargs: Any) -> None: """ if aiofiles is None: raise ImportError( - "aiofiles is required for async SQL loading. " - "Install it with: pip install pytest-postgresql[async]" + "aiofiles is required for async SQL loading. Install it with: pip install pytest-postgresql[async]" ) async with await psycopg.AsyncConnection.connect(**kwargs) as db_connection: diff --git a/tests/test_janitor.py b/tests/test_janitor.py index 35c2a518..a416bf48 100644 --- a/tests/test_janitor.py +++ b/tests/test_janitor.py @@ -194,9 +194,7 @@ async def test_async_janitor_init_with_template() -> None: async def test_async_janitor_init_as_template() -> None: """init() appends IS_TEMPLATE = true when as_template is True.""" cur = _make_cursor_mock() - janitor = AsyncDatabaseJanitor( - user="user", host="host", port="1234", dbname="mydb", as_template=True, version=10 - ) + janitor = AsyncDatabaseJanitor(user="user", host="host", port="1234", dbname="mydb", as_template=True, version=10) with patch.object(AsyncDatabaseJanitor, "cursor", _make_cursor_context(cur)): await janitor.init() @@ -220,9 +218,7 @@ async def test_async_janitor_drop_drops_database() -> None: async def test_async_janitor_drop_as_template() -> None: """drop() resets is_template before dropping when as_template is True.""" cur = _make_cursor_mock() - janitor = AsyncDatabaseJanitor( - user="user", host="host", port="1234", dbname="mydb", as_template=True, version=10 - ) + janitor = AsyncDatabaseJanitor(user="user", host="host", port="1234", dbname="mydb", as_template=True, version=10) with patch.object(AsyncDatabaseJanitor, "cursor", _make_cursor_context(cur)): await janitor.drop() @@ -243,9 +239,7 @@ def test_async_janitor_is_template_false() -> None: def test_async_janitor_is_template_true() -> None: """is_template() returns True when as_template=True.""" - janitor = AsyncDatabaseJanitor( - user="user", host="host", port="1234", dbname="mydb", as_template=True, version=10 - ) + janitor = AsyncDatabaseJanitor(user="user", host="host", port="1234", dbname="mydb", as_template=True, version=10) assert janitor.is_template() is True From fe8c553ddf8c74acd92df0c01b096c38ed16b40f Mon Sep 17 00:00:00 2001 From: tboy1337 Date: Sat, 14 Mar 2026 03:14:19 +0700 Subject: [PATCH 07/42] fix: enhance async fixture and retry tests - Updated the postgresql_async fixture to include loop_scope for better async handling. - Added xdist_group marker to the test_postgres_terminate_connection_async for improved test organization. - Refactored retry tests to mock sleep and current time, ensuring accurate timeout handling and retry logic validation. --- pytest_postgresql/factories/client.py | 2 +- tests/test_postgresql.py | 1 + tests/test_retry.py | 27 ++++++++++++++++++++++++--- 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/pytest_postgresql/factories/client.py b/pytest_postgresql/factories/client.py index 0bd38c1b..29af20fa 100644 --- a/pytest_postgresql/factories/client.py +++ b/pytest_postgresql/factories/client.py @@ -116,7 +116,7 @@ def postgresql_async( "Install it with: pip install pytest-postgresql[async]" ) - @pytest_asyncio.fixture(scope=scope) + @pytest_asyncio.fixture(scope=scope, loop_scope=scope) async def postgresql_async_factory(request: FixtureRequest) -> AsyncIterator[AsyncConnection]: """Async connection fixture factory for PostgreSQL. diff --git a/tests/test_postgresql.py b/tests/test_postgresql.py index da5c5b2f..1461694d 100644 --- a/tests/test_postgresql.py +++ b/tests/test_postgresql.py @@ -110,6 +110,7 @@ async def test_rand_postgres_port_async(postgresql2_async: AsyncConnection) -> N decimal.Decimal(POSTGRESQL_VERSION) < 10, reason="Test query not supported in those postgresql versions, and soon will not be supported.", ) +@pytest.mark.xdist_group(name="terminate_connection") @pytest.mark.asyncio @pytest.mark.parametrize("_", range(2)) async def test_postgres_terminate_connection_async(postgresql2_async: AsyncConnection, _: int) -> None: diff --git a/tests/test_retry.py b/tests/test_retry.py index 1aea3cb5..8581331d 100644 --- a/tests/test_retry.py +++ b/tests/test_retry.py @@ -1,5 +1,8 @@ """Unit tests for retry and retry_async.""" +import datetime +from unittest.mock import AsyncMock, patch + import pytest from pytest_postgresql.retry import retry_async @@ -27,9 +30,13 @@ async def flaky() -> str: raise ConnectionError("transient") return "ok" - result = await retry_async(flaky, timeout=10, possible_exception=ConnectionError) + sleep_mock = AsyncMock() + with patch("pytest_postgresql.retry.asyncio.sleep", sleep_mock): + result = await retry_async(flaky, timeout=10, possible_exception=ConnectionError) + assert result == "ok" assert attempts == 3 + assert sleep_mock.call_count == 2 @pytest.mark.asyncio @@ -39,8 +46,22 @@ async def test_retry_async_timeout() -> None: async def always_fail() -> None: raise ValueError("boom") - with pytest.raises(TimeoutError, match="Failed after"): - await retry_async(always_fail, timeout=1, possible_exception=ValueError) + sleep_mock = AsyncMock() + base = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc) + call_count = 0 + + def advancing_clock() -> datetime.datetime: + nonlocal call_count + call_count += 1 + # First call captures starting time; all subsequent calls report past the timeout. + return base if call_count == 1 else base + datetime.timedelta(seconds=10) + + with ( + patch("pytest_postgresql.retry.asyncio.sleep", sleep_mock), + patch("pytest_postgresql.retry.get_current_datetime", advancing_clock), + ): + with pytest.raises(TimeoutError, match="Failed after"): + await retry_async(always_fail, timeout=1, possible_exception=ValueError) @pytest.mark.asyncio From f16583ad898657036d76d0692cfb2d156477125f Mon Sep 17 00:00:00 2001 From: tboy1337 Date: Sat, 14 Mar 2026 03:20:31 +0700 Subject: [PATCH 08/42] refactor: update connection handling in AsyncDatabaseJanitor - Changed isolation level and autocommit settings to direct attribute assignment instead of using methods. - Removed unnecessary mock methods in test for improved clarity and performance. --- pytest_postgresql/janitor.py | 4 ++-- tests/test_janitor.py | 2 -- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/pytest_postgresql/janitor.py b/pytest_postgresql/janitor.py index ccb10d41..dabe7ecf 100644 --- a/pytest_postgresql/janitor.py +++ b/pytest_postgresql/janitor.py @@ -294,8 +294,8 @@ async def connect() -> psycopg.AsyncConnection: conn = await retry_async(connect, timeout=self._connection_timeout, possible_exception=psycopg.OperationalError) try: - await conn.set_isolation_level(self.isolation_level) - await conn.set_autocommit(True) + conn.isolation_level = self.isolation_level + conn.autocommit = True # We must not run a transaction since we create a database. async with conn.cursor() as cur: yield cur diff --git a/tests/test_janitor.py b/tests/test_janitor.py index a416bf48..91c62341 100644 --- a/tests/test_janitor.py +++ b/tests/test_janitor.py @@ -283,8 +283,6 @@ async def test_async_janitor_dont_datallowconn_sql() -> None: def _make_async_conn_mock() -> MagicMock: """Create a MagicMock that behaves like a psycopg3 AsyncConnection.""" conn = MagicMock() - conn.set_isolation_level = AsyncMock() - conn.set_autocommit = AsyncMock() conn.close = AsyncMock() cursor_mock = MagicMock() cursor_mock.__aenter__ = AsyncMock(return_value=MagicMock()) From 38a5ae4c0628c5377d7b1adf84a46968909ac494 Mon Sep 17 00:00:00 2001 From: tboy1337 Date: Sat, 14 Mar 2026 03:28:22 +0700 Subject: [PATCH 09/42] fix: add space in SQL query for connection termination - Added a space at the end of the SQL query string in both DatabaseJanitor and AsyncDatabaseJanitor to ensure proper formatting. --- pytest_postgresql/janitor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pytest_postgresql/janitor.py b/pytest_postgresql/janitor.py index dabe7ecf..ff7087f9 100644 --- a/pytest_postgresql/janitor.py +++ b/pytest_postgresql/janitor.py @@ -104,7 +104,7 @@ def _dont_datallowconn(cur: Cursor, dbname: str) -> None: @staticmethod def _terminate_connection(cur: Cursor, dbname: str) -> None: cur.execute( - "SELECT pg_terminate_backend(pg_stat_activity.pid)" + "SELECT pg_terminate_backend(pg_stat_activity.pid) " "FROM pg_stat_activity " "WHERE pg_stat_activity.datname = %s;", (dbname,), @@ -252,7 +252,7 @@ async def _dont_datallowconn(cur: AsyncCursor, dbname: str) -> None: # type: ig @staticmethod async def _terminate_connection(cur: AsyncCursor, dbname: str) -> None: # type: ignore[type-arg] await cur.execute( - "SELECT pg_terminate_backend(pg_stat_activity.pid)" + "SELECT pg_terminate_backend(pg_stat_activity.pid) " "FROM pg_stat_activity " "WHERE pg_stat_activity.datname = %s;", (dbname,), From bdca11e8af1f7367dcacf66b0fd5bab53fd5bd88 Mon Sep 17 00:00:00 2001 From: tboy1337 Date: Sat, 14 Mar 2026 11:21:13 +0700 Subject: [PATCH 10/42] refactor: improve PostgreSQLExecutor and AsyncDatabaseJanitor handling - Updated PostgreSQLExecutor to conditionally omit Unix socket parameters on Windows. - Enhanced error handling in PostgreSQLExecutor for Windows compatibility. - Refactored AsyncDatabaseJanitor to use async methods for setting isolation level and autocommit. - Updated tests to mock async connection methods for better compatibility. --- pytest_postgresql/executor.py | 16 +++++++++++++--- pytest_postgresql/janitor.py | 4 ++-- pytest_postgresql/plugin.py | 1 + tests/conftest.py | 14 ++++++++++++++ tests/test_janitor.py | 2 ++ 5 files changed, 32 insertions(+), 5 deletions(-) diff --git a/pytest_postgresql/executor.py b/pytest_postgresql/executor.py index ef027739..504071e6 100644 --- a/pytest_postgresql/executor.py +++ b/pytest_postgresql/executor.py @@ -50,9 +50,9 @@ class PostgreSQLExecutor(TCPExecutor): BASE_PROC_START_COMMAND = ( '{executable} start -D "{datadir}" ' - "-o \"-F -p {port} -c log_destination='stderr' " + "-o \"-F -p {port} -c log_destination=stderr " "-c logging_collector=off " - "-c unix_socket_directories='{unixsocketdir}' {postgres_options}\" " + "{unix_socket_opt}{postgres_options}\" " '-l "{logfile}" {startparams}' ) @@ -108,11 +108,17 @@ def __init__( self.logfile = logfile self.startparams = startparams self.postgres_options = postgres_options + # On Windows, Unix sockets are not supported; omit the parameter entirely. + unix_socket_opt = ( + f"-c unix_socket_directories={self.unixsocketdir} " + if platform.system() != "Windows" + else "" + ) command = self.BASE_PROC_START_COMMAND.format( executable=self.executable, datadir=self.datadir, port=port, - unixsocketdir=self.unixsocketdir, + unix_socket_opt=unix_socket_opt, logfile=self.logfile, startparams=self.startparams, postgres_options=self.postgres_options, @@ -230,6 +236,10 @@ def stop(self: T, sig: Optional[int] = None, exp_sig: Optional[int] = None) -> T except ProcessFinishedWithError: # Finished, leftovers ought to be cleaned afterwards anyway pass + except AttributeError: + # os.killpg is not available on Windows; the pg_ctl stop above + # already terminated the process, so this is safe to ignore. + pass return self def __del__(self) -> None: diff --git a/pytest_postgresql/janitor.py b/pytest_postgresql/janitor.py index ff7087f9..e85232e2 100644 --- a/pytest_postgresql/janitor.py +++ b/pytest_postgresql/janitor.py @@ -294,8 +294,8 @@ async def connect() -> psycopg.AsyncConnection: conn = await retry_async(connect, timeout=self._connection_timeout, possible_exception=psycopg.OperationalError) try: - conn.isolation_level = self.isolation_level - conn.autocommit = True + await conn.set_isolation_level(self.isolation_level) + await conn.set_autocommit(True) # We must not run a transaction since we create a database. async with conn.cursor() as cur: yield cur diff --git a/pytest_postgresql/plugin.py b/pytest_postgresql/plugin.py index 612e408a..5fa7b58c 100644 --- a/pytest_postgresql/plugin.py +++ b/pytest_postgresql/plugin.py @@ -135,3 +135,4 @@ def pytest_addoption(parser: Parser) -> None: postgresql_proc = factories.postgresql_proc() postgresql_noproc = factories.postgresql_noproc() postgresql = factories.postgresql("postgresql_proc") +postgresql_async = factories.postgresql_async("postgresql_proc") diff --git a/tests/conftest.py b/tests/conftest.py index 483437af..7c2623c7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,12 +1,26 @@ """Tests main conftest file.""" +import asyncio import os +import sys +import warnings from pathlib import Path +import pytest from pytest_postgresql import factories from pytest_postgresql.plugin import * # noqa: F403,F401 pytest_plugins = ["pytester"] + + +@pytest.fixture(scope="session") +def event_loop_policy(): # type: ignore[override] + """Use SelectorEventLoop on Windows; psycopg3 async requires it.""" + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + if sys.platform == "win32": + return asyncio.WindowsSelectorEventLoopPolicy() + return asyncio.DefaultEventLoopPolicy() POSTGRESQL_VERSION = os.environ.get("POSTGRES", "13") diff --git a/tests/test_janitor.py b/tests/test_janitor.py index 91c62341..3f042f63 100644 --- a/tests/test_janitor.py +++ b/tests/test_janitor.py @@ -284,6 +284,8 @@ def _make_async_conn_mock() -> MagicMock: """Create a MagicMock that behaves like a psycopg3 AsyncConnection.""" conn = MagicMock() conn.close = AsyncMock() + conn.set_isolation_level = AsyncMock() + conn.set_autocommit = AsyncMock() cursor_mock = MagicMock() cursor_mock.__aenter__ = AsyncMock(return_value=MagicMock()) cursor_mock.__aexit__ = AsyncMock(return_value=False) From 2267c0013c4a88495c3414ef90a9861ff29bd032 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 14 Mar 2026 04:21:30 +0000 Subject: [PATCH 11/42] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- pytest_postgresql/executor.py | 10 +++------- tests/conftest.py | 3 +++ 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/pytest_postgresql/executor.py b/pytest_postgresql/executor.py index 504071e6..10a3902b 100644 --- a/pytest_postgresql/executor.py +++ b/pytest_postgresql/executor.py @@ -50,9 +50,9 @@ class PostgreSQLExecutor(TCPExecutor): BASE_PROC_START_COMMAND = ( '{executable} start -D "{datadir}" ' - "-o \"-F -p {port} -c log_destination=stderr " + '-o "-F -p {port} -c log_destination=stderr ' "-c logging_collector=off " - "{unix_socket_opt}{postgres_options}\" " + '{unix_socket_opt}{postgres_options}" ' '-l "{logfile}" {startparams}' ) @@ -109,11 +109,7 @@ def __init__( self.startparams = startparams self.postgres_options = postgres_options # On Windows, Unix sockets are not supported; omit the parameter entirely. - unix_socket_opt = ( - f"-c unix_socket_directories={self.unixsocketdir} " - if platform.system() != "Windows" - else "" - ) + unix_socket_opt = f"-c unix_socket_directories={self.unixsocketdir} " if platform.system() != "Windows" else "" command = self.BASE_PROC_START_COMMAND.format( executable=self.executable, datadir=self.datadir, diff --git a/tests/conftest.py b/tests/conftest.py index 7c2623c7..47c45058 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,6 +7,7 @@ from pathlib import Path import pytest + from pytest_postgresql import factories from pytest_postgresql.plugin import * # noqa: F403,F401 @@ -21,6 +22,8 @@ def event_loop_policy(): # type: ignore[override] if sys.platform == "win32": return asyncio.WindowsSelectorEventLoopPolicy() return asyncio.DefaultEventLoopPolicy() + + POSTGRESQL_VERSION = os.environ.get("POSTGRES", "13") From 717e81f096e7e75c8cc74e9a79974a831a8f279b Mon Sep 17 00:00:00 2001 From: tboy1337 Date: Sat, 14 Mar 2026 11:26:25 +0700 Subject: [PATCH 12/42] revert: remove Windows-specific changes deferred to PR-1182 Remove Windows executor compatibility changes (log_destination quoting, unix_socket_directories platform guard, os.killpg AttributeError catch) and the Windows-only event_loop_policy fixture from conftest.py. These belong in the separate Windows compatibility PR #1182. Made-with: Cursor --- pytest_postgresql/executor.py | 16 +++------------- tests/conftest.py | 14 -------------- 2 files changed, 3 insertions(+), 27 deletions(-) diff --git a/pytest_postgresql/executor.py b/pytest_postgresql/executor.py index 504071e6..ef027739 100644 --- a/pytest_postgresql/executor.py +++ b/pytest_postgresql/executor.py @@ -50,9 +50,9 @@ class PostgreSQLExecutor(TCPExecutor): BASE_PROC_START_COMMAND = ( '{executable} start -D "{datadir}" ' - "-o \"-F -p {port} -c log_destination=stderr " + "-o \"-F -p {port} -c log_destination='stderr' " "-c logging_collector=off " - "{unix_socket_opt}{postgres_options}\" " + "-c unix_socket_directories='{unixsocketdir}' {postgres_options}\" " '-l "{logfile}" {startparams}' ) @@ -108,17 +108,11 @@ def __init__( self.logfile = logfile self.startparams = startparams self.postgres_options = postgres_options - # On Windows, Unix sockets are not supported; omit the parameter entirely. - unix_socket_opt = ( - f"-c unix_socket_directories={self.unixsocketdir} " - if platform.system() != "Windows" - else "" - ) command = self.BASE_PROC_START_COMMAND.format( executable=self.executable, datadir=self.datadir, port=port, - unix_socket_opt=unix_socket_opt, + unixsocketdir=self.unixsocketdir, logfile=self.logfile, startparams=self.startparams, postgres_options=self.postgres_options, @@ -236,10 +230,6 @@ def stop(self: T, sig: Optional[int] = None, exp_sig: Optional[int] = None) -> T except ProcessFinishedWithError: # Finished, leftovers ought to be cleaned afterwards anyway pass - except AttributeError: - # os.killpg is not available on Windows; the pg_ctl stop above - # already terminated the process, so this is safe to ignore. - pass return self def __del__(self) -> None: diff --git a/tests/conftest.py b/tests/conftest.py index 7c2623c7..483437af 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,26 +1,12 @@ """Tests main conftest file.""" -import asyncio import os -import sys -import warnings from pathlib import Path -import pytest from pytest_postgresql import factories from pytest_postgresql.plugin import * # noqa: F403,F401 pytest_plugins = ["pytester"] - - -@pytest.fixture(scope="session") -def event_loop_policy(): # type: ignore[override] - """Use SelectorEventLoop on Windows; psycopg3 async requires it.""" - with warnings.catch_warnings(): - warnings.simplefilter("ignore", DeprecationWarning) - if sys.platform == "win32": - return asyncio.WindowsSelectorEventLoopPolicy() - return asyncio.DefaultEventLoopPolicy() POSTGRESQL_VERSION = os.environ.get("POSTGRES", "13") From 83c7baeedf3fa97fad4f8bff1c3f695de42431b4 Mon Sep 17 00:00:00 2001 From: tboy1337 Date: Sat, 14 Mar 2026 11:53:58 +0700 Subject: [PATCH 13/42] refactor: enhance SQL query handling in DatabaseJanitor and AsyncDatabaseJanitor - Updated SQL query construction to use psycopg.sql for better safety and readability. - Refactored database creation, alteration, and dropping methods to utilize composable SQL objects. - Improved test assertions by rendering SQL commands for clarity in test outputs. --- pytest_postgresql/janitor.py | 35 +++++++++++++++++++---------------- tests/test_janitor.py | 25 +++++++++++++++++++------ 2 files changed, 38 insertions(+), 22 deletions(-) diff --git a/pytest_postgresql/janitor.py b/pytest_postgresql/janitor.py index e85232e2..0d1a0ecc 100644 --- a/pytest_postgresql/janitor.py +++ b/pytest_postgresql/janitor.py @@ -7,6 +7,7 @@ from typing import AsyncIterator, Callable, Iterator, Type, TypeVar import psycopg +import psycopg.sql as sql from packaging.version import parse from psycopg import AsyncCursor, Connection, Cursor @@ -69,18 +70,17 @@ def __init__( def init(self) -> None: """Create database in postgresql.""" with self.cursor() as cur: + query = sql.SQL("CREATE DATABASE {}").format(sql.Identifier(self.dbname)) if self.template_dbname: # And make sure no-one is left connected to the template database. # Otherwise, Creating database from template will fail self._terminate_connection(cur, self.template_dbname) - query = f'CREATE DATABASE "{self.dbname}" TEMPLATE "{self.template_dbname}"' - else: - query = f'CREATE DATABASE "{self.dbname}"' + query = query + sql.SQL(" TEMPLATE {}").format(sql.Identifier(self.template_dbname)) if self.as_template: - query += " IS_TEMPLATE = true" + query = query + sql.SQL(" IS_TEMPLATE = true") - cur.execute(f"{query};") + cur.execute(query) def is_template(self) -> bool: """Determine whether the DatabaseJanitor maintains template or database.""" @@ -94,12 +94,14 @@ def drop(self) -> None: self._dont_datallowconn(cur, self.dbname) self._terminate_connection(cur, self.dbname) if self.as_template: - cur.execute(f'ALTER DATABASE "{self.dbname}" with is_template false;') - cur.execute(f'DROP DATABASE IF EXISTS "{self.dbname}";') + cur.execute( + sql.SQL("ALTER DATABASE {} WITH is_template false").format(sql.Identifier(self.dbname)) + ) + cur.execute(sql.SQL("DROP DATABASE IF EXISTS {}").format(sql.Identifier(self.dbname))) @staticmethod def _dont_datallowconn(cur: Cursor, dbname: str) -> None: - cur.execute(f'ALTER DATABASE "{dbname}" with allow_connections false;') + cur.execute(sql.SQL("ALTER DATABASE {} WITH allow_connections false").format(sql.Identifier(dbname))) @staticmethod def _terminate_connection(cur: Cursor, dbname: str) -> None: @@ -217,18 +219,17 @@ def __init__( async def init(self) -> None: """Create database in postgresql.""" async with self.cursor() as cur: + query = sql.SQL("CREATE DATABASE {}").format(sql.Identifier(self.dbname)) if self.template_dbname: # And make sure no-one is left connected to the template database. # Otherwise, Creating database from template will fail await self._terminate_connection(cur, self.template_dbname) - query = f'CREATE DATABASE "{self.dbname}" TEMPLATE "{self.template_dbname}"' - else: - query = f'CREATE DATABASE "{self.dbname}"' + query = query + sql.SQL(" TEMPLATE {}").format(sql.Identifier(self.template_dbname)) if self.as_template: - query += " IS_TEMPLATE = true" + query = query + sql.SQL(" IS_TEMPLATE = true") - await cur.execute(f"{query};") + await cur.execute(query) def is_template(self) -> bool: """Determine whether the AsyncDatabaseJanitor maintains template or database.""" @@ -242,12 +243,14 @@ async def drop(self) -> None: await self._dont_datallowconn(cur, self.dbname) await self._terminate_connection(cur, self.dbname) if self.as_template: - await cur.execute(f'ALTER DATABASE "{self.dbname}" with is_template false;') - await cur.execute(f'DROP DATABASE IF EXISTS "{self.dbname}";') + await cur.execute( + sql.SQL("ALTER DATABASE {} WITH is_template false").format(sql.Identifier(self.dbname)) + ) + await cur.execute(sql.SQL("DROP DATABASE IF EXISTS {}").format(sql.Identifier(self.dbname))) @staticmethod async def _dont_datallowconn(cur: AsyncCursor, dbname: str) -> None: # type: ignore[type-arg] - await cur.execute(f'ALTER DATABASE "{dbname}" with allow_connections false;') + await cur.execute(sql.SQL("ALTER DATABASE {} WITH allow_connections false").format(sql.Identifier(dbname))) @staticmethod async def _terminate_connection(cur: AsyncCursor, dbname: str) -> None: # type: ignore[type-arg] diff --git a/tests/test_janitor.py b/tests/test_janitor.py index 3f042f63..9390d9e7 100644 --- a/tests/test_janitor.py +++ b/tests/test_janitor.py @@ -5,6 +5,7 @@ from typing import Any, AsyncIterator from unittest.mock import AsyncMock, MagicMock, patch +import psycopg.sql as pgsql import pytest from packaging.version import parse from psycopg import AsyncCursor @@ -148,6 +149,18 @@ async def test_janitor_populate_async(connect_mock: MagicMock, load_database: st # --------------------------------------------------------------------------- +def _render_sql(obj: object) -> str: + """Render a psycopg.sql Composable to its SQL text form for test assertions.""" + if isinstance(obj, pgsql.Composed): + return "".join(_render_sql(part) for part in obj) + if isinstance(obj, pgsql.SQL): + return obj._obj # type: ignore[attr-defined] + if isinstance(obj, pgsql.Identifier): + parts: tuple[str, ...] = obj._obj # type: ignore[attr-defined] + return ".".join('"' + s.replace('"', '""') + '"' for s in parts) + return str(obj) + + def _make_cursor_mock() -> MagicMock: """Create a mock async cursor that records execute() calls.""" cur = AsyncMock(spec=AsyncCursor) @@ -172,7 +185,7 @@ async def test_async_janitor_init_creates_database() -> None: with patch.object(AsyncDatabaseJanitor, "cursor", _make_cursor_context(cur)): await janitor.init() - executed_sql = " ".join(str(c.args[0]) for c in cur.execute.call_args_list) + executed_sql = " ".join(_render_sql(c.args[0]) for c in cur.execute.call_args_list) assert 'CREATE DATABASE "mydb"' in executed_sql @@ -186,7 +199,7 @@ async def test_async_janitor_init_with_template() -> None: with patch.object(AsyncDatabaseJanitor, "cursor", _make_cursor_context(cur)): await janitor.init() - executed_sql = " ".join(str(c.args[0]) for c in cur.execute.call_args_list) + executed_sql = " ".join(_render_sql(c.args[0]) for c in cur.execute.call_args_list) assert 'CREATE DATABASE "mydb" TEMPLATE "tmpl"' in executed_sql @@ -198,7 +211,7 @@ async def test_async_janitor_init_as_template() -> None: with patch.object(AsyncDatabaseJanitor, "cursor", _make_cursor_context(cur)): await janitor.init() - executed_sql = " ".join(str(c.args[0]) for c in cur.execute.call_args_list) + executed_sql = " ".join(_render_sql(c.args[0]) for c in cur.execute.call_args_list) assert "IS_TEMPLATE = true" in executed_sql @@ -210,7 +223,7 @@ async def test_async_janitor_drop_drops_database() -> None: with patch.object(AsyncDatabaseJanitor, "cursor", _make_cursor_context(cur)): await janitor.drop() - executed_sql = " ".join(str(c.args[0]) for c in cur.execute.call_args_list) + executed_sql = " ".join(_render_sql(c.args[0]) for c in cur.execute.call_args_list) assert 'DROP DATABASE IF EXISTS "mydb"' in executed_sql @@ -222,7 +235,7 @@ async def test_async_janitor_drop_as_template() -> None: with patch.object(AsyncDatabaseJanitor, "cursor", _make_cursor_context(cur)): await janitor.drop() - executed_sql = [str(c.args[0]) for c in cur.execute.call_args_list] + executed_sql = [_render_sql(c.args[0]) for c in cur.execute.call_args_list] assert any("is_template false" in s for s in executed_sql) assert any('DROP DATABASE IF EXISTS "mydb"' in s for s in executed_sql) # is_template false must come before DROP @@ -275,7 +288,7 @@ async def test_async_janitor_dont_datallowconn_sql() -> None: await AsyncDatabaseJanitor._dont_datallowconn(cur, "target_db") cur.execute.assert_called_once() - sql_str = cur.execute.call_args.args[0] + sql_str = _render_sql(cur.execute.call_args.args[0]) assert "allow_connections false" in sql_str assert '"target_db"' in sql_str From e9911ea3585e076b52760c393d85ff60f959a41f Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 14 Mar 2026 04:54:14 +0000 Subject: [PATCH 14/42] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- pytest_postgresql/janitor.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/pytest_postgresql/janitor.py b/pytest_postgresql/janitor.py index 0d1a0ecc..146f4dd0 100644 --- a/pytest_postgresql/janitor.py +++ b/pytest_postgresql/janitor.py @@ -94,9 +94,7 @@ def drop(self) -> None: self._dont_datallowconn(cur, self.dbname) self._terminate_connection(cur, self.dbname) if self.as_template: - cur.execute( - sql.SQL("ALTER DATABASE {} WITH is_template false").format(sql.Identifier(self.dbname)) - ) + cur.execute(sql.SQL("ALTER DATABASE {} WITH is_template false").format(sql.Identifier(self.dbname))) cur.execute(sql.SQL("DROP DATABASE IF EXISTS {}").format(sql.Identifier(self.dbname))) @staticmethod From c23b364e160116a34575770fc1b201c2da794b4e Mon Sep 17 00:00:00 2001 From: tboy1337 Date: Sat, 14 Mar 2026 12:06:41 +0700 Subject: [PATCH 15/42] fix: improve error handling in postgresql_async fixture - Updated the postgresql_async fixture to conditionally use pytest_asyncio if available, enhancing compatibility with synchronous fixtures. - Added tests to ensure that the fixture does not raise errors at creation time when pytest_asyncio is absent, while still raising an ImportError during usage without pytest_asyncio. --- pytest_postgresql/factories/client.py | 15 +++++++++----- tests/test_factory_errors.py | 28 +++++++++++++++++++++++---- 2 files changed, 34 insertions(+), 9 deletions(-) diff --git a/pytest_postgresql/factories/client.py b/pytest_postgresql/factories/client.py index 8fa9f4f8..0c51bfae 100644 --- a/pytest_postgresql/factories/client.py +++ b/pytest_postgresql/factories/client.py @@ -110,18 +110,23 @@ def postgresql_async( :param scope: fixture scope; by default "function" which is recommended. :returns: function which makes an async connection to postgresql """ - if pytest_asyncio is None: - raise ImportError( - "pytest-asyncio is required for async fixtures. Install it with: pip install pytest-postgresql[async]" - ) + if pytest_asyncio is not None: + fixture_decorator = pytest_asyncio.fixture(scope=scope, loop_scope=scope) + else: + fixture_decorator = pytest.fixture(scope=scope) - @pytest_asyncio.fixture(scope=scope, loop_scope=scope) + @fixture_decorator async def postgresql_async_factory(request: FixtureRequest) -> AsyncIterator[AsyncConnection]: """Async connection fixture factory for PostgreSQL. :param request: fixture request object :returns: postgresql async client """ + if pytest_asyncio is None: + raise ImportError( + "pytest-asyncio is required for async fixtures. " + "Install it with: pip install pytest-postgresql[async]" + ) proc_fixture: PostgreSQLExecutor | NoopExecutor = request.getfixturevalue(process_fixture_name) config = get_config(request) diff --git a/tests/test_factory_errors.py b/tests/test_factory_errors.py index 238ae142..17cb437f 100644 --- a/tests/test_factory_errors.py +++ b/tests/test_factory_errors.py @@ -1,5 +1,6 @@ """Tests for factory error paths (missing optional dependencies).""" +import asyncio from unittest.mock import patch import pytest @@ -7,8 +8,27 @@ from pytest_postgresql.factories.client import postgresql_async -def test_postgresql_async_raises_without_pytest_asyncio() -> None: - """postgresql_async() raises ImportError with a helpful message when pytest_asyncio is not installed.""" +def test_postgresql_async_factory_creation_succeeds_without_pytest_asyncio() -> None: + """postgresql_async() must not raise at factory-creation time when pytest-asyncio is absent. + + The plugin registers ``postgresql_async`` at load time (plugin.py), so raising here + would break all users — including those who only use synchronous fixtures. + """ with patch("pytest_postgresql.factories.client.pytest_asyncio", None): - with pytest.raises(ImportError, match="pytest-asyncio"): - postgresql_async("some_proc_fixture") + fixture_func = postgresql_async("some_proc_fixture") + assert callable(fixture_func) + + +def test_postgresql_async_raises_on_use_without_pytest_asyncio() -> None: + """The fixture body raises ImportError with a helpful message when pytest-asyncio is absent.""" + + async def _invoke() -> None: + with patch("pytest_postgresql.factories.client.pytest_asyncio", None): + fixture_func = postgresql_async("some_proc_fixture") + # pytest 8+ wraps fixtures to prevent direct calls; unwrap first. + raw_func = getattr(fixture_func, "__wrapped__", fixture_func) + async for _ in raw_func(None): # type: ignore[arg-type] + break # pragma: no cover + + with pytest.raises(ImportError, match="pytest-asyncio"): + asyncio.run(_invoke()) From bfbff7555278c5af2a513457ee983bfc48847934 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 14 Mar 2026 05:08:06 +0000 Subject: [PATCH 16/42] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- pytest_postgresql/factories/client.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pytest_postgresql/factories/client.py b/pytest_postgresql/factories/client.py index 0c51bfae..2ae5a45a 100644 --- a/pytest_postgresql/factories/client.py +++ b/pytest_postgresql/factories/client.py @@ -124,8 +124,7 @@ async def postgresql_async_factory(request: FixtureRequest) -> AsyncIterator[Asy """ if pytest_asyncio is None: raise ImportError( - "pytest-asyncio is required for async fixtures. " - "Install it with: pip install pytest-postgresql[async]" + "pytest-asyncio is required for async fixtures. Install it with: pip install pytest-postgresql[async]" ) proc_fixture: PostgreSQLExecutor | NoopExecutor = request.getfixturevalue(process_fixture_name) config = get_config(request) From e2d10f2adba99a38e22dbae8f5dd93d5ad782138 Mon Sep 17 00:00:00 2001 From: tboy1337 Date: Sat, 14 Mar 2026 12:25:23 +0700 Subject: [PATCH 17/42] fix: enhance postgresql_async fixture to provide synchronous stub when pytest-asyncio is absent - Updated the postgresql_async fixture to return a synchronous stub that raises ImportError if pytest-asyncio is not available, preventing coroutine-related warnings. - Modified tests to verify that the synchronous stub is correctly registered and raises the appropriate error when used without pytest-asyncio. --- pytest_postgresql/factories/client.py | 21 +++++++++++---------- tests/test_factory_errors.py | 25 ++++++++++++------------- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/pytest_postgresql/factories/client.py b/pytest_postgresql/factories/client.py index 0c51bfae..09ef6128 100644 --- a/pytest_postgresql/factories/client.py +++ b/pytest_postgresql/factories/client.py @@ -110,23 +110,24 @@ def postgresql_async( :param scope: fixture scope; by default "function" which is recommended. :returns: function which makes an async connection to postgresql """ - if pytest_asyncio is not None: - fixture_decorator = pytest_asyncio.fixture(scope=scope, loop_scope=scope) - else: - fixture_decorator = pytest.fixture(scope=scope) + if pytest_asyncio is None: + @pytest.fixture(scope=scope) + def postgresql_async_factory(request: FixtureRequest) -> None: + """Sync stub that raises ImportError when pytest-asyncio is absent.""" + raise ImportError( + "pytest-asyncio is required for async fixtures. " + "Install it with: pip install pytest-postgresql[async]" + ) + + return postgresql_async_factory # type: ignore[return-value] - @fixture_decorator + @pytest_asyncio.fixture(scope=scope, loop_scope=scope) async def postgresql_async_factory(request: FixtureRequest) -> AsyncIterator[AsyncConnection]: """Async connection fixture factory for PostgreSQL. :param request: fixture request object :returns: postgresql async client """ - if pytest_asyncio is None: - raise ImportError( - "pytest-asyncio is required for async fixtures. " - "Install it with: pip install pytest-postgresql[async]" - ) proc_fixture: PostgreSQLExecutor | NoopExecutor = request.getfixturevalue(process_fixture_name) config = get_config(request) diff --git a/tests/test_factory_errors.py b/tests/test_factory_errors.py index 17cb437f..18216693 100644 --- a/tests/test_factory_errors.py +++ b/tests/test_factory_errors.py @@ -1,6 +1,5 @@ """Tests for factory error paths (missing optional dependencies).""" -import asyncio from unittest.mock import patch import pytest @@ -20,15 +19,15 @@ def test_postgresql_async_factory_creation_succeeds_without_pytest_asyncio() -> def test_postgresql_async_raises_on_use_without_pytest_asyncio() -> None: - """The fixture body raises ImportError with a helpful message when pytest-asyncio is absent.""" - - async def _invoke() -> None: - with patch("pytest_postgresql.factories.client.pytest_asyncio", None): - fixture_func = postgresql_async("some_proc_fixture") - # pytest 8+ wraps fixtures to prevent direct calls; unwrap first. - raw_func = getattr(fixture_func, "__wrapped__", fixture_func) - async for _ in raw_func(None): # type: ignore[arg-type] - break # pragma: no cover - - with pytest.raises(ImportError, match="pytest-asyncio"): - asyncio.run(_invoke()) + """When pytest-asyncio is absent, the registered stub is synchronous and raises ImportError. + + A synchronous stub avoids the "coroutine was never awaited" warning that would + result from registering an async def with plain pytest.fixture. + """ + with patch("pytest_postgresql.factories.client.pytest_asyncio", None): + fixture_func = postgresql_async("some_proc_fixture") + # pytest 8+ wraps fixtures to prevent direct calls; unwrap first. + raw_func = getattr(fixture_func, "__wrapped__", fixture_func) + assert not hasattr(raw_func, "__await__"), "stub must be a sync function, not a coroutine" + with pytest.raises(ImportError, match="pytest-asyncio"): + raw_func(None) # type: ignore[arg-type] From b40ac9d12db0e8ca60a453c52808998a7da97c7e Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 14 Mar 2026 05:27:38 +0000 Subject: [PATCH 18/42] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- pytest_postgresql/factories/client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pytest_postgresql/factories/client.py b/pytest_postgresql/factories/client.py index 09ef6128..26a701d1 100644 --- a/pytest_postgresql/factories/client.py +++ b/pytest_postgresql/factories/client.py @@ -111,12 +111,12 @@ def postgresql_async( :returns: function which makes an async connection to postgresql """ if pytest_asyncio is None: + @pytest.fixture(scope=scope) def postgresql_async_factory(request: FixtureRequest) -> None: """Sync stub that raises ImportError when pytest-asyncio is absent.""" raise ImportError( - "pytest-asyncio is required for async fixtures. " - "Install it with: pip install pytest-postgresql[async]" + "pytest-asyncio is required for async fixtures. Install it with: pip install pytest-postgresql[async]" ) return postgresql_async_factory # type: ignore[return-value] From 257e61d7688e6de65064f2938fa45d2fed2b2891 Mon Sep 17 00:00:00 2001 From: tboy1337 Date: Sun, 21 Jun 2026 19:03:27 +0700 Subject: [PATCH 19/42] feat: add async testing support and improve documentation - Added `pytest-asyncio` and `aiofiles` as development dependencies for async testing. - Updated README to include instructions for async tests with `psycopg.AsyncConnection`. - Enhanced documentation for async fixtures and their usage. - Improved error handling in the `postgresql_async` fixture to provide a synchronous stub when `pytest-asyncio` is absent. - Added tests to verify async functionality and ensure proper teardown of resources. --- Pipfile | 3 ++ README.rst | 35 +++++++++++++- pytest_postgresql/factories/client.py | 31 ++++++++---- pytest_postgresql/factories/process.py | 65 ++++++++++++++------------ pytest_postgresql/janitor.py | 6 +-- tests/test_executor.py | 51 ++++++++++++++++++++ tests/test_janitor.py | 26 +++++++++-- tests/test_retry.py | 7 ++- 8 files changed, 174 insertions(+), 50 deletions(-) diff --git a/Pipfile b/Pipfile index 799dc292..c7c39ee4 100644 --- a/Pipfile +++ b/Pipfile @@ -13,6 +13,9 @@ psycopg = "==3.3.3" [dev-packages] towncrier = "==25.8.0" psycopg-binary = {version = "==3.3.3", markers="implementation_name == 'cpython'"} +pytest-asyncio = ">=0.21" +aiofiles = ">=23.0" +types-aiofiles = ">=23.0" pytest-cov = "==7.0.0" pytest-xdist = "==3.8.0" mock = "==5.2.0" diff --git a/README.rst b/README.rst index 02c57c56..f974559c 100644 --- a/README.rst +++ b/README.rst @@ -38,6 +38,14 @@ Quick Start You will also need to install ``psycopg`` (version 3). See `its installation instructions `_. + For async tests with ``psycopg.AsyncConnection``, install the optional async extra: + + .. code-block:: sh + + pip install pytest-postgresql[async] + + This pulls in ``pytest-asyncio`` and ``aiofiles``. + .. note:: While this plugin requires ``psycopg`` 3 to manage the database, your application code can still use ``psycopg`` 2. @@ -54,6 +62,21 @@ Quick Start cur.execute("CREATE TABLE test (id serial PRIMARY KEY, num integer, data varchar);") postgresql.commit() + For async code, use ``postgresql_async`` with ``pytest.mark.asyncio``: + + .. code-block:: python + + import pytest + + @pytest.mark.asyncio + async def test_example_async(postgresql_async): + """Check main async postgresql fixture.""" + async with postgresql_async.cursor() as cur: + await cur.execute( + "CREATE TABLE test (id serial PRIMARY KEY, num integer, data varchar);" + ) + await postgresql_async.commit() + How to use ========== @@ -73,13 +96,15 @@ The plugin provides two main types of fixtures: **1. Client Fixtures** These provide a connection to a database for your tests. - * **postgresql** - A function-scoped fixture. It returns a connected ``psycopg.Connection``. + * **postgresql** - A function-scoped fixture (by default). It returns a connected ``psycopg.Connection``. After each test, it terminates leftover connections and drops the test database to ensure isolation. + * **postgresql_async** - The async counterpart. It returns a connected ``psycopg.AsyncConnection``. + Requires ``pytest-postgresql[async]`` and ``@pytest.mark.asyncio`` on your tests. **2. Process Fixtures** These manage the PostgreSQL server lifecycle. - * **postgresql_proc** - A session-scoped fixture that starts a PostgreSQL instance on its first use and stops it when all tests are finished. + * **postgresql_proc** - A session-scoped fixture (by default) that starts a PostgreSQL instance on its first use and stops it when all tests are finished. * **postgresql_noproc** - A fixture for connecting to an already running PostgreSQL instance (e.g., in Docker or CI). Customizing Fixtures @@ -98,6 +123,12 @@ You can create additional fixtures using factories: # Create a client fixture that uses the custom process postgresql_my = factories.postgresql('postgresql_my_proc') + # Async client fixture (requires pytest-postgresql[async]) + postgresql_my_async = factories.postgresql_async('postgresql_my_proc') + +All factories accept an optional ``scope`` parameter (``"session"``, ``"package"``, ``"module"``, ``"class"``, or ``"function"``). +Defaults are unchanged: ``"function"`` for client fixtures and ``"session"`` for process fixtures. + .. note:: Each process fixture can be configured independently through factory arguments. diff --git a/pytest_postgresql/factories/client.py b/pytest_postgresql/factories/client.py index 26a701d1..e4cd2dbf 100644 --- a/pytest_postgresql/factories/client.py +++ b/pytest_postgresql/factories/client.py @@ -17,24 +17,27 @@ # along with pytest-postgresql. If not, see . """Fixture factory for postgresql client.""" -from typing import AsyncIterator, Callable, Iterator +from typing import Any, AsyncIterator, Callable, Iterator, cast import psycopg import pytest from psycopg import AsyncConnection, Connection from pytest import FixtureRequest -try: - import pytest_asyncio -except ImportError: - pytest_asyncio = None # type: ignore[assignment] - from pytest_postgresql.config import get_config from pytest_postgresql.executor import PostgreSQLExecutor from pytest_postgresql.executor_noop import NoopExecutor from pytest_postgresql.janitor import AsyncDatabaseJanitor, DatabaseJanitor from pytest_postgresql.types import FixtureScopeT +pytest_asyncio: Any = None +try: + import pytest_asyncio as _pytest_asyncio_module + + pytest_asyncio = _pytest_asyncio_module +except ImportError: + pass + def postgresql( process_fixture_name: str, @@ -113,15 +116,20 @@ def postgresql_async( if pytest_asyncio is None: @pytest.fixture(scope=scope) - def postgresql_async_factory(request: FixtureRequest) -> None: + def postgresql_async_stub(request: FixtureRequest) -> None: """Sync stub that raises ImportError when pytest-asyncio is absent.""" raise ImportError( "pytest-asyncio is required for async fixtures. Install it with: pip install pytest-postgresql[async]" ) - return postgresql_async_factory # type: ignore[return-value] + return cast( + Callable[[FixtureRequest], AsyncIterator[AsyncConnection]], + postgresql_async_stub, + ) + + assert pytest_asyncio is not None - @pytest_asyncio.fixture(scope=scope, loop_scope=scope) + @pytest_asyncio.fixture(scope=scope, loop_scope=scope) # type: ignore[untyped-decorator] async def postgresql_async_factory(request: FixtureRequest) -> AsyncIterator[AsyncConnection]: """Async connection fixture factory for PostgreSQL. @@ -161,4 +169,7 @@ async def postgresql_async_factory(request: FixtureRequest) -> AsyncIterator[Asy yield db_connection await db_connection.close() - return postgresql_async_factory + return cast( + Callable[[FixtureRequest], AsyncIterator[AsyncConnection]], + postgresql_async_factory, + ) diff --git a/pytest_postgresql/factories/process.py b/pytest_postgresql/factories/process.py index cbfc0111..f9d32394 100644 --- a/pytest_postgresql/factories/process.py +++ b/pytest_postgresql/factories/process.py @@ -152,37 +152,40 @@ def postgresql_proc_fixture( tmpdir = tmp_path_factory.mktemp(f"pytest-postgresql-{request.fixturename}") datadir, logfile_path = _prepare_dir(tmpdir, str(pg_port)) - postgresql_executor = PostgreSQLExecutor( - executable=postgresql_ctl, - host=host or config.host, - port=pg_port, - user=user or config.user, - password=password or config.password, - dbname=pg_dbname, - options=options or config.options, - datadir=str(datadir), - unixsocketdir=unixsocketdir or config.unixsocketdir, - logfile=str(logfile_path), - startparams=startparams or config.startparams, - postgres_options=postgres_options or config.postgres_options, - ) - # start server - with postgresql_executor: - postgresql_executor.wait_for_postgres() - janitor = DatabaseJanitor( - user=postgresql_executor.user, - host=postgresql_executor.host, - port=postgresql_executor.port, - dbname=postgresql_executor.template_dbname, - as_template=True, - version=postgresql_executor.version, - password=postgresql_executor.password, + try: + postgresql_executor = PostgreSQLExecutor( + executable=postgresql_ctl, + host=host or config.host, + port=pg_port, + user=user or config.user, + password=password or config.password, + dbname=pg_dbname, + options=options or config.options, + datadir=str(datadir), + unixsocketdir=unixsocketdir or config.unixsocketdir, + logfile=str(logfile_path), + startparams=startparams or config.startparams, + postgres_options=postgres_options or config.postgres_options, ) - if config.drop_test_database: - janitor.drop() - with janitor: - for load_element in pg_load: - janitor.load(load_element) - yield postgresql_executor + # start server + with postgresql_executor: + postgresql_executor.wait_for_postgres() + janitor = DatabaseJanitor( + user=postgresql_executor.user, + host=postgresql_executor.host, + port=postgresql_executor.port, + dbname=postgresql_executor.template_dbname, + as_template=True, + version=postgresql_executor.version, + password=postgresql_executor.password, + ) + if config.drop_test_database: + janitor.drop() + with janitor: + for load_element in pg_load: + janitor.load(load_element) + yield postgresql_executor + finally: + port_filename_path.unlink(missing_ok=True) return postgresql_proc_fixture diff --git a/pytest_postgresql/janitor.py b/pytest_postgresql/janitor.py index 146f4dd0..be3d631c 100644 --- a/pytest_postgresql/janitor.py +++ b/pytest_postgresql/janitor.py @@ -247,11 +247,11 @@ async def drop(self) -> None: await cur.execute(sql.SQL("DROP DATABASE IF EXISTS {}").format(sql.Identifier(self.dbname))) @staticmethod - async def _dont_datallowconn(cur: AsyncCursor, dbname: str) -> None: # type: ignore[type-arg] + async def _dont_datallowconn(cur: AsyncCursor, dbname: str) -> None: await cur.execute(sql.SQL("ALTER DATABASE {} WITH allow_connections false").format(sql.Identifier(dbname))) @staticmethod - async def _terminate_connection(cur: AsyncCursor, dbname: str) -> None: # type: ignore[type-arg] + async def _terminate_connection(cur: AsyncCursor, dbname: str) -> None: await cur.execute( "SELECT pg_terminate_backend(pg_stat_activity.pid) " "FROM pg_stat_activity " @@ -281,7 +281,7 @@ async def load(self, load: Callable | str | Path) -> None: await result @asynccontextmanager - async def cursor(self, dbname: str = "postgres") -> AsyncIterator[AsyncCursor]: # type: ignore[type-arg] + async def cursor(self, dbname: str = "postgres") -> AsyncIterator[AsyncCursor]: """Return postgresql async cursor.""" async def connect() -> psycopg.AsyncConnection: diff --git a/tests/test_executor.py b/tests/test_executor.py index 25c6aa24..3dae6943 100644 --- a/tests/test_executor.py +++ b/tests/test_executor.py @@ -1,6 +1,7 @@ """Test various executor behaviours.""" from typing import Any +from unittest.mock import MagicMock, patch import psycopg import pytest @@ -173,3 +174,53 @@ def test_custom_isolation_level(postgres_isolation_level: Connection) -> None: cur = postgres_isolation_level.cursor() cur.execute("SELECT 1") assert cur.fetchone() == (1,) + + +def test_postgresql_proc_removes_port_lock_on_teardown( + request: FixtureRequest, + tmp_path_factory: pytest.TempPathFactory, +) -> None: + """Port sentinel file is removed when the process fixture tears down.""" + fixture_func = postgresql_proc(port=None, scope="function") + raw_func = getattr(fixture_func, "__wrapped__", fixture_func) + + port_path = tmp_path_factory.getbasetemp() + pg_port = 54321 + + executor_mock = MagicMock() + executor_mock.__enter__ = MagicMock(return_value=executor_mock) + executor_mock.__exit__ = MagicMock(return_value=False) + executor_mock.user = "postgres" + executor_mock.host = "127.0.0.1" + executor_mock.port = pg_port + executor_mock.template_dbname = "template_tests" + executor_mock.version = 14 + executor_mock.password = None + executor_mock.wait_for_postgres = MagicMock() + + janitor_mock = MagicMock() + janitor_mock.__enter__ = MagicMock(return_value=janitor_mock) + janitor_mock.__exit__ = MagicMock(return_value=False) + + with ( + patch("pytest_postgresql.factories.process._pg_exe", return_value="/usr/bin/pg_ctl"), + patch("pytest_postgresql.factories.process._pg_port", return_value=pg_port), + patch("pytest_postgresql.factories.process.PostgreSQLExecutor", return_value=executor_mock), + patch("pytest_postgresql.factories.process.DatabaseJanitor", return_value=janitor_mock), + patch("pytest_postgresql.factories.process.get_config") as get_config_mock, + ): + config_mock = MagicMock() + config_mock.dbname = "tests" + config_mock.load = [] + config_mock.drop_test_database = False + config_mock.port_search_count = 5 + get_config_mock.return_value = config_mock + + gen = raw_func(request, tmp_path_factory) + next(gen) + port_file = port_path / f"postgresql-{pg_port}.port" + assert port_file.exists() + with pytest.raises(StopIteration): + next(gen) + + assert not port_file.exists() diff --git a/tests/test_janitor.py b/tests/test_janitor.py index 9390d9e7..c960db02 100644 --- a/tests/test_janitor.py +++ b/tests/test_janitor.py @@ -144,6 +144,26 @@ async def test_janitor_populate_async(connect_mock: MagicMock, load_database: st assert connect_mock.call_args.kwargs == call_kwargs +@pytest.mark.asyncio +async def test_janitor_populate_async_awaitable_loader() -> None: + """AsyncDatabaseJanitor.load awaits async loader callables.""" + call_kwargs = { + "host": "host", + "port": "1234", + "user": "user", + "dbname": "database_name", + "password": "some_password", # noqa: S106 + } + loader_mock = AsyncMock() + + async def async_loader(**kwargs: object) -> None: + await loader_mock(**kwargs) + + janitor = AsyncDatabaseJanitor(version=10, **call_kwargs) # type: ignore[arg-type] + await janitor.load(async_loader) + loader_mock.assert_awaited_once_with(**call_kwargs) + + # --------------------------------------------------------------------------- # AsyncDatabaseJanitor -- init() / drop() / helper method tests # --------------------------------------------------------------------------- @@ -154,9 +174,9 @@ def _render_sql(obj: object) -> str: if isinstance(obj, pgsql.Composed): return "".join(_render_sql(part) for part in obj) if isinstance(obj, pgsql.SQL): - return obj._obj # type: ignore[attr-defined] + return obj._obj if isinstance(obj, pgsql.Identifier): - parts: tuple[str, ...] = obj._obj # type: ignore[attr-defined] + parts = tuple(obj._obj) return ".".join('"' + s.replace('"', '""') + '"' for s in parts) return str(obj) @@ -171,7 +191,7 @@ def _make_cursor_context(cur: AsyncMock) -> Any: """Return an async context manager that yields the given cursor mock.""" @asynccontextmanager - async def _ctx(dbname: str = "postgres") -> AsyncIterator[AsyncMock]: + async def _ctx(self: AsyncDatabaseJanitor, dbname: str = "postgres") -> AsyncIterator[AsyncMock]: yield cur return _ctx diff --git a/tests/test_retry.py b/tests/test_retry.py index 8581331d..64debe85 100644 --- a/tests/test_retry.py +++ b/tests/test_retry.py @@ -46,6 +46,7 @@ async def test_retry_async_timeout() -> None: async def always_fail() -> None: raise ValueError("boom") + always_fail_mock = AsyncMock(side_effect=ValueError("boom")) sleep_mock = AsyncMock() base = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc) call_count = 0 @@ -61,7 +62,11 @@ def advancing_clock() -> datetime.datetime: patch("pytest_postgresql.retry.get_current_datetime", advancing_clock), ): with pytest.raises(TimeoutError, match="Failed after"): - await retry_async(always_fail, timeout=1, possible_exception=ValueError) + await retry_async(always_fail_mock, timeout=1, possible_exception=ValueError) + + sleep_mock.assert_not_awaited() + assert always_fail_mock.await_count == 1 + assert call_count == 2 @pytest.mark.asyncio From 16b8ce37aae331239b556ef41c546f683d00c1b2 Mon Sep 17 00:00:00 2001 From: tboy1337 Date: Sun, 21 Jun 2026 19:33:06 +0700 Subject: [PATCH 20/42] refactor: streamline temporary directory creation in postgresql_proc and enhance SQL rendering in tests - Moved the temporary directory creation and data directory preparation to the try block in the postgresql_proc function for better error handling. - Updated the SQL rendering function to utilize the as_string method for pgsql.Composable objects, simplifying the code and improving readability. --- pytest_postgresql/factories/process.py | 6 +++--- tests/test_janitor.py | 9 ++------- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/pytest_postgresql/factories/process.py b/pytest_postgresql/factories/process.py index f9d32394..dd30ff61 100644 --- a/pytest_postgresql/factories/process.py +++ b/pytest_postgresql/factories/process.py @@ -149,10 +149,10 @@ def postgresql_proc_fixture( ) n += 1 - tmpdir = tmp_path_factory.mktemp(f"pytest-postgresql-{request.fixturename}") - datadir, logfile_path = _prepare_dir(tmpdir, str(pg_port)) - try: + tmpdir = tmp_path_factory.mktemp(f"pytest-postgresql-{request.fixturename}") + datadir, logfile_path = _prepare_dir(tmpdir, str(pg_port)) + postgresql_executor = PostgreSQLExecutor( executable=postgresql_ctl, host=host or config.host, diff --git a/tests/test_janitor.py b/tests/test_janitor.py index c960db02..f732b404 100644 --- a/tests/test_janitor.py +++ b/tests/test_janitor.py @@ -171,13 +171,8 @@ async def async_loader(**kwargs: object) -> None: def _render_sql(obj: object) -> str: """Render a psycopg.sql Composable to its SQL text form for test assertions.""" - if isinstance(obj, pgsql.Composed): - return "".join(_render_sql(part) for part in obj) - if isinstance(obj, pgsql.SQL): - return obj._obj - if isinstance(obj, pgsql.Identifier): - parts = tuple(obj._obj) - return ".".join('"' + s.replace('"', '""') + '"' for s in parts) + if isinstance(obj, pgsql.Composable): + return obj.as_string(None) return str(obj) From bab9403382a19192c5d9f776da732701e7ad9b33 Mon Sep 17 00:00:00 2001 From: tboy1337 Date: Sun, 21 Jun 2026 19:55:27 +0700 Subject: [PATCH 21/42] Fix xdist CI failures and raise patch coverage for async changes. Use the shared basetemp parent under xdist for port-lock tests, align async template tests with the sync xdist group, and add unit tests for sql_async, drop_test_database, and setup-failure port cleanup. Co-authored-by: Cursor --- tests/test_executor.py | 36 +++++++++++++++++++++++ tests/test_factory_errors.py | 52 ++++++++++++++++++++++++++++++++- tests/test_loader.py | 39 ++++++++++++++++++++++++- tests/test_retry.py | 3 -- tests/test_template_database.py | 2 +- 5 files changed, 126 insertions(+), 6 deletions(-) diff --git a/tests/test_executor.py b/tests/test_executor.py index bd3f249d..8474a9be 100644 --- a/tests/test_executor.py +++ b/tests/test_executor.py @@ -302,6 +302,8 @@ def test_postgresql_proc_removes_port_lock_on_teardown( raw_func = getattr(fixture_func, "__wrapped__", fixture_func) port_path = tmp_path_factory.getbasetemp() + if hasattr(request.config, "workerinput"): + port_path = tmp_path_factory.getbasetemp().parent pg_port = 54321 executor_mock = MagicMock() @@ -343,6 +345,40 @@ def test_postgresql_proc_removes_port_lock_on_teardown( assert not port_file.exists() +def test_postgresql_proc_removes_port_lock_on_setup_failure( + request: FixtureRequest, + tmp_path_factory: pytest.TempPathFactory, +) -> None: + """Port sentinel file is removed when fixture setup fails after claiming a port.""" + fixture_func = postgresql_proc(port=None, scope="function") + raw_func = getattr(fixture_func, "__wrapped__", fixture_func) + + port_path = tmp_path_factory.getbasetemp() + if hasattr(request.config, "workerinput"): + port_path = tmp_path_factory.getbasetemp().parent + pg_port = 54322 + + with ( + patch("pytest_postgresql.factories.process._pg_exe", return_value="/usr/bin/pg_ctl"), + patch("pytest_postgresql.factories.process._pg_port", return_value=pg_port), + patch("pytest_postgresql.factories.process.get_config") as get_config_mock, + patch.object(tmp_path_factory, "mktemp", side_effect=OSError("setup failed")), + ): + config_mock = MagicMock() + config_mock.dbname = "tests" + config_mock.load = [] + config_mock.drop_test_database = False + config_mock.port_search_count = 5 + get_config_mock.return_value = config_mock + + gen = raw_func(request, tmp_path_factory) + with pytest.raises(OSError, match="setup failed"): + next(gen) + + port_file = port_path / f"postgresql-{pg_port}.port" + assert not port_file.exists() + + @pytest.mark.skipif(platform.system() != "Windows", reason="Windows-specific test") def test_actual_postgresql_start_windows( request: FixtureRequest, diff --git a/tests/test_factory_errors.py b/tests/test_factory_errors.py index 18216693..583f48d4 100644 --- a/tests/test_factory_errors.py +++ b/tests/test_factory_errors.py @@ -1,6 +1,6 @@ """Tests for factory error paths (missing optional dependencies).""" -from unittest.mock import patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -31,3 +31,53 @@ def test_postgresql_async_raises_on_use_without_pytest_asyncio() -> None: assert not hasattr(raw_func, "__await__"), "stub must be a sync function, not a coroutine" with pytest.raises(ImportError, match="pytest-asyncio"): raw_func(None) # type: ignore[arg-type] + + +@pytest.mark.asyncio +async def test_postgresql_async_drops_database_when_configured() -> None: + """Async fixture calls janitor.drop() when drop_test_database is configured.""" + fixture_func = postgresql_async("proc_fixture") + raw_func = getattr(fixture_func, "__wrapped__", fixture_func) + + proc_mock = MagicMock() + proc_mock.host = "127.0.0.1" + proc_mock.port = 5432 + proc_mock.user = "postgres" + proc_mock.password = None + proc_mock.options = None + proc_mock.dbname = "tests" + proc_mock.template_dbname = "template_tests" + proc_mock.version = 14 + + janitor_mock = AsyncMock() + janitor_mock.__aenter__ = AsyncMock(return_value=janitor_mock) + janitor_mock.__aexit__ = AsyncMock(return_value=False) + janitor_mock.drop = AsyncMock() + + conn_mock = AsyncMock() + conn_mock.close = AsyncMock() + + request_mock = MagicMock() + request_mock.getfixturevalue.return_value = proc_mock + + with ( + patch("pytest_postgresql.factories.client.AsyncDatabaseJanitor", return_value=janitor_mock), + patch( + "pytest_postgresql.factories.client.AsyncConnection.connect", + new_callable=AsyncMock, + return_value=conn_mock, + ), + patch("pytest_postgresql.factories.client.get_config") as get_config_mock, + ): + config_mock = MagicMock() + config_mock.drop_test_database = True + get_config_mock.return_value = config_mock + + agen = raw_func(request_mock) + conn = await agen.__anext__() + assert conn is conn_mock + janitor_mock.drop.assert_awaited_once() + with pytest.raises(StopAsyncIteration): + await agen.__anext__() + + conn_mock.close.assert_awaited_once() diff --git a/tests/test_loader.py b/tests/test_loader.py index 5d69cdbb..3b81dbc8 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -1,7 +1,7 @@ """Tests for the `build_loader` function.""" from pathlib import Path -from unittest.mock import patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -61,3 +61,40 @@ async def test_sql_async_raises_without_aiofiles() -> None: with patch("pytest_postgresql.loader.aiofiles", None): with pytest.raises(ImportError, match="aiofiles"): await sql_async(Path("dummy.sql"), host="h", port=5432, user="u", dbname="d") + + +@pytest.mark.asyncio +async def test_sql_async_executes_sql_file() -> None: + """sql_async reads the file and executes SQL on an async connection.""" + sql_path = Path("dummy.sql") + fd_mock = AsyncMock() + fd_mock.read = AsyncMock(return_value="SELECT 1") + fd_cm = AsyncMock() + fd_cm.__aenter__ = AsyncMock(return_value=fd_mock) + fd_cm.__aexit__ = AsyncMock(return_value=False) + + cur_mock = AsyncMock() + cur_cm = MagicMock() + cur_cm.__aenter__ = AsyncMock(return_value=cur_mock) + cur_cm.__aexit__ = AsyncMock(return_value=False) + + conn_mock = AsyncMock() + conn_mock.cursor = MagicMock(return_value=cur_cm) + conn_mock.commit = AsyncMock() + conn_cm = AsyncMock() + conn_cm.__aenter__ = AsyncMock(return_value=conn_mock) + conn_cm.__aexit__ = AsyncMock(return_value=False) + + connect_mock = AsyncMock(return_value=conn_cm) + open_mock = MagicMock(return_value=fd_cm) + aiofiles_mock = MagicMock() + aiofiles_mock.open = open_mock + + with ( + patch("pytest_postgresql.loader.aiofiles", aiofiles_mock), + patch("pytest_postgresql.loader.psycopg.AsyncConnection.connect", connect_mock), + ): + await sql_async(sql_path, host="h", port=5432, user="u", dbname="d") + + cur_mock.execute.assert_awaited_once_with("SELECT 1") + conn_mock.commit.assert_awaited_once() diff --git a/tests/test_retry.py b/tests/test_retry.py index 64debe85..4f341cbf 100644 --- a/tests/test_retry.py +++ b/tests/test_retry.py @@ -43,9 +43,6 @@ async def flaky() -> str: async def test_retry_async_timeout() -> None: """Test that retry_async raises TimeoutError after the timeout elapses.""" - async def always_fail() -> None: - raise ValueError("boom") - always_fail_mock = AsyncMock(side_effect=ValueError("boom")) sleep_mock = AsyncMock() base = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc) diff --git a/tests/test_template_database.py b/tests/test_template_database.py index fc64442e..7c4fa283 100644 --- a/tests/test_template_database.py +++ b/tests/test_template_database.py @@ -37,7 +37,7 @@ def test_template_database(postgresql_template: Connection, _: int) -> None: assert len(res) == 0 -@pytest.mark.xdist_group(name="template_database_async") +@pytest.mark.xdist_group(name="template_database") @pytest.mark.asyncio @pytest.mark.parametrize("_", range(5)) async def test_template_database_async(async_postgresql_template: AsyncConnection, _: int) -> None: From f4bf1b9061bfec0c4779b67a864fdb1ac478732b Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 21 Jun 2026 12:56:08 +0000 Subject: [PATCH 22/42] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/test_retry.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_retry.py b/tests/test_retry.py index 4f341cbf..613f4eba 100644 --- a/tests/test_retry.py +++ b/tests/test_retry.py @@ -42,7 +42,6 @@ async def flaky() -> str: @pytest.mark.asyncio async def test_retry_async_timeout() -> None: """Test that retry_async raises TimeoutError after the timeout elapses.""" - always_fail_mock = AsyncMock(side_effect=ValueError("boom")) sleep_mock = AsyncMock() base = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc) From c6390a514d44915c748b8bc739e70006e5fc724c Mon Sep 17 00:00:00 2001 From: tboy1337 Date: Sun, 21 Jun 2026 19:59:48 +0700 Subject: [PATCH 23/42] Replace mock coverage tests with integration tests for async paths. Exercise sql_async via AsyncDatabaseJanitor against live Postgres and add a pointed_pytester drop_test_database async example mirroring the sync integration test. Co-authored-by: Cursor --- .../examples/test_drop_test_database_async.py | 17 +++++ tests/test_factory_errors.py | 52 +-------------- tests/test_janitor.py | 34 ++++++++++ tests/test_loader.py | 39 +---------- tests/test_postgres_options_plugin.py | 64 +++++++++++++++++++ 5 files changed, 117 insertions(+), 89 deletions(-) create mode 100644 tests/examples/test_drop_test_database_async.py diff --git a/tests/examples/test_drop_test_database_async.py b/tests/examples/test_drop_test_database_async.py new file mode 100644 index 00000000..88210ae0 --- /dev/null +++ b/tests/examples/test_drop_test_database_async.py @@ -0,0 +1,17 @@ +"""Async tests for pytest-postgresql drop-test-database behaviour.""" + +import pytest +from psycopg import AsyncConnection + +from pytest_postgresql import factories + +postgresql_async = factories.postgresql_async("postgresql_noproc") + + +@pytest.mark.asyncio +async def test_postgres_load_override_async(postgresql_async: AsyncConnection) -> None: + """Check postgresql_async can load one file and override a pre-existing database.""" + async with postgresql_async.cursor() as cur: + await cur.execute("SELECT * FROM test;") + results = await cur.fetchall() + assert len(results) == 1 diff --git a/tests/test_factory_errors.py b/tests/test_factory_errors.py index 583f48d4..18216693 100644 --- a/tests/test_factory_errors.py +++ b/tests/test_factory_errors.py @@ -1,6 +1,6 @@ """Tests for factory error paths (missing optional dependencies).""" -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import patch import pytest @@ -31,53 +31,3 @@ def test_postgresql_async_raises_on_use_without_pytest_asyncio() -> None: assert not hasattr(raw_func, "__await__"), "stub must be a sync function, not a coroutine" with pytest.raises(ImportError, match="pytest-asyncio"): raw_func(None) # type: ignore[arg-type] - - -@pytest.mark.asyncio -async def test_postgresql_async_drops_database_when_configured() -> None: - """Async fixture calls janitor.drop() when drop_test_database is configured.""" - fixture_func = postgresql_async("proc_fixture") - raw_func = getattr(fixture_func, "__wrapped__", fixture_func) - - proc_mock = MagicMock() - proc_mock.host = "127.0.0.1" - proc_mock.port = 5432 - proc_mock.user = "postgres" - proc_mock.password = None - proc_mock.options = None - proc_mock.dbname = "tests" - proc_mock.template_dbname = "template_tests" - proc_mock.version = 14 - - janitor_mock = AsyncMock() - janitor_mock.__aenter__ = AsyncMock(return_value=janitor_mock) - janitor_mock.__aexit__ = AsyncMock(return_value=False) - janitor_mock.drop = AsyncMock() - - conn_mock = AsyncMock() - conn_mock.close = AsyncMock() - - request_mock = MagicMock() - request_mock.getfixturevalue.return_value = proc_mock - - with ( - patch("pytest_postgresql.factories.client.AsyncDatabaseJanitor", return_value=janitor_mock), - patch( - "pytest_postgresql.factories.client.AsyncConnection.connect", - new_callable=AsyncMock, - return_value=conn_mock, - ), - patch("pytest_postgresql.factories.client.get_config") as get_config_mock, - ): - config_mock = MagicMock() - config_mock.drop_test_database = True - get_config_mock.return_value = config_mock - - agen = raw_func(request_mock) - conn = await agen.__anext__() - assert conn is conn_mock - janitor_mock.drop.assert_awaited_once() - with pytest.raises(StopAsyncIteration): - await agen.__anext__() - - conn_mock.close.assert_awaited_once() diff --git a/tests/test_janitor.py b/tests/test_janitor.py index f732b404..850d0e17 100644 --- a/tests/test_janitor.py +++ b/tests/test_janitor.py @@ -2,16 +2,22 @@ import sys from contextlib import asynccontextmanager +from pathlib import Path from typing import Any, AsyncIterator from unittest.mock import AsyncMock, MagicMock, patch +import psycopg import psycopg.sql as pgsql import pytest from packaging.version import parse from psycopg import AsyncCursor +from pytest_postgresql.executor import PostgreSQLExecutor +from pytest_postgresql.factories.noprocess import xdistify_dbname from pytest_postgresql.janitor import AsyncDatabaseJanitor, DatabaseJanitor +TEST_SQL_FILE = Path(__file__).resolve().parent / "test_sql" / "test.sql" + VERSION = parse("10") @@ -164,6 +170,34 @@ async def async_loader(**kwargs: object) -> None: loader_mock.assert_awaited_once_with(**call_kwargs) +@pytest.mark.asyncio +async def test_janitor_populate_async_sql_path(postgresql_proc: PostgreSQLExecutor) -> None: + """AsyncDatabaseJanitor.load executes SQL from a Path via sql_async against live PostgreSQL.""" + dbname = xdistify_dbname("sql_async_load") + janitor = AsyncDatabaseJanitor( + user=postgresql_proc.user, + host=postgresql_proc.host, + port=postgresql_proc.port, + dbname=dbname, + version=postgresql_proc.version, + password=postgresql_proc.password, + connection_timeout=5, + ) + async with janitor: + await janitor.load(TEST_SQL_FILE) + async with await psycopg.AsyncConnection.connect( + dbname=dbname, + user=postgresql_proc.user, + password=postgresql_proc.password, + host=postgresql_proc.host, + port=postgresql_proc.port, + ) as conn: + async with conn.cursor() as cur: + await cur.execute("SELECT * FROM test_load") + rows = await cur.fetchall() + assert len(rows) == 1 + + # --------------------------------------------------------------------------- # AsyncDatabaseJanitor -- init() / drop() / helper method tests # --------------------------------------------------------------------------- diff --git a/tests/test_loader.py b/tests/test_loader.py index 3b81dbc8..5d69cdbb 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -1,7 +1,7 @@ """Tests for the `build_loader` function.""" from pathlib import Path -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import patch import pytest @@ -61,40 +61,3 @@ async def test_sql_async_raises_without_aiofiles() -> None: with patch("pytest_postgresql.loader.aiofiles", None): with pytest.raises(ImportError, match="aiofiles"): await sql_async(Path("dummy.sql"), host="h", port=5432, user="u", dbname="d") - - -@pytest.mark.asyncio -async def test_sql_async_executes_sql_file() -> None: - """sql_async reads the file and executes SQL on an async connection.""" - sql_path = Path("dummy.sql") - fd_mock = AsyncMock() - fd_mock.read = AsyncMock(return_value="SELECT 1") - fd_cm = AsyncMock() - fd_cm.__aenter__ = AsyncMock(return_value=fd_mock) - fd_cm.__aexit__ = AsyncMock(return_value=False) - - cur_mock = AsyncMock() - cur_cm = MagicMock() - cur_cm.__aenter__ = AsyncMock(return_value=cur_mock) - cur_cm.__aexit__ = AsyncMock(return_value=False) - - conn_mock = AsyncMock() - conn_mock.cursor = MagicMock(return_value=cur_cm) - conn_mock.commit = AsyncMock() - conn_cm = AsyncMock() - conn_cm.__aenter__ = AsyncMock(return_value=conn_mock) - conn_cm.__aexit__ = AsyncMock(return_value=False) - - connect_mock = AsyncMock(return_value=conn_cm) - open_mock = MagicMock(return_value=fd_cm) - aiofiles_mock = MagicMock() - aiofiles_mock.open = open_mock - - with ( - patch("pytest_postgresql.loader.aiofiles", aiofiles_mock), - patch("pytest_postgresql.loader.psycopg.AsyncConnection.connect", connect_mock), - ): - await sql_async(sql_path, host="h", port=5432, user="u", dbname="d") - - cur_mock.execute.assert_awaited_once_with("SELECT 1") - conn_mock.commit.assert_awaited_once() diff --git a/tests/test_postgres_options_plugin.py b/tests/test_postgres_options_plugin.py index 754dc3b6..297eb64f 100644 --- a/tests/test_postgres_options_plugin.py +++ b/tests/test_postgres_options_plugin.py @@ -144,3 +144,67 @@ def test_postgres_drop_test_database( pass assert hasattr(excinfo.value, "__cause__") assert f'FATAL: database "{template_janitor.dbname}" does not exist' in str(excinfo.value.__cause__) + + +def test_postgres_drop_test_database_async( + postgresql_proc_to_override: PostgreSQLExecutor, + pointed_pytester: Pytester, +) -> None: + """Check that async client fixture drops the database when --postgresql-drop-test-database is set. + + Mirrors ``test_postgres_drop_test_database`` but runs an async subprocess test that uses + ``postgresql_async`` against the same live PostgreSQL process. + """ + dbname = xdistify_dbname("override") + template_dbname = dbname + "_tmpl" + template_janitor = DatabaseJanitor( + user=postgresql_proc_to_override.user, + host=postgresql_proc_to_override.host, + port=postgresql_proc_to_override.port, + dbname=template_dbname, + as_template=True, + version=postgresql_proc_to_override.version, + password=postgresql_proc_to_override.password, + connection_timeout=5, + ) + template_janitor.init() + template_janitor.load(load_database) + assert template_janitor.dbname + janitor = DatabaseJanitor( + user=postgresql_proc_to_override.user, + host=postgresql_proc_to_override.host, + port=postgresql_proc_to_override.port, + dbname=dbname, + template_dbname=template_janitor.dbname, + version=postgresql_proc_to_override.version, + password=postgresql_proc_to_override.password, + connection_timeout=5, + ) + janitor.init() + assert janitor.dbname + with janitor.cursor(janitor.dbname) as cur: + cur.execute("SELECT * FROM stories") + res = cur.fetchall() + assert len(res) == 4 + + pointed_pytester.copy_example("test_drop_test_database_async.py") + test_sql_path = pointed_pytester.copy_example("test.sql") + ret = pointed_pytester.runpytest( + f"--postgresql-load={test_sql_path}", + f"--postgresql-port={postgresql_proc_to_override.port}", + "--postgresql-dbname=override", + "--postgresql-drop-test-database", + "test_drop_test_database_async.py", + ) + ret.assert_outcomes(passed=1) + + with pytest.raises(TimeoutError) as excinfo: + with janitor.cursor(janitor.dbname): + pass + assert hasattr(excinfo.value, "__cause__") + assert f'FATAL: database "{janitor.dbname}" does not exist' in str(excinfo.value.__cause__) + with pytest.raises(TimeoutError) as excinfo: + with template_janitor.cursor(template_janitor.dbname): + pass + assert hasattr(excinfo.value, "__cause__") + assert f'FATAL: database "{template_janitor.dbname}" does not exist' in str(excinfo.value.__cause__) From 4cf5706383502511437f802101551a25b76302fd Mon Sep 17 00:00:00 2001 From: tboy1337 Date: Sun, 21 Jun 2026 20:19:24 +0700 Subject: [PATCH 24/42] Fix postgresql_oldest and Windows CI failures for async support. Use a psycopg 3.0-compatible SQL render fallback in janitor tests and set WindowsSelectorEventLoopPolicy in the plugin so psycopg async works on Windows. Co-authored-by: Cursor --- pytest_postgresql/plugin.py | 9 +++++++++ tests/test_janitor.py | 17 ++++++++++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/pytest_postgresql/plugin.py b/pytest_postgresql/plugin.py index 5fa7b58c..d87e1eb5 100644 --- a/pytest_postgresql/plugin.py +++ b/pytest_postgresql/plugin.py @@ -17,8 +17,11 @@ # along with pytest-postgresql. If not, see . """Plugin module of pytest-postgresql.""" +import asyncio +import sys from tempfile import gettempdir +import pytest from _pytest.config.argparsing import Parser from pytest_postgresql import factories @@ -42,6 +45,12 @@ ) +def pytest_configure(config: pytest.Config) -> None: + """Configure pytest-postgresql plugin.""" + if sys.platform == "win32": + asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) + + def pytest_addoption(parser: Parser) -> None: """Configure options for pytest-postgresql.""" parser.addini(name="postgresql_exec", help=_help_executable, default="/usr/lib/postgresql/13/bin/pg_ctl") diff --git a/tests/test_janitor.py b/tests/test_janitor.py index 850d0e17..75685163 100644 --- a/tests/test_janitor.py +++ b/tests/test_janitor.py @@ -203,10 +203,25 @@ async def test_janitor_populate_async_sql_path(postgresql_proc: PostgreSQLExecut # --------------------------------------------------------------------------- +def _render_composable_fallback(obj: pgsql.Composable) -> str: + """Render SQL composables without a connection (psycopg 3.0 Identifier compat).""" + if isinstance(obj, pgsql.Composed): + return "".join(_render_composable_fallback(part) for part in obj._obj) + if isinstance(obj, pgsql.SQL): + return obj._obj + if isinstance(obj, pgsql.Identifier): + parts = obj._obj if isinstance(obj._obj, tuple) else (obj._obj,) + return ".".join(f'"{part}"' for part in parts) + return str(obj) + + def _render_sql(obj: object) -> str: """Render a psycopg.sql Composable to its SQL text form for test assertions.""" if isinstance(obj, pgsql.Composable): - return obj.as_string(None) + try: + return obj.as_string(None) + except (TypeError, ValueError): + return _render_composable_fallback(obj) return str(obj) From 50377e19c2ba573a91c29150932d70b7388d1e89 Mon Sep 17 00:00:00 2001 From: tboy1337 Date: Sun, 21 Jun 2026 20:38:03 +0700 Subject: [PATCH 25/42] Replace mock AsyncDatabaseJanitor SQL tests with Postgres integration tests. Exercise init, drop, template flags, and TEMPLATE cloning against live PostgreSQL and remove _render_sql mock infrastructure. Co-authored-by: Cursor --- tests/test_janitor.py | 193 ++++++++++++++++++++---------------------- 1 file changed, 92 insertions(+), 101 deletions(-) diff --git a/tests/test_janitor.py b/tests/test_janitor.py index 75685163..e65e8b4a 100644 --- a/tests/test_janitor.py +++ b/tests/test_janitor.py @@ -1,13 +1,11 @@ """Database Janitor tests.""" import sys -from contextlib import asynccontextmanager from pathlib import Path -from typing import Any, AsyncIterator +from typing import Any from unittest.mock import AsyncMock, MagicMock, patch import psycopg -import psycopg.sql as pgsql import pytest from packaging.version import parse from psycopg import AsyncCursor @@ -199,113 +197,118 @@ async def test_janitor_populate_async_sql_path(postgresql_proc: PostgreSQLExecut # --------------------------------------------------------------------------- -# AsyncDatabaseJanitor -- init() / drop() / helper method tests +# AsyncDatabaseJanitor -- init() / drop() integration tests # --------------------------------------------------------------------------- -def _render_composable_fallback(obj: pgsql.Composable) -> str: - """Render SQL composables without a connection (psycopg 3.0 Identifier compat).""" - if isinstance(obj, pgsql.Composed): - return "".join(_render_composable_fallback(part) for part in obj._obj) - if isinstance(obj, pgsql.SQL): - return obj._obj - if isinstance(obj, pgsql.Identifier): - parts = obj._obj if isinstance(obj._obj, tuple) else (obj._obj,) - return ".".join(f'"{part}"' for part in parts) - return str(obj) - - -def _render_sql(obj: object) -> str: - """Render a psycopg.sql Composable to its SQL text form for test assertions.""" - if isinstance(obj, pgsql.Composable): - try: - return obj.as_string(None) - except (TypeError, ValueError): - return _render_composable_fallback(obj) - return str(obj) - - -def _make_cursor_mock() -> MagicMock: - """Create a mock async cursor that records execute() calls.""" - cur = AsyncMock(spec=AsyncCursor) - return cur - - -def _make_cursor_context(cur: AsyncMock) -> Any: - """Return an async context manager that yields the given cursor mock.""" - - @asynccontextmanager - async def _ctx(self: AsyncDatabaseJanitor, dbname: str = "postgres") -> AsyncIterator[AsyncMock]: - yield cur +def _proc_connection_kwargs(proc: PostgreSQLExecutor, *, dbname: str = "postgres") -> dict[str, object]: + return { + "dbname": dbname, + "user": proc.user, + "password": proc.password, + "host": proc.host, + "port": proc.port, + } - return _ctx +async def _database_exists(proc: PostgreSQLExecutor, dbname: str) -> bool: + async with await psycopg.AsyncConnection.connect(**_proc_connection_kwargs(proc)) as conn: + async with conn.cursor() as cur: + await cur.execute("SELECT 1 FROM pg_database WHERE datname = %s", (dbname,)) + return await cur.fetchone() is not None -@pytest.mark.asyncio -async def test_async_janitor_init_creates_database() -> None: - """init() executes CREATE DATABASE with the configured dbname.""" - cur = _make_cursor_mock() - janitor = AsyncDatabaseJanitor(user="user", host="host", port="1234", dbname="mydb", version=10) - with patch.object(AsyncDatabaseJanitor, "cursor", _make_cursor_context(cur)): - await janitor.init() - executed_sql = " ".join(_render_sql(c.args[0]) for c in cur.execute.call_args_list) - assert 'CREATE DATABASE "mydb"' in executed_sql +async def _database_is_template(proc: PostgreSQLExecutor, dbname: str) -> bool: + async with await psycopg.AsyncConnection.connect(**_proc_connection_kwargs(proc)) as conn: + async with conn.cursor() as cur: + await cur.execute("SELECT datistemplate FROM pg_database WHERE datname = %s", (dbname,)) + row = await cur.fetchone() + return bool(row and row[0]) @pytest.mark.asyncio -async def test_async_janitor_init_with_template() -> None: - """init() uses TEMPLATE clause when template_dbname is set.""" - cur = _make_cursor_mock() +async def test_async_janitor_init_and_drop(postgresql_proc: PostgreSQLExecutor) -> None: + """init() creates a database and drop() removes it against live PostgreSQL.""" + dbname = xdistify_dbname("async_janitor_lifecycle") janitor = AsyncDatabaseJanitor( - user="user", host="host", port="1234", dbname="mydb", template_dbname="tmpl", version=10 + user=postgresql_proc.user, + host=postgresql_proc.host, + port=postgresql_proc.port, + dbname=dbname, + version=postgresql_proc.version, + password=postgresql_proc.password, + connection_timeout=5, ) - with patch.object(AsyncDatabaseJanitor, "cursor", _make_cursor_context(cur)): - await janitor.init() - - executed_sql = " ".join(_render_sql(c.args[0]) for c in cur.execute.call_args_list) - assert 'CREATE DATABASE "mydb" TEMPLATE "tmpl"' in executed_sql + await janitor.init() + assert await _database_exists(postgresql_proc, dbname) + await janitor.drop() + assert not await _database_exists(postgresql_proc, dbname) @pytest.mark.asyncio -async def test_async_janitor_init_as_template() -> None: - """init() appends IS_TEMPLATE = true when as_template is True.""" - cur = _make_cursor_mock() - janitor = AsyncDatabaseJanitor(user="user", host="host", port="1234", dbname="mydb", as_template=True, version=10) - with patch.object(AsyncDatabaseJanitor, "cursor", _make_cursor_context(cur)): - await janitor.init() - - executed_sql = " ".join(_render_sql(c.args[0]) for c in cur.execute.call_args_list) - assert "IS_TEMPLATE = true" in executed_sql +async def test_async_janitor_template_flag_and_context_manager(postgresql_proc: PostgreSQLExecutor) -> None: + """as_template marks the database as a template and async with drops it cleanly.""" + dbname = xdistify_dbname("async_janitor_tmpl") + janitor = AsyncDatabaseJanitor( + user=postgresql_proc.user, + host=postgresql_proc.host, + port=postgresql_proc.port, + dbname=dbname, + version=postgresql_proc.version, + password=postgresql_proc.password, + as_template=True, + connection_timeout=5, + ) + async with janitor: + assert await _database_is_template(postgresql_proc, dbname) + assert not await _database_exists(postgresql_proc, dbname) @pytest.mark.asyncio -async def test_async_janitor_drop_drops_database() -> None: - """drop() executes DROP DATABASE IF EXISTS for the configured dbname.""" - cur = _make_cursor_mock() - janitor = AsyncDatabaseJanitor(user="user", host="host", port="1234", dbname="mydb", version=10) - with patch.object(AsyncDatabaseJanitor, "cursor", _make_cursor_context(cur)): - await janitor.drop() - - executed_sql = " ".join(_render_sql(c.args[0]) for c in cur.execute.call_args_list) - assert 'DROP DATABASE IF EXISTS "mydb"' in executed_sql +async def test_async_janitor_creates_database_from_template(postgresql_proc: PostgreSQLExecutor) -> None: + """init() clones schema and data from a template database.""" + base_dbname = xdistify_dbname("async_janitor_tmpl_base") + clone_dbname = xdistify_dbname("async_janitor_tmpl_clone") + base_janitor = AsyncDatabaseJanitor( + user=postgresql_proc.user, + host=postgresql_proc.host, + port=postgresql_proc.port, + dbname=base_dbname, + version=postgresql_proc.version, + password=postgresql_proc.password, + as_template=True, + connection_timeout=5, + ) + clone_janitor = AsyncDatabaseJanitor( + user=postgresql_proc.user, + host=postgresql_proc.host, + port=postgresql_proc.port, + dbname=clone_dbname, + template_dbname=base_dbname, + version=postgresql_proc.version, + password=postgresql_proc.password, + connection_timeout=5, + ) + try: + await base_janitor.init() + await base_janitor.load(TEST_SQL_FILE) + await clone_janitor.init() + async with await psycopg.AsyncConnection.connect(**_proc_connection_kwargs(postgresql_proc, dbname=clone_dbname)) as conn: + async with conn.cursor() as cur: + await cur.execute("SELECT * FROM test_load") + rows = await cur.fetchall() + assert len(rows) == 1 + finally: + await clone_janitor.drop() + await base_janitor.drop() + assert not await _database_exists(postgresql_proc, clone_dbname) + assert not await _database_exists(postgresql_proc, base_dbname) -@pytest.mark.asyncio -async def test_async_janitor_drop_as_template() -> None: - """drop() resets is_template before dropping when as_template is True.""" - cur = _make_cursor_mock() - janitor = AsyncDatabaseJanitor(user="user", host="host", port="1234", dbname="mydb", as_template=True, version=10) - with patch.object(AsyncDatabaseJanitor, "cursor", _make_cursor_context(cur)): - await janitor.drop() - executed_sql = [_render_sql(c.args[0]) for c in cur.execute.call_args_list] - assert any("is_template false" in s for s in executed_sql) - assert any('DROP DATABASE IF EXISTS "mydb"' in s for s in executed_sql) - # is_template false must come before DROP - template_idx = next(i for i, s in enumerate(executed_sql) if "is_template false" in s) - drop_idx = next(i for i, s in enumerate(executed_sql) if "DROP DATABASE" in s) - assert template_idx < drop_idx +# --------------------------------------------------------------------------- +# AsyncDatabaseJanitor -- lightweight unit tests +# --------------------------------------------------------------------------- def test_async_janitor_is_template_false() -> None: @@ -345,18 +348,6 @@ async def test_async_janitor_terminate_connection_sql() -> None: assert params == ("target_db",) -@pytest.mark.asyncio -async def test_async_janitor_dont_datallowconn_sql() -> None: - """_dont_datallowconn() executes ALTER DATABASE allow_connections false for the dbname.""" - cur = AsyncMock(spec=AsyncCursor) - await AsyncDatabaseJanitor._dont_datallowconn(cur, "target_db") - - cur.execute.assert_called_once() - sql_str = _render_sql(cur.execute.call_args.args[0]) - assert "allow_connections false" in sql_str - assert '"target_db"' in sql_str - - def _make_async_conn_mock() -> MagicMock: """Create a MagicMock that behaves like a psycopg3 AsyncConnection.""" conn = MagicMock() From 91b73057c4533a0d2fae853ac3d79799ccd18f41 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 21 Jun 2026 13:38:18 +0000 Subject: [PATCH 26/42] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/test_janitor.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_janitor.py b/tests/test_janitor.py index e65e8b4a..7ec2291e 100644 --- a/tests/test_janitor.py +++ b/tests/test_janitor.py @@ -293,7 +293,9 @@ async def test_async_janitor_creates_database_from_template(postgresql_proc: Pos await base_janitor.init() await base_janitor.load(TEST_SQL_FILE) await clone_janitor.init() - async with await psycopg.AsyncConnection.connect(**_proc_connection_kwargs(postgresql_proc, dbname=clone_dbname)) as conn: + async with await psycopg.AsyncConnection.connect( + **_proc_connection_kwargs(postgresql_proc, dbname=clone_dbname) + ) as conn: async with conn.cursor() as cur: await cur.execute("SELECT * FROM test_load") rows = await cur.fetchall() From 96289b822582ac48edef2ce39a74b35d562aee6f Mon Sep 17 00:00:00 2001 From: tboy1337 Date: Sun, 21 Jun 2026 20:53:21 +0700 Subject: [PATCH 27/42] Fix mypy errors in janitor integration test helpers. Inline AsyncConnection.connect kwargs instead of dict unpacking so strict mypy accepts the connection parameters. Co-authored-by: Cursor --- tests/test_janitor.py | 32 +++++++++++++++++++------------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/tests/test_janitor.py b/tests/test_janitor.py index 7ec2291e..4f133f01 100644 --- a/tests/test_janitor.py +++ b/tests/test_janitor.py @@ -201,25 +201,27 @@ async def test_janitor_populate_async_sql_path(postgresql_proc: PostgreSQLExecut # --------------------------------------------------------------------------- -def _proc_connection_kwargs(proc: PostgreSQLExecutor, *, dbname: str = "postgres") -> dict[str, object]: - return { - "dbname": dbname, - "user": proc.user, - "password": proc.password, - "host": proc.host, - "port": proc.port, - } - - async def _database_exists(proc: PostgreSQLExecutor, dbname: str) -> bool: - async with await psycopg.AsyncConnection.connect(**_proc_connection_kwargs(proc)) as conn: + async with await psycopg.AsyncConnection.connect( + dbname="postgres", + user=proc.user, + password=proc.password, + host=proc.host, + port=proc.port, + ) as conn: async with conn.cursor() as cur: await cur.execute("SELECT 1 FROM pg_database WHERE datname = %s", (dbname,)) return await cur.fetchone() is not None async def _database_is_template(proc: PostgreSQLExecutor, dbname: str) -> bool: - async with await psycopg.AsyncConnection.connect(**_proc_connection_kwargs(proc)) as conn: + async with await psycopg.AsyncConnection.connect( + dbname="postgres", + user=proc.user, + password=proc.password, + host=proc.host, + port=proc.port, + ) as conn: async with conn.cursor() as cur: await cur.execute("SELECT datistemplate FROM pg_database WHERE datname = %s", (dbname,)) row = await cur.fetchone() @@ -294,7 +296,11 @@ async def test_async_janitor_creates_database_from_template(postgresql_proc: Pos await base_janitor.load(TEST_SQL_FILE) await clone_janitor.init() async with await psycopg.AsyncConnection.connect( - **_proc_connection_kwargs(postgresql_proc, dbname=clone_dbname) + dbname=clone_dbname, + user=postgresql_proc.user, + password=postgresql_proc.password, + host=postgresql_proc.host, + port=postgresql_proc.port, ) as conn: async with conn.cursor() as cur: await cur.execute("SELECT * FROM test_load") From 8c866fcd64377a416d0df11cd996d33c70009a64 Mon Sep 17 00:00:00 2001 From: tboy1337 Date: Sun, 21 Jun 2026 21:46:11 +0700 Subject: [PATCH 28/42] Update pytest-asyncio dependency to version 0.24 in Pipfile and pyproject.toml; enhance documentation for async extra to reflect the new version requirement. --- Pipfile | 2 +- newsfragments/1235.feature.rst | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Pipfile b/Pipfile index 25f937b0..871ac051 100644 --- a/Pipfile +++ b/Pipfile @@ -14,7 +14,7 @@ pytest-postgresql = {path = ".", editable = true} [dev-packages] towncrier = "==25.8.0" psycopg-binary = {version = "==3.3.4", markers="implementation_name == 'cpython'"} -pytest-asyncio = ">=0.21" +pytest-asyncio = ">=0.24" aiofiles = ">=23.0" types-aiofiles = ">=23.0" coverage = ">=7.14.1" diff --git a/newsfragments/1235.feature.rst b/newsfragments/1235.feature.rst index 6c047099..6bd324fb 100644 --- a/newsfragments/1235.feature.rst +++ b/newsfragments/1235.feature.rst @@ -1,3 +1,3 @@ Added async PostgreSQL fixture support via ``postgresql_async`` factory and ``AsyncDatabaseJanitor``. Added configurable fixture ``scope`` parameter to ``postgresql``, ``postgresql_async``, ``postgresql_proc``, and ``postgresql_noproc`` factories (defaults preserved: ``"function"`` for client fixtures, ``"session"`` for process fixtures). -Added optional ``async`` extra (``pip install pytest-postgresql[async]``) providing ``pytest-asyncio`` and ``aiofiles`` dependencies. +Added optional ``async`` extra (``pip install pytest-postgresql[async]``) providing ``pytest-asyncio`` (>= 0.24) and ``aiofiles`` dependencies. diff --git a/pyproject.toml b/pyproject.toml index 9722ad67..1675e45c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,7 +38,7 @@ requires-python = ">= 3.10" [project.optional-dependencies] async = [ - "pytest-asyncio >= 0.21", + "pytest-asyncio >= 0.24", "aiofiles >= 23.0" ] From 18f04d71ea3cb0d1f9787d425a2bdb5b5e27182c Mon Sep 17 00:00:00 2001 From: tboy1337 Date: Sun, 21 Jun 2026 21:50:26 +0700 Subject: [PATCH 29/42] Enhance README and client.py documentation for async fixtures, specifying minimum pytest-asyncio version 0.24. Update error messages to reflect new version requirement and clarify async fixture behavior. --- README.rst | 31 ++++++++++++++++++++++++--- pytest_postgresql/factories/client.py | 7 +++++- tests/test_factory_errors.py | 2 +- 3 files changed, 35 insertions(+), 5 deletions(-) diff --git a/README.rst b/README.rst index f974559c..8574d12f 100644 --- a/README.rst +++ b/README.rst @@ -44,7 +44,14 @@ Quick Start pip install pytest-postgresql[async] - This pulls in ``pytest-asyncio`` and ``aiofiles``. + This installs: + + * ``pytest-asyncio`` (>= 0.24) — required for ``@pytest.mark.asyncio`` and + ``postgresql_async`` fixtures. Version 0.24 or newer is required because + scoped async fixtures rely on the ``loop_scope`` argument introduced in + that release. + * ``aiofiles`` (>= 23.0) — required only when loading SQL files via the + async loader (``sql_async``). .. note:: @@ -99,7 +106,25 @@ The plugin provides two main types of fixtures: * **postgresql** - A function-scoped fixture (by default). It returns a connected ``psycopg.Connection``. After each test, it terminates leftover connections and drops the test database to ensure isolation. * **postgresql_async** - The async counterpart. It returns a connected ``psycopg.AsyncConnection``. - Requires ``pytest-postgresql[async]`` and ``@pytest.mark.asyncio`` on your tests. + Requires ``pytest-postgresql[async]`` (``pytest-asyncio`` >= 0.24), and each test must be + marked with ``@pytest.mark.asyncio``. + +**Async fixtures** + ``postgresql_async`` and custom factories created with ``factories.postgresql_async`` are + async generator fixtures. They use ``pytest_asyncio.fixture`` with matching ``scope`` and + ``loop_scope`` so that non-function scopes (for example ``module`` or ``session``) share the + same event loop for the fixture lifetime. + + Minimum versions when installing manually instead of via ``[async]``: + + .. code-block:: text + + pytest-asyncio >= 0.24 + aiofiles >= 23.0 # only for async SQL file loading + + If ``pytest-asyncio`` is missing, fixture setup raises ``ImportError``. If an older + ``pytest-asyncio`` (< 0.24) is installed, plugin registration fails with + ``TypeError: fixture() got an unexpected keyword argument 'loop_scope'``. **2. Process Fixtures** These manage the PostgreSQL server lifecycle. @@ -123,7 +148,7 @@ You can create additional fixtures using factories: # Create a client fixture that uses the custom process postgresql_my = factories.postgresql('postgresql_my_proc') - # Async client fixture (requires pytest-postgresql[async]) + # Async client fixture (requires pytest-postgresql[async], pytest-asyncio >= 0.24) postgresql_my_async = factories.postgresql_async('postgresql_my_proc') All factories accept an optional ``scope`` parameter (``"session"``, ``"package"``, ``"module"``, ``"class"``, or ``"function"``). diff --git a/pytest_postgresql/factories/client.py b/pytest_postgresql/factories/client.py index e4cd2dbf..1e105454 100644 --- a/pytest_postgresql/factories/client.py +++ b/pytest_postgresql/factories/client.py @@ -106,6 +106,10 @@ def postgresql_async( ) -> Callable[[FixtureRequest], AsyncIterator[AsyncConnection]]: """Return async connection fixture factory for PostgreSQL. + Requires ``pytest-asyncio`` >= 0.24 (install via ``pip install pytest-postgresql[async]``). + Scoped fixtures pass ``loop_scope=scope`` to ``pytest_asyncio.fixture``, which is only + supported in pytest-asyncio 0.24 and later. + :param process_fixture_name: name of the process fixture :param dbname: database name :param isolation_level: optional postgresql isolation level @@ -119,7 +123,8 @@ def postgresql_async( def postgresql_async_stub(request: FixtureRequest) -> None: """Sync stub that raises ImportError when pytest-asyncio is absent.""" raise ImportError( - "pytest-asyncio is required for async fixtures. Install it with: pip install pytest-postgresql[async]" + "pytest-asyncio >= 0.24 is required for async fixtures. " + "Install it with: pip install pytest-postgresql[async]" ) return cast( diff --git a/tests/test_factory_errors.py b/tests/test_factory_errors.py index 18216693..395aee11 100644 --- a/tests/test_factory_errors.py +++ b/tests/test_factory_errors.py @@ -29,5 +29,5 @@ def test_postgresql_async_raises_on_use_without_pytest_asyncio() -> None: # pytest 8+ wraps fixtures to prevent direct calls; unwrap first. raw_func = getattr(fixture_func, "__wrapped__", fixture_func) assert not hasattr(raw_func, "__await__"), "stub must be a sync function, not a coroutine" - with pytest.raises(ImportError, match="pytest-asyncio"): + with pytest.raises(ImportError, match="pytest-asyncio >= 0.24"): raw_func(None) # type: ignore[arg-type] From 7c28065f44eac844b89ee8e0b26fa162e39af105 Mon Sep 17 00:00:00 2001 From: tboy1337 Date: Sun, 21 Jun 2026 21:59:22 +0700 Subject: [PATCH 30/42] Update error message in test for pytest-asyncio version requirement to use escaped regex pattern. --- tests/test_factory_errors.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_factory_errors.py b/tests/test_factory_errors.py index 395aee11..61082c51 100644 --- a/tests/test_factory_errors.py +++ b/tests/test_factory_errors.py @@ -29,5 +29,5 @@ def test_postgresql_async_raises_on_use_without_pytest_asyncio() -> None: # pytest 8+ wraps fixtures to prevent direct calls; unwrap first. raw_func = getattr(fixture_func, "__wrapped__", fixture_func) assert not hasattr(raw_func, "__await__"), "stub must be a sync function, not a coroutine" - with pytest.raises(ImportError, match="pytest-asyncio >= 0.24"): + with pytest.raises(ImportError, match=r"pytest-asyncio >= 0\.24"): raw_func(None) # type: ignore[arg-type] From 2a51cd25991d8d31fad4cfef984037903230d88b Mon Sep 17 00:00:00 2001 From: tboy1337 Date: Sun, 21 Jun 2026 22:20:09 +0700 Subject: [PATCH 31/42] Update pytest-postgresql plugin to set Windows event loop policy conditionally based on asyncio plugin presence; refactor password handling in janitor tests for consistency. --- pytest_postgresql/plugin.py | 2 +- tests/test_janitor.py | 15 ++++++++------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/pytest_postgresql/plugin.py b/pytest_postgresql/plugin.py index d87e1eb5..017ef81f 100644 --- a/pytest_postgresql/plugin.py +++ b/pytest_postgresql/plugin.py @@ -47,7 +47,7 @@ def pytest_configure(config: pytest.Config) -> None: """Configure pytest-postgresql plugin.""" - if sys.platform == "win32": + if sys.platform == "win32" and config.pluginmanager.has_plugin("asyncio"): asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) diff --git a/tests/test_janitor.py b/tests/test_janitor.py index 4f133f01..d5a8e724 100644 --- a/tests/test_janitor.py +++ b/tests/test_janitor.py @@ -17,6 +17,7 @@ TEST_SQL_FILE = Path(__file__).resolve().parent / "test_sql" / "test.sql" VERSION = parse("10") +TEST_PASSWORD = "some_password" # noqa: S106 @pytest.mark.parametrize("version", (VERSION, 10, "10")) @@ -64,11 +65,11 @@ def test_cursor_connects_with_password(connect_mock: MagicMock) -> None: port="1234", dbname="database_name", version=10, - password="some_password", # noqa: S106 + password=TEST_PASSWORD, ) with janitor.cursor(): connect_mock.assert_called_once_with( - dbname="postgres", user="user", password="some_password", host="host", port="1234" + dbname="postgres", user="user", password=TEST_PASSWORD, host="host", port="1234" ) @@ -84,11 +85,11 @@ async def test_cursor_connects_with_password_async() -> None: port="1234", dbname="database_name", version=10, - password="some_password", # noqa: S106 + password=TEST_PASSWORD, ) async with janitor.cursor(): connect_mock.assert_called_once_with( - dbname="postgres", user="user", password="some_password", host="host", port="1234" + dbname="postgres", user="user", password=TEST_PASSWORD, host="host", port="1234" ) @@ -118,7 +119,7 @@ def test_janitor_populate(connect_mock: MagicMock, load_database: str) -> None: "port": "1234", "user": "user", "dbname": "database_name", - "password": "some_password", # noqa: S106 + "password": TEST_PASSWORD, } janitor = DatabaseJanitor(version=10, **call_kwargs) # type: ignore[arg-type] janitor.load(load_database) @@ -140,7 +141,7 @@ async def test_janitor_populate_async(connect_mock: MagicMock, load_database: st "port": "1234", "user": "user", "dbname": "database_name", - "password": "some_password", # noqa: S106 + "password": TEST_PASSWORD, } janitor = AsyncDatabaseJanitor(version=10, **call_kwargs) # type: ignore[arg-type] await janitor.load(load_database) @@ -156,7 +157,7 @@ async def test_janitor_populate_async_awaitable_loader() -> None: "port": "1234", "user": "user", "dbname": "database_name", - "password": "some_password", # noqa: S106 + "password": TEST_PASSWORD, } loader_mock = AsyncMock() From 34c3d47c16e02e327474b7372db41e5f0ee065a5 Mon Sep 17 00:00:00 2001 From: tboy1337 Date: Sun, 21 Jun 2026 23:16:32 +0700 Subject: [PATCH 32/42] Refactor password handling in janitor tests to address linting warning S105. --- tests/test_janitor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_janitor.py b/tests/test_janitor.py index d5a8e724..98217c2a 100644 --- a/tests/test_janitor.py +++ b/tests/test_janitor.py @@ -17,7 +17,7 @@ TEST_SQL_FILE = Path(__file__).resolve().parent / "test_sql" / "test.sql" VERSION = parse("10") -TEST_PASSWORD = "some_password" # noqa: S106 +TEST_PASSWORD = "some_password" # noqa: S105 @pytest.mark.parametrize("version", (VERSION, 10, "10")) From b87cef4229c98990c06623422ea731b505606831 Mon Sep 17 00:00:00 2001 From: tboy1337 Date: Mon, 22 Jun 2026 10:45:07 +0700 Subject: [PATCH 33/42] Refactor fixture scope handling in PostgreSQL factories by removing the scope parameter and setting defaults directly in the fixture decorators. Update README to clarify async fixture behavior and dependencies. Remove unused types module. --- README.rst | 15 ++++----------- newsfragments/1235.feature.rst | 1 - pytest_postgresql/factories/client.py | 13 +++---------- pytest_postgresql/factories/noprocess.py | 5 +---- pytest_postgresql/factories/process.py | 5 +---- pytest_postgresql/types.py | 5 ----- tests/test_executor.py | 4 ++-- 7 files changed, 11 insertions(+), 37 deletions(-) delete mode 100644 pytest_postgresql/types.py diff --git a/README.rst b/README.rst index 8574d12f..bf3840ce 100644 --- a/README.rst +++ b/README.rst @@ -103,7 +103,7 @@ The plugin provides two main types of fixtures: **1. Client Fixtures** These provide a connection to a database for your tests. - * **postgresql** - A function-scoped fixture (by default). It returns a connected ``psycopg.Connection``. + * **postgresql** - A function-scoped fixture. It returns a connected ``psycopg.Connection``. After each test, it terminates leftover connections and drops the test database to ensure isolation. * **postgresql_async** - The async counterpart. It returns a connected ``psycopg.AsyncConnection``. Requires ``pytest-postgresql[async]`` (``pytest-asyncio`` >= 0.24), and each test must be @@ -111,9 +111,7 @@ The plugin provides two main types of fixtures: **Async fixtures** ``postgresql_async`` and custom factories created with ``factories.postgresql_async`` are - async generator fixtures. They use ``pytest_asyncio.fixture`` with matching ``scope`` and - ``loop_scope`` so that non-function scopes (for example ``module`` or ``session``) share the - same event loop for the fixture lifetime. + async generator fixtures using ``pytest_asyncio.fixture``. Minimum versions when installing manually instead of via ``[async]``: @@ -122,14 +120,12 @@ The plugin provides two main types of fixtures: pytest-asyncio >= 0.24 aiofiles >= 23.0 # only for async SQL file loading - If ``pytest-asyncio`` is missing, fixture setup raises ``ImportError``. If an older - ``pytest-asyncio`` (< 0.24) is installed, plugin registration fails with - ``TypeError: fixture() got an unexpected keyword argument 'loop_scope'``. + If ``pytest-asyncio`` is missing, fixture setup raises ``ImportError``. **2. Process Fixtures** These manage the PostgreSQL server lifecycle. - * **postgresql_proc** - A session-scoped fixture (by default) that starts a PostgreSQL instance on its first use and stops it when all tests are finished. + * **postgresql_proc** - A session-scoped fixture that starts a PostgreSQL instance on its first use and stops it when all tests are finished. * **postgresql_noproc** - A fixture for connecting to an already running PostgreSQL instance (e.g., in Docker or CI). Customizing Fixtures @@ -151,9 +147,6 @@ You can create additional fixtures using factories: # Async client fixture (requires pytest-postgresql[async], pytest-asyncio >= 0.24) postgresql_my_async = factories.postgresql_async('postgresql_my_proc') -All factories accept an optional ``scope`` parameter (``"session"``, ``"package"``, ``"module"``, ``"class"``, or ``"function"``). -Defaults are unchanged: ``"function"`` for client fixtures and ``"session"`` for process fixtures. - .. note:: Each process fixture can be configured independently through factory arguments. diff --git a/newsfragments/1235.feature.rst b/newsfragments/1235.feature.rst index 6bd324fb..08ec7659 100644 --- a/newsfragments/1235.feature.rst +++ b/newsfragments/1235.feature.rst @@ -1,3 +1,2 @@ Added async PostgreSQL fixture support via ``postgresql_async`` factory and ``AsyncDatabaseJanitor``. -Added configurable fixture ``scope`` parameter to ``postgresql``, ``postgresql_async``, ``postgresql_proc``, and ``postgresql_noproc`` factories (defaults preserved: ``"function"`` for client fixtures, ``"session"`` for process fixtures). Added optional ``async`` extra (``pip install pytest-postgresql[async]``) providing ``pytest-asyncio`` (>= 0.24) and ``aiofiles`` dependencies. diff --git a/pytest_postgresql/factories/client.py b/pytest_postgresql/factories/client.py index 1e105454..74d3f303 100644 --- a/pytest_postgresql/factories/client.py +++ b/pytest_postgresql/factories/client.py @@ -28,7 +28,6 @@ from pytest_postgresql.executor import PostgreSQLExecutor from pytest_postgresql.executor_noop import NoopExecutor from pytest_postgresql.janitor import AsyncDatabaseJanitor, DatabaseJanitor -from pytest_postgresql.types import FixtureScopeT pytest_asyncio: Any = None try: @@ -43,7 +42,6 @@ def postgresql( process_fixture_name: str, dbname: str | None = None, isolation_level: "psycopg.IsolationLevel | None" = None, - scope: FixtureScopeT = "function", ) -> Callable[[FixtureRequest], Iterator[Connection]]: """Return connection fixture factory for PostgreSQL. @@ -51,11 +49,10 @@ def postgresql( :param dbname: database name :param isolation_level: optional postgresql isolation level defaults to server's default - :param scope: fixture scope; by default "function" which is recommended. :returns: function which makes a connection to postgresql """ - @pytest.fixture(scope=scope) + @pytest.fixture def postgresql_factory(request: FixtureRequest) -> Iterator[Connection]: """Fixture connection factory for PostgreSQL. @@ -102,24 +99,20 @@ def postgresql_async( process_fixture_name: str, dbname: str | None = None, isolation_level: "psycopg.IsolationLevel | None" = None, - scope: FixtureScopeT = "function", ) -> Callable[[FixtureRequest], AsyncIterator[AsyncConnection]]: """Return async connection fixture factory for PostgreSQL. Requires ``pytest-asyncio`` >= 0.24 (install via ``pip install pytest-postgresql[async]``). - Scoped fixtures pass ``loop_scope=scope`` to ``pytest_asyncio.fixture``, which is only - supported in pytest-asyncio 0.24 and later. :param process_fixture_name: name of the process fixture :param dbname: database name :param isolation_level: optional postgresql isolation level defaults to server's default - :param scope: fixture scope; by default "function" which is recommended. :returns: function which makes an async connection to postgresql """ if pytest_asyncio is None: - @pytest.fixture(scope=scope) + @pytest.fixture def postgresql_async_stub(request: FixtureRequest) -> None: """Sync stub that raises ImportError when pytest-asyncio is absent.""" raise ImportError( @@ -134,7 +127,7 @@ def postgresql_async_stub(request: FixtureRequest) -> None: assert pytest_asyncio is not None - @pytest_asyncio.fixture(scope=scope, loop_scope=scope) # type: ignore[untyped-decorator] + @pytest_asyncio.fixture # type: ignore[untyped-decorator] async def postgresql_async_factory(request: FixtureRequest) -> AsyncIterator[AsyncConnection]: """Async connection fixture factory for PostgreSQL. diff --git a/pytest_postgresql/factories/noprocess.py b/pytest_postgresql/factories/noprocess.py index 8af27c37..2d7f8b49 100644 --- a/pytest_postgresql/factories/noprocess.py +++ b/pytest_postgresql/factories/noprocess.py @@ -27,7 +27,6 @@ from pytest_postgresql.config import get_config from pytest_postgresql.executor_noop import NoopExecutor from pytest_postgresql.janitor import DatabaseJanitor -from pytest_postgresql.types import FixtureScopeT def xdistify_dbname(dbname: str) -> str: @@ -47,7 +46,6 @@ def postgresql_noproc( options: str = "", load: list[Callable | str | Path] | None = None, depends_on: str | None = None, - scope: FixtureScopeT = "session", ) -> Callable[[FixtureRequest], Iterator[NoopExecutor]]: """Postgresql noprocess factory. @@ -59,11 +57,10 @@ def postgresql_noproc( :param options: Postgresql connection options :param load: List of functions used to initialize database's template. :param depends_on: Optional name of the fixture to depend on. - :param scope: fixture scope; by default "session" which is recommended. :returns: function which makes a postgresql process """ - @pytest.fixture(scope=scope) + @pytest.fixture(scope="session") def postgresql_noproc_fixture(request: FixtureRequest) -> Iterator[NoopExecutor]: """Noop Process fixture for PostgreSQL. diff --git a/pytest_postgresql/factories/process.py b/pytest_postgresql/factories/process.py index dd30ff61..31c86148 100644 --- a/pytest_postgresql/factories/process.py +++ b/pytest_postgresql/factories/process.py @@ -32,7 +32,6 @@ from pytest_postgresql.exceptions import ExecutableMissingException from pytest_postgresql.executor import PostgreSQLExecutor from pytest_postgresql.janitor import DatabaseJanitor -from pytest_postgresql.types import FixtureScopeT PortType = port_for.PortType # mypy requires explicit export @@ -82,7 +81,6 @@ def postgresql_proc( unixsocketdir: str | None = None, postgres_options: str | None = None, load: list[Callable | str | Path] | None = None, - scope: FixtureScopeT = "session", ) -> Callable[[FixtureRequest, TempPathFactory], Iterator[PostgreSQLExecutor]]: """Postgresql process factory. @@ -103,11 +101,10 @@ def postgresql_proc( :param unixsocketdir: directory to create postgresql's unixsockets :param postgres_options: Postgres executable options for use by pg_ctl :param load: List of functions used to initialize database's template. - :param scope: fixture scope; by default "session" which is recommended. :returns: function which makes a postgresql process """ - @pytest.fixture(scope=scope) + @pytest.fixture(scope="session") def postgresql_proc_fixture( request: FixtureRequest, tmp_path_factory: TempPathFactory ) -> Iterator[PostgreSQLExecutor]: diff --git a/pytest_postgresql/types.py b/pytest_postgresql/types.py deleted file mode 100644 index e5f35043..00000000 --- a/pytest_postgresql/types.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Pytest PostgreSQL types.""" - -from typing import Literal - -FixtureScopeT = Literal["session", "package", "module", "class", "function"] diff --git a/tests/test_executor.py b/tests/test_executor.py index 8474a9be..10f52b75 100644 --- a/tests/test_executor.py +++ b/tests/test_executor.py @@ -298,7 +298,7 @@ def test_postgresql_proc_removes_port_lock_on_teardown( tmp_path_factory: pytest.TempPathFactory, ) -> None: """Port sentinel file is removed when the process fixture tears down.""" - fixture_func = postgresql_proc(port=None, scope="function") + fixture_func = postgresql_proc(port=None) raw_func = getattr(fixture_func, "__wrapped__", fixture_func) port_path = tmp_path_factory.getbasetemp() @@ -350,7 +350,7 @@ def test_postgresql_proc_removes_port_lock_on_setup_failure( tmp_path_factory: pytest.TempPathFactory, ) -> None: """Port sentinel file is removed when fixture setup fails after claiming a port.""" - fixture_func = postgresql_proc(port=None, scope="function") + fixture_func = postgresql_proc(port=None) raw_func = getattr(fixture_func, "__wrapped__", fixture_func) port_path = tmp_path_factory.getbasetemp() From c98d05b3e9074cac346a57789cbe11b602b58392 Mon Sep 17 00:00:00 2001 From: tboy1337 Date: Mon, 22 Jun 2026 11:39:34 +0700 Subject: [PATCH 34/42] Update README to remove unnecessary version details for async fixtures and modify test cases to handle port initialization correctly by passing None instead of -1. This change ensures better compatibility with various configurations. --- README.rst | 4 +--- tests/test_executor.py | 12 ++++++------ 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/README.rst b/README.rst index bf3840ce..77c3b555 100644 --- a/README.rst +++ b/README.rst @@ -47,9 +47,7 @@ Quick Start This installs: * ``pytest-asyncio`` (>= 0.24) — required for ``@pytest.mark.asyncio`` and - ``postgresql_async`` fixtures. Version 0.24 or newer is required because - scoped async fixtures rely on the ``loop_scope`` argument introduced in - that release. + ``postgresql_async`` fixtures. * ``aiofiles`` (>= 23.0) — required only when loading SQL files via the async loader (``sql_async``). diff --git a/tests/test_executor.py b/tests/test_executor.py index 10f52b75..c9b5ce83 100644 --- a/tests/test_executor.py +++ b/tests/test_executor.py @@ -90,7 +90,7 @@ def test_executor_init_with_password( config = get_config(request) monkeypatch.setenv("LC_ALL", locale) pg_exe = process._pg_exe(None, config) - port = process._pg_port(-1, config, []) + port = process._pg_port(None, config, []) tmpdir = tmp_path_factory.mktemp(f"pytest-postgresql-{request.node.name}") datadir, logfile_path = process._prepare_dir(tmpdir, port) executor = PostgreSQLExecutor( @@ -114,7 +114,7 @@ def test_executor_init_bad_tmp_path( r"""Test init with \ and space chars in the path.""" config = get_config(request) pg_exe = process._pg_exe(None, config) - port = process._pg_port(-1, config, []) + port = process._pg_port(None, config, []) tmpdir = tmp_path_factory.mktemp(f"pytest-postgresql-{request.node.name}") / r"a bad\path/" tmpdir.mkdir(parents=True, exist_ok=True) datadir, logfile_path = process._prepare_dir(tmpdir, port) @@ -199,7 +199,7 @@ def test_executor_with_special_chars_in_all_paths( """ config = get_config(request) pg_exe = process._pg_exe(None, config) - port = process._pg_port(-1, config, []) + port = process._pg_port(None, config, []) # Create a tmpdir with spaces in the name tmpdir = tmp_path_factory.mktemp(f"pytest-postgresql-{request.node.name}") / "my test dir" tmpdir.mkdir(exist_ok=True) @@ -391,7 +391,7 @@ def test_actual_postgresql_start_windows( """ config = get_config(request) pg_exe = process._pg_exe(None, config) - port = process._pg_port(-1, config, []) + port = process._pg_port(None, config, []) tmpdir = tmp_path_factory.mktemp(f"pytest-postgresql-{request.node.name}") datadir, logfile_path = process._prepare_dir(tmpdir, port) @@ -430,7 +430,7 @@ def test_actual_postgresql_start_unix( """ config = get_config(request) pg_exe = process._pg_exe(None, config) - port = process._pg_port(-1, config, []) + port = process._pg_port(None, config, []) tmpdir = tmp_path_factory.mktemp(f"pytest-postgresql-{request.node.name}") datadir, logfile_path = process._prepare_dir(tmpdir, port) @@ -466,7 +466,7 @@ def test_actual_postgresql_start_darwin( """ config = get_config(request) pg_exe = process._pg_exe(None, config) - port = process._pg_port(-1, config, []) + port = process._pg_port(None, config, []) tmpdir = tmp_path_factory.mktemp(f"pytest-postgresql-{request.node.name}") datadir, logfile_path = process._prepare_dir(tmpdir, port) From c5270ff427184c23c6edb2d04837038db969b3b6 Mon Sep 17 00:00:00 2001 From: tboy1337 Date: Mon, 22 Jun 2026 13:47:17 +0700 Subject: [PATCH 35/42] Update pytest-asyncio dependency to version 1.4 across configuration files and documentation. Enhance README to reflect changes in async fixture requirements and clarify Windows event loop handling for compatibility with psycopg async. --- Pipfile | 2 +- README.rst | 12 ++++-- newsfragments/1235.feature.rst | 2 +- newsfragments/1295.bugfix.rst | 1 + pyproject.toml | 2 +- pytest_postgresql/factories/client.py | 4 +- pytest_postgresql/plugin.py | 33 ++++++++++++++- tests/test_factory_errors.py | 2 +- tests/test_plugin_asyncio.py | 61 +++++++++++++++++++++++++++ 9 files changed, 107 insertions(+), 12 deletions(-) create mode 100644 newsfragments/1295.bugfix.rst create mode 100644 tests/test_plugin_asyncio.py diff --git a/Pipfile b/Pipfile index 871ac051..dd60baf0 100644 --- a/Pipfile +++ b/Pipfile @@ -14,7 +14,7 @@ pytest-postgresql = {path = ".", editable = true} [dev-packages] towncrier = "==25.8.0" psycopg-binary = {version = "==3.3.4", markers="implementation_name == 'cpython'"} -pytest-asyncio = ">=0.24" +pytest-asyncio = ">=1.4" aiofiles = ">=23.0" types-aiofiles = ">=23.0" coverage = ">=7.14.1" diff --git a/README.rst b/README.rst index 77c3b555..088e0c94 100644 --- a/README.rst +++ b/README.rst @@ -46,11 +46,15 @@ Quick Start This installs: - * ``pytest-asyncio`` (>= 0.24) — required for ``@pytest.mark.asyncio`` and + * ``pytest-asyncio`` (>= 1.4) — required for ``@pytest.mark.asyncio`` and ``postgresql_async`` fixtures. * ``aiofiles`` (>= 23.0) — required only when loading SQL files via the async loader (``sql_async``). + On Windows, the plugin configures a ``SelectorEventLoop`` automatically (required + by ``psycopg`` async). On Python 3.14+, this uses pytest-asyncio's loop-factory + hook instead of the deprecated asyncio policy API. + .. note:: While this plugin requires ``psycopg`` 3 to manage the database, your application code can still use ``psycopg`` 2. @@ -104,7 +108,7 @@ The plugin provides two main types of fixtures: * **postgresql** - A function-scoped fixture. It returns a connected ``psycopg.Connection``. After each test, it terminates leftover connections and drops the test database to ensure isolation. * **postgresql_async** - The async counterpart. It returns a connected ``psycopg.AsyncConnection``. - Requires ``pytest-postgresql[async]`` (``pytest-asyncio`` >= 0.24), and each test must be + Requires ``pytest-postgresql[async]`` (``pytest-asyncio`` >= 1.4), and each test must be marked with ``@pytest.mark.asyncio``. **Async fixtures** @@ -115,7 +119,7 @@ The plugin provides two main types of fixtures: .. code-block:: text - pytest-asyncio >= 0.24 + pytest-asyncio >= 1.4 aiofiles >= 23.0 # only for async SQL file loading If ``pytest-asyncio`` is missing, fixture setup raises ``ImportError``. @@ -142,7 +146,7 @@ You can create additional fixtures using factories: # Create a client fixture that uses the custom process postgresql_my = factories.postgresql('postgresql_my_proc') - # Async client fixture (requires pytest-postgresql[async], pytest-asyncio >= 0.24) + # Async client fixture (requires pytest-postgresql[async], pytest-asyncio >= 1.4) postgresql_my_async = factories.postgresql_async('postgresql_my_proc') .. note:: diff --git a/newsfragments/1235.feature.rst b/newsfragments/1235.feature.rst index 08ec7659..1a355d0a 100644 --- a/newsfragments/1235.feature.rst +++ b/newsfragments/1235.feature.rst @@ -1,2 +1,2 @@ Added async PostgreSQL fixture support via ``postgresql_async`` factory and ``AsyncDatabaseJanitor``. -Added optional ``async`` extra (``pip install pytest-postgresql[async]``) providing ``pytest-asyncio`` (>= 0.24) and ``aiofiles`` dependencies. +Added optional ``async`` extra (``pip install pytest-postgresql[async]``) providing ``pytest-asyncio`` (>= 1.4) and ``aiofiles`` dependencies. diff --git a/newsfragments/1295.bugfix.rst b/newsfragments/1295.bugfix.rst new file mode 100644 index 00000000..962f8919 --- /dev/null +++ b/newsfragments/1295.bugfix.rst @@ -0,0 +1 @@ +Fixed ``DeprecationWarning`` on Python 3.14 from deprecated asyncio event-loop policy usage on Windows; use pytest-asyncio loop factories instead. diff --git a/pyproject.toml b/pyproject.toml index 1675e45c..0bcddb93 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,7 +38,7 @@ requires-python = ">= 3.10" [project.optional-dependencies] async = [ - "pytest-asyncio >= 0.24", + "pytest-asyncio >= 1.4", "aiofiles >= 23.0" ] diff --git a/pytest_postgresql/factories/client.py b/pytest_postgresql/factories/client.py index 74d3f303..b49a31ea 100644 --- a/pytest_postgresql/factories/client.py +++ b/pytest_postgresql/factories/client.py @@ -102,7 +102,7 @@ def postgresql_async( ) -> Callable[[FixtureRequest], AsyncIterator[AsyncConnection]]: """Return async connection fixture factory for PostgreSQL. - Requires ``pytest-asyncio`` >= 0.24 (install via ``pip install pytest-postgresql[async]``). + Requires ``pytest-asyncio`` >= 1.4 (install via ``pip install pytest-postgresql[async]``). :param process_fixture_name: name of the process fixture :param dbname: database name @@ -116,7 +116,7 @@ def postgresql_async( def postgresql_async_stub(request: FixtureRequest) -> None: """Sync stub that raises ImportError when pytest-asyncio is absent.""" raise ImportError( - "pytest-asyncio >= 0.24 is required for async fixtures. " + "pytest-asyncio >= 1.4 is required for async fixtures. " "Install it with: pip install pytest-postgresql[async]" ) diff --git a/pytest_postgresql/plugin.py b/pytest_postgresql/plugin.py index 017ef81f..beb5c205 100644 --- a/pytest_postgresql/plugin.py +++ b/pytest_postgresql/plugin.py @@ -18,11 +18,14 @@ """Plugin module of pytest-postgresql.""" import asyncio +import selectors import sys +from collections.abc import Callable from tempfile import gettempdir import pytest from _pytest.config.argparsing import Parser +from packaging.version import parse from pytest_postgresql import factories @@ -45,10 +48,36 @@ ) +def _windows_selector_event_loop() -> asyncio.AbstractEventLoop: + return asyncio.SelectorEventLoop(selectors.SelectSelector()) + + +def _pytest_asyncio_supports_loop_factories() -> bool: + try: + import pytest_asyncio + + return parse(pytest_asyncio.__version__) >= parse("1.4.0") + except ImportError: + return False + + def pytest_configure(config: pytest.Config) -> None: """Configure pytest-postgresql plugin.""" - if sys.platform == "win32" and config.pluginmanager.has_plugin("asyncio"): - asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) + if sys.platform != "win32" or not config.pluginmanager.has_plugin("asyncio"): + return + if sys.version_info >= (3, 14) or _pytest_asyncio_supports_loop_factories(): + return + asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) + + +def pytest_asyncio_loop_factories( + config: pytest.Config, + item: pytest.Item, +) -> dict[str, Callable[[], asyncio.AbstractEventLoop]] | None: + """Use SelectorEventLoop on Windows for psycopg async compatibility.""" + if sys.platform != "win32": + return None + return {"selector": _windows_selector_event_loop} def pytest_addoption(parser: Parser) -> None: diff --git a/tests/test_factory_errors.py b/tests/test_factory_errors.py index 61082c51..da12ae8a 100644 --- a/tests/test_factory_errors.py +++ b/tests/test_factory_errors.py @@ -29,5 +29,5 @@ def test_postgresql_async_raises_on_use_without_pytest_asyncio() -> None: # pytest 8+ wraps fixtures to prevent direct calls; unwrap first. raw_func = getattr(fixture_func, "__wrapped__", fixture_func) assert not hasattr(raw_func, "__await__"), "stub must be a sync function, not a coroutine" - with pytest.raises(ImportError, match=r"pytest-asyncio >= 0\.24"): + with pytest.raises(ImportError, match=r"pytest-asyncio >= 1\.4"): raw_func(None) # type: ignore[arg-type] diff --git a/tests/test_plugin_asyncio.py b/tests/test_plugin_asyncio.py new file mode 100644 index 00000000..535e64bb --- /dev/null +++ b/tests/test_plugin_asyncio.py @@ -0,0 +1,61 @@ +"""Tests for Windows asyncio loop configuration in the plugin.""" + +import asyncio +import sys +from unittest.mock import MagicMock, patch + +import pytest + +from pytest_postgresql.plugin import ( + _windows_selector_event_loop, + pytest_asyncio_loop_factories, + pytest_configure, +) + + +@pytest.mark.skipif(sys.version_info < (3, 14), reason="Deprecation applies from Python 3.14") +def test_pytest_configure_skips_deprecated_policy_on_python_314() -> None: + """pytest_configure must not call deprecated asyncio policy APIs on Python 3.14+.""" + config = MagicMock() + config.pluginmanager.has_plugin.return_value = True + + with patch.object(asyncio, "set_event_loop_policy") as set_policy: + pytest_configure(config) + + set_policy.assert_not_called() + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows-specific loop factory") +def test_windows_selector_loop_factory() -> None: + """Windows selector loop factory returns a SelectorEventLoop instance.""" + loop = _windows_selector_event_loop() + try: + assert isinstance(loop, asyncio.SelectorEventLoop) + finally: + loop.close() + + +def test_pytest_asyncio_loop_factories_returns_none_on_non_windows() -> None: + """Non-Windows platforms do not override pytest-asyncio loop factories.""" + if sys.platform == "win32": + pytest.skip("Windows returns a selector factory mapping") + + config = MagicMock() + item = MagicMock() + assert pytest_asyncio_loop_factories(config, item) is None + + +@pytest.mark.skipif(sys.platform != "win32", reason="Windows-specific loop factory hook") +def test_pytest_asyncio_loop_factories_on_windows() -> None: + """Windows configures a single selector loop factory for pytest-asyncio.""" + config = MagicMock() + item = MagicMock() + factories = pytest_asyncio_loop_factories(config, item) + + assert factories is not None + assert set(factories) == {"selector"} + loop = factories["selector"]() + try: + assert isinstance(loop, asyncio.SelectorEventLoop) + finally: + loop.close() From 470b4aac914bd08c9111c596344c88504db96d93 Mon Sep 17 00:00:00 2001 From: tboy1337 Date: Mon, 22 Jun 2026 14:13:55 +0700 Subject: [PATCH 36/42] Enhance README to clarify the handling of Windows event loop policy with pytest-asyncio and update test cases to ensure compatibility with the new loop-factory hook. Add optional hook implementation for pytest-asyncio loop factories. --- README.rst | 5 +++-- pytest_postgresql/plugin.py | 1 + tests/test_plugin_asyncio.py | 5 ++++- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/README.rst b/README.rst index 088e0c94..8b56f0f8 100644 --- a/README.rst +++ b/README.rst @@ -52,8 +52,9 @@ Quick Start async loader (``sql_async``). On Windows, the plugin configures a ``SelectorEventLoop`` automatically (required - by ``psycopg`` async). On Python 3.14+, this uses pytest-asyncio's loop-factory - hook instead of the deprecated asyncio policy API. + by ``psycopg`` async). With ``pytest-asyncio`` >= 1.4, this is done via the + loop-factory hook on all supported Python versions. On Python 3.14+, the legacy + ``asyncio`` policy fallback is not used because that API is deprecated. .. note:: diff --git a/pytest_postgresql/plugin.py b/pytest_postgresql/plugin.py index beb5c205..3cd64e12 100644 --- a/pytest_postgresql/plugin.py +++ b/pytest_postgresql/plugin.py @@ -70,6 +70,7 @@ def pytest_configure(config: pytest.Config) -> None: asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) +@pytest.hookimpl(optionalhook=True) def pytest_asyncio_loop_factories( config: pytest.Config, item: pytest.Item, diff --git a/tests/test_plugin_asyncio.py b/tests/test_plugin_asyncio.py index 535e64bb..6d3a8201 100644 --- a/tests/test_plugin_asyncio.py +++ b/tests/test_plugin_asyncio.py @@ -19,7 +19,10 @@ def test_pytest_configure_skips_deprecated_policy_on_python_314() -> None: config = MagicMock() config.pluginmanager.has_plugin.return_value = True - with patch.object(asyncio, "set_event_loop_policy") as set_policy: + with ( + patch("pytest_postgresql.plugin.sys.platform", "win32"), + patch.object(asyncio, "set_event_loop_policy") as set_policy, + ): pytest_configure(config) set_policy.assert_not_called() From c21daacf6a707911be1e5d384b8f5bf96912d23f Mon Sep 17 00:00:00 2001 From: tboy1337 Date: Mon, 22 Jun 2026 14:29:24 +0700 Subject: [PATCH 37/42] Refactor Windows event loop handling in pytest-postgresql plugin to improve compatibility with asyncio. Introduce helper functions to check platform and Python version, and update tests to reflect these changes. --- pytest_postgresql/plugin.py | 20 ++++++++++++++------ tests/test_plugin_asyncio.py | 6 ++---- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/pytest_postgresql/plugin.py b/pytest_postgresql/plugin.py index 3cd64e12..b250d619 100644 --- a/pytest_postgresql/plugin.py +++ b/pytest_postgresql/plugin.py @@ -18,14 +18,14 @@ """Plugin module of pytest-postgresql.""" import asyncio +import platform import selectors -import sys from collections.abc import Callable from tempfile import gettempdir import pytest from _pytest.config.argparsing import Parser -from packaging.version import parse +from packaging.version import Version, parse from pytest_postgresql import factories @@ -61,13 +61,21 @@ def _pytest_asyncio_supports_loop_factories() -> bool: return False +def _is_windows() -> bool: + return platform.system() == "Windows" + + +def _uses_deprecated_asyncio_policy_on_windows() -> bool: + return Version(platform.python_version()) < Version("3.14") and not _pytest_asyncio_supports_loop_factories() + + def pytest_configure(config: pytest.Config) -> None: """Configure pytest-postgresql plugin.""" - if sys.platform != "win32" or not config.pluginmanager.has_plugin("asyncio"): + if not _is_windows() or not config.pluginmanager.has_plugin("asyncio"): return - if sys.version_info >= (3, 14) or _pytest_asyncio_supports_loop_factories(): + if not _uses_deprecated_asyncio_policy_on_windows(): return - asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) + asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) # type: ignore[attr-defined] @pytest.hookimpl(optionalhook=True) @@ -76,7 +84,7 @@ def pytest_asyncio_loop_factories( item: pytest.Item, ) -> dict[str, Callable[[], asyncio.AbstractEventLoop]] | None: """Use SelectorEventLoop on Windows for psycopg async compatibility.""" - if sys.platform != "win32": + if not _is_windows(): return None return {"selector": _windows_selector_event_loop} diff --git a/tests/test_plugin_asyncio.py b/tests/test_plugin_asyncio.py index 6d3a8201..d539251f 100644 --- a/tests/test_plugin_asyncio.py +++ b/tests/test_plugin_asyncio.py @@ -20,7 +20,7 @@ def test_pytest_configure_skips_deprecated_policy_on_python_314() -> None: config.pluginmanager.has_plugin.return_value = True with ( - patch("pytest_postgresql.plugin.sys.platform", "win32"), + patch("pytest_postgresql.plugin.platform.system", return_value="Windows"), patch.object(asyncio, "set_event_loop_policy") as set_policy, ): pytest_configure(config) @@ -38,11 +38,9 @@ def test_windows_selector_loop_factory() -> None: loop.close() +@pytest.mark.skipif(sys.platform == "win32", reason="Windows returns a selector factory mapping") def test_pytest_asyncio_loop_factories_returns_none_on_non_windows() -> None: """Non-Windows platforms do not override pytest-asyncio loop factories.""" - if sys.platform == "win32": - pytest.skip("Windows returns a selector factory mapping") - config = MagicMock() item = MagicMock() assert pytest_asyncio_loop_factories(config, item) is None From 679d8b9f1a32afc7e434429871fe9076db178376 Mon Sep 17 00:00:00 2001 From: tboy1337 Date: Mon, 22 Jun 2026 14:44:56 +0700 Subject: [PATCH 38/42] Refactor pytest-asyncio loop factory implementation in the Windows context to ensure it is only registered when on a Windows platform. Update tests to verify the correct behavior of the loop factory registration based on the operating system. --- pytest_postgresql/plugin.py | 18 +++++++++--------- tests/test_plugin_asyncio.py | 19 +++++++------------ 2 files changed, 16 insertions(+), 21 deletions(-) diff --git a/pytest_postgresql/plugin.py b/pytest_postgresql/plugin.py index b250d619..90ea8918 100644 --- a/pytest_postgresql/plugin.py +++ b/pytest_postgresql/plugin.py @@ -78,15 +78,15 @@ def pytest_configure(config: pytest.Config) -> None: asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) # type: ignore[attr-defined] -@pytest.hookimpl(optionalhook=True) -def pytest_asyncio_loop_factories( - config: pytest.Config, - item: pytest.Item, -) -> dict[str, Callable[[], asyncio.AbstractEventLoop]] | None: - """Use SelectorEventLoop on Windows for psycopg async compatibility.""" - if not _is_windows(): - return None - return {"selector": _windows_selector_event_loop} +if _is_windows(): + + @pytest.hookimpl(optionalhook=True) + def pytest_asyncio_loop_factories( + config: pytest.Config, + item: pytest.Item, + ) -> dict[str, Callable[[], asyncio.AbstractEventLoop]]: + """Use SelectorEventLoop on Windows for psycopg async compatibility.""" + return {"selector": _windows_selector_event_loop} def pytest_addoption(parser: Parser) -> None: diff --git a/tests/test_plugin_asyncio.py b/tests/test_plugin_asyncio.py index d539251f..290a499f 100644 --- a/tests/test_plugin_asyncio.py +++ b/tests/test_plugin_asyncio.py @@ -6,11 +6,8 @@ import pytest -from pytest_postgresql.plugin import ( - _windows_selector_event_loop, - pytest_asyncio_loop_factories, - pytest_configure, -) +import pytest_postgresql.plugin as plugin_module +from pytest_postgresql.plugin import _windows_selector_event_loop, pytest_configure @pytest.mark.skipif(sys.version_info < (3, 14), reason="Deprecation applies from Python 3.14") @@ -38,12 +35,10 @@ def test_windows_selector_loop_factory() -> None: loop.close() -@pytest.mark.skipif(sys.platform == "win32", reason="Windows returns a selector factory mapping") -def test_pytest_asyncio_loop_factories_returns_none_on_non_windows() -> None: - """Non-Windows platforms do not override pytest-asyncio loop factories.""" - config = MagicMock() - item = MagicMock() - assert pytest_asyncio_loop_factories(config, item) is None +@pytest.mark.skipif(sys.platform == "win32", reason="Windows registers loop factory hook at import") +def test_loop_factory_hook_not_registered_on_non_windows() -> None: + """Non-Windows platforms must not register pytest_asyncio_loop_factories.""" + assert not hasattr(plugin_module, "pytest_asyncio_loop_factories") @pytest.mark.skipif(sys.platform != "win32", reason="Windows-specific loop factory hook") @@ -51,7 +46,7 @@ def test_pytest_asyncio_loop_factories_on_windows() -> None: """Windows configures a single selector loop factory for pytest-asyncio.""" config = MagicMock() item = MagicMock() - factories = pytest_asyncio_loop_factories(config, item) + factories = plugin_module.pytest_asyncio_loop_factories(config, item) assert factories is not None assert set(factories) == {"selector"} From 9bdf2153f4b45b6292c89fad07196d8a7f7e69ab Mon Sep 17 00:00:00 2001 From: tboy1337 Date: Mon, 22 Jun 2026 15:08:49 +0700 Subject: [PATCH 39/42] Add support for optional pytest-asyncio import in plugin.py This update introduces a conditional import for pytest-asyncio, allowing the plugin to function without it. The version check for pytest-asyncio has been refactored to improve clarity and maintain compatibility with versions below 1.4. This change enhances the plugin's flexibility in various environments. --- pytest_postgresql/plugin.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/pytest_postgresql/plugin.py b/pytest_postgresql/plugin.py index 90ea8918..56b27065 100644 --- a/pytest_postgresql/plugin.py +++ b/pytest_postgresql/plugin.py @@ -22,6 +22,7 @@ import selectors from collections.abc import Callable from tempfile import gettempdir +from typing import Any import pytest from _pytest.config.argparsing import Parser @@ -29,6 +30,14 @@ from pytest_postgresql import factories +pytest_asyncio: Any = None +try: + import pytest_asyncio as _pytest_asyncio_module + + pytest_asyncio = _pytest_asyncio_module +except ImportError: + pass + _help_executable = "Path to PostgreSQL executable" _help_host = "Host at which PostgreSQL will accept connections" _help_port = "Port at which PostgreSQL will accept connections" @@ -53,12 +62,9 @@ def _windows_selector_event_loop() -> asyncio.AbstractEventLoop: def _pytest_asyncio_supports_loop_factories() -> bool: - try: - import pytest_asyncio - - return parse(pytest_asyncio.__version__) >= parse("1.4.0") - except ImportError: + if pytest_asyncio is None: return False + return parse(pytest_asyncio.__version__) >= parse("1.4.0") def _is_windows() -> bool: From 9e8662714acfeba636f7b8577b17c58213e9f4d2 Mon Sep 17 00:00:00 2001 From: tboy1337 Date: Mon, 22 Jun 2026 16:21:08 +0700 Subject: [PATCH 40/42] ci: re-trigger workflow after runner communication flake on 3.10 The postgresql_oldest / postgres (3.10) job failed due to a hosted runner losing communication after 46m; all other matrix jobs passed. Co-authored-by: Cursor From 214179f150ac14c111375d1adca2f0b45af9f8cd Mon Sep 17 00:00:00 2001 From: tboy1337 Date: Mon, 22 Jun 2026 16:26:51 +0700 Subject: [PATCH 41/42] Refactor loader functions to remove maxsplit in regex for import path parsing This change simplifies the regex used in `build_loader` and `build_loader_async` functions, allowing for deeper nested import paths without limiting the split. Additionally, new tests have been added to verify the correct behavior of these functions with deeply nested import paths. --- pytest_postgresql/loader.py | 4 ++-- tests/test_loader.py | 23 +++++++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/pytest_postgresql/loader.py b/pytest_postgresql/loader.py index 63f025ba..e73e2852 100644 --- a/pytest_postgresql/loader.py +++ b/pytest_postgresql/loader.py @@ -19,7 +19,7 @@ def build_loader(load: Callable | str | Path) -> Callable: if isinstance(load, Path): return partial(sql, load) elif isinstance(load, str): - loader_parts = re.split("[.:]", load, maxsplit=2) + loader_parts = re.split("[.:]", load) import_path = ".".join(loader_parts[:-1]) loader_name = loader_parts[-1] _temp_import = importlib.import_module(import_path) @@ -43,7 +43,7 @@ def build_loader_async(load: Callable | str | Path) -> Callable: if isinstance(load, Path): return partial(sql_async, load) elif isinstance(load, str): - loader_parts = re.split("[.:]", load, maxsplit=2) + loader_parts = re.split("[.:]", load) import_path = ".".join(loader_parts[:-1]) loader_name = loader_parts[-1] _temp_import = importlib.import_module(import_path) diff --git a/tests/test_loader.py b/tests/test_loader.py index 5d69cdbb..ce931b8e 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -1,6 +1,7 @@ """Tests for the `build_loader` function.""" from pathlib import Path +from types import ModuleType from unittest.mock import patch import pytest @@ -20,6 +21,17 @@ def test_loader_callables_dot_separator() -> None: assert build_loader("tests.loader.load_database") == load_database +def test_loader_deeply_nested_import_path() -> None: + """All path segments before the final delimiter are joined as the import path.""" + sentinel = object() + fake_module = ModuleType("fake_module") + fake_module.my_loader = sentinel # type: ignore[attr-defined] + with patch("pytest_postgresql.loader.importlib.import_module", return_value=fake_module) as import_mock: + result = build_loader("a.b.c.d:my_loader") + import_mock.assert_called_once_with("a.b.c.d") + assert result is sentinel + + @pytest.mark.asyncio async def test_loader_callables_async() -> None: """Async test handling callables in build_loader_async.""" @@ -38,6 +50,17 @@ async def test_loader_callables_async_dot_separator() -> None: assert build_loader_async("tests.loader.load_database") == load_database +def test_loader_async_deeply_nested_import_path() -> None: + """build_loader_async splits all path segments before the final loader name.""" + sentinel = object() + fake_module = ModuleType("fake_module") + fake_module.my_loader = sentinel # type: ignore[attr-defined] + with patch("pytest_postgresql.loader.importlib.import_module", return_value=fake_module) as import_mock: + result = build_loader_async("a.b.c.d:my_loader") + import_mock.assert_called_once_with("a.b.c.d") + assert result is sentinel + + def test_loader_sql() -> None: """Test returning partial running sql for the sql file path.""" sql_path = Path("test_sql/eidastats.sql") From 4adf2edfe898ad6a38d0d6fc62df66645a65d4a8 Mon Sep 17 00:00:00 2001 From: tboy1337 Date: Sat, 27 Jun 2026 16:02:43 +0700 Subject: [PATCH 42/42] Enhance README and plugin.py to clarify Windows event loop handling for psycopg async Updated the README to explain the necessity of using a SelectorEventLoop on Windows due to incompatibility with the ProactorEventLoop. Added details on pytest-asyncio's loop-factory hook and its role in configuring the event loop. In plugin.py, improved comments and documentation regarding the registration of the SelectorEventLoop factory, ensuring clarity on its purpose and usage in async tests on Windows. --- README.rst | 16 ++++++++++++---- pytest_postgresql/plugin.py | 25 +++++++++++++++++++++++-- 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/README.rst b/README.rst index 8b56f0f8..f7f4a0e7 100644 --- a/README.rst +++ b/README.rst @@ -51,10 +51,18 @@ Quick Start * ``aiofiles`` (>= 23.0) — required only when loading SQL files via the async loader (``sql_async``). - On Windows, the plugin configures a ``SelectorEventLoop`` automatically (required - by ``psycopg`` async). With ``pytest-asyncio`` >= 1.4, this is done via the - loop-factory hook on all supported Python versions. On Python 3.14+, the legacy - ``asyncio`` policy fallback is not used because that API is deprecated. + On Windows, the plugin configures a ``SelectorEventLoop`` automatically. This + is required because ``psycopg`` async is incompatible with the default + ``ProactorEventLoop`` on Windows (`documented by psycopg + `_). Without it, + ``postgresql_async`` tests fail with ``Psycopg cannot use the + 'ProactorEventLoop' to run in async mode``. No extra configuration is needed + when you install ``pytest-postgresql[async]``. + + With ``pytest-asyncio`` >= 1.4, the plugin registers a selector loop factory via + pytest-asyncio's loop-factory hook on all supported Python versions. On + Python 3.14+, the legacy ``asyncio`` policy fallback is not used because that + API is deprecated. .. note:: diff --git a/pytest_postgresql/plugin.py b/pytest_postgresql/plugin.py index 56b27065..98ab78a9 100644 --- a/pytest_postgresql/plugin.py +++ b/pytest_postgresql/plugin.py @@ -56,8 +56,23 @@ "Use cautiously and not on CI." ) +# psycopg async cannot use Windows' default ProactorEventLoop (the default since +# Python 3.8). libpq socket I/O relies on selector APIs (add_reader / fileno) +# that Proactor does not support. See: +# https://www.psycopg.org/psycopg3/docs/advanced/async.html +# +# pytest-asyncio does not switch event loops for us, so without the hook below +# ``postgresql_async`` tests fail on Windows with: +# "Psycopg cannot use the 'ProactorEventLoop' to run in async mode". +# +# We register a SelectorEventLoop via pytest-asyncio's official +# ``pytest_asyncio_loop_factories`` hook (pytest-asyncio >= 1.4). A legacy +# ``WindowsSelectorEventLoopPolicy`` fallback in ``pytest_configure`` remains for +# Windows + Python < 3.14 when the loop-factory hook is unavailable. + def _windows_selector_event_loop() -> asyncio.AbstractEventLoop: + """Create a SelectorEventLoop for psycopg async on Windows.""" return asyncio.SelectorEventLoop(selectors.SelectSelector()) @@ -76,7 +91,7 @@ def _uses_deprecated_asyncio_policy_on_windows() -> bool: def pytest_configure(config: pytest.Config) -> None: - """Configure pytest-postgresql plugin.""" + """Set legacy Windows selector policy when loop-factory hook is unavailable.""" if not _is_windows() or not config.pluginmanager.has_plugin("asyncio"): return if not _uses_deprecated_asyncio_policy_on_windows(): @@ -91,7 +106,13 @@ def pytest_asyncio_loop_factories( config: pytest.Config, item: pytest.Item, ) -> dict[str, Callable[[], asyncio.AbstractEventLoop]]: - """Use SelectorEventLoop on Windows for psycopg async compatibility.""" + """Register a SelectorEventLoop factory for psycopg async on Windows. + + psycopg async is incompatible with the default ProactorEventLoop; see + https://www.psycopg.org/psycopg3/docs/advanced/async.html . pytest-asyncio + exposes this hook (>= 1.4) so plugins can supply a compatible loop without + requiring users to call ``asyncio.set_event_loop_policy`` themselves. + """ return {"selector": _windows_selector_event_loop}