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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 0 additions & 30 deletions .github/actions/detect-pg-ctl/action.yml

This file was deleted.

21 changes: 11 additions & 10 deletions .github/workflows/async-postgres.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,30 +48,31 @@ jobs:
run: |
if command -v pg_ctl >/dev/null 2>&1; then
PG_CTL_PATH=$(command -v pg_ctl)
echo "POSTGRESQL_EXEC=$PG_CTL_PATH" >> $GITHUB_ENV
echo "POSTGRESQL_EXEC=$PG_CTL_PATH" >> "$GITHUB_ENV"
elif [ -f "/opt/homebrew/opt/postgresql@${{ inputs.postgresql }}/bin/pg_ctl" ]; then
echo "POSTGRESQL_EXEC=/opt/homebrew/opt/postgresql@${{ inputs.postgresql }}/bin/pg_ctl" >> $GITHUB_ENV
echo "POSTGRESQL_EXEC=/opt/homebrew/opt/postgresql@${{ inputs.postgresql }}/bin/pg_ctl" >> "$GITHUB_ENV"
elif [ -f "/usr/local/opt/postgresql@${{ inputs.postgresql }}/bin/pg_ctl" ]; then
echo "POSTGRESQL_EXEC=/usr/local/opt/postgresql@${{ inputs.postgresql }}/bin/pg_ctl" >> $GITHUB_ENV
echo "POSTGRESQL_EXEC=/usr/local/opt/postgresql@${{ inputs.postgresql }}/bin/pg_ctl" >> "$GITHUB_ENV"
elif [ -f "/usr/lib/postgresql/${{ inputs.postgresql }}/bin/pg_ctl" ]; then
echo "POSTGRESQL_EXEC=/usr/lib/postgresql/${{ inputs.postgresql }}/bin/pg_ctl" >> $GITHUB_ENV
echo "POSTGRESQL_EXEC=/usr/lib/postgresql/${{ inputs.postgresql }}/bin/pg_ctl" >> "$GITHUB_ENV"
else
echo "Error: pg_ctl not found in expected locations"
exit 1
fi
- name: Detect PostgreSQL path on Windows
if: runner.os == 'Windows'
uses: ./.github/actions/detect-pg-ctl
with:
postgresql-version: ${{ inputs.postgresql }}
- name: update locale for tests
if: startsWith(inputs.os, 'ubuntu')
run: |
sudo locale-gen de_DE.UTF-8
- name: Run async tests
- name: Run async tests on Unix
if: runner.os != 'Windows'
uses: fizyk/actions-reuse/.github/actions/uv-run@7eefbd6ffc6c6787ad3be6ce93dcf475e3ae99b5 # v5.6.0
with:
command: python -m pytest -svv -m asyncio -p no:xdist --postgresql-exec="${{ env.POSTGRESQL_EXEC }}" -k "not docker" --basetemp="${{ runner.temp }}/pytest-basetemp"
- name: Run async tests on Windows
if: runner.os == 'Windows'
uses: fizyk/actions-reuse/.github/actions/uv-run@7eefbd6ffc6c6787ad3be6ce93dcf475e3ae99b5 # v5.6.0
with:
command: python -m pytest -svv -m asyncio -p no:xdist -k "not docker" --basetemp="${{ runner.temp }}/pytest-basetemp"
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: failure()
with:
Expand Down
7 changes: 2 additions & 5 deletions .github/workflows/single-postgres-windows.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,17 +43,14 @@ jobs:
- uses: ankane/setup-postgres@v1 # zizmor: ignore[unpinned-uses]
with:
postgres-version: ${{ inputs.postgresql }}
- uses: ./.github/actions/detect-pg-ctl
with:
postgresql-version: ${{ inputs.postgresql }}
- uses: fizyk/actions-reuse/.github/actions/uv-pytest-coverage@7eefbd6ffc6c6787ad3be6ce93dcf475e3ae99b5 # v5.6.0
with:
pytest-opts: -svv -p no:xdist --postgresql-exec="${{ env.POSTGRESQL_EXEC }}" -k "not docker" --basetemp="${{ runner.temp }}/pytest-basetemp"
pytest-opts: -svv -p no:xdist -k "not docker" --basetemp="${{ runner.temp }}/pytest-basetemp"
data-file: .coverage.serial
output-file: coverage-serial.xml
- uses: fizyk/actions-reuse/.github/actions/uv-pytest-coverage@7eefbd6ffc6c6787ad3be6ce93dcf475e3ae99b5 # v5.6.0
with:
pytest-opts: -n auto --dist loadgroup --max-worker-restart 0 --postgresql-exec="${{ env.POSTGRESQL_EXEC }}" -k "not docker" --basetemp="${{ runner.temp }}/pytest-basetemp"
pytest-opts: -n auto --dist loadgroup --max-worker-restart 0 -k "not docker" --basetemp="${{ runner.temp }}/pytest-basetemp"
coverage-config: .coveragerc
data-file: .coverage.xdist
output-file: coverage-xdist.xml
Expand Down
8 changes: 4 additions & 4 deletions .github/workflows/single-postgres.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,16 +53,16 @@ jobs:
# Try to find pg_ctl dynamically for cross-platform compatibility
if command -v pg_ctl >/dev/null 2>&1; then
PG_CTL_PATH=$(command -v pg_ctl)
echo "POSTGRESQL_EXEC=$PG_CTL_PATH" >> $GITHUB_ENV
echo "POSTGRESQL_EXEC=$PG_CTL_PATH" >> "$GITHUB_ENV"
elif [ -f "/opt/homebrew/opt/postgresql@${{ inputs.postgresql }}/bin/pg_ctl" ]; then
# macOS Apple Silicon Homebrew path
echo "POSTGRESQL_EXEC=/opt/homebrew/opt/postgresql@${{ inputs.postgresql }}/bin/pg_ctl" >> $GITHUB_ENV
echo "POSTGRESQL_EXEC=/opt/homebrew/opt/postgresql@${{ inputs.postgresql }}/bin/pg_ctl" >> "$GITHUB_ENV"
elif [ -f "/usr/local/opt/postgresql@${{ inputs.postgresql }}/bin/pg_ctl" ]; then
# macOS Intel Homebrew path
echo "POSTGRESQL_EXEC=/usr/local/opt/postgresql@${{ inputs.postgresql }}/bin/pg_ctl" >> $GITHUB_ENV
echo "POSTGRESQL_EXEC=/usr/local/opt/postgresql@${{ inputs.postgresql }}/bin/pg_ctl" >> "$GITHUB_ENV"
elif [ -f "/usr/lib/postgresql/${{ inputs.postgresql }}/bin/pg_ctl" ]; then
# Debian/Ubuntu path (fallback)
echo "POSTGRESQL_EXEC=/usr/lib/postgresql/${{ inputs.postgresql }}/bin/pg_ctl" >> $GITHUB_ENV
echo "POSTGRESQL_EXEC=/usr/lib/postgresql/${{ inputs.postgresql }}/bin/pg_ctl" >> "$GITHUB_ENV"
else
echo "Error: pg_ctl not found in expected locations"
exit 1
Expand Down
19 changes: 12 additions & 7 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -343,7 +343,7 @@ You can define settings via fixture factory arguments, command line options, or
- n/a
- --postgresql-exec
- postgresql_exec
- ``pg_config --bindir`` + ``pg_ctl``
- discovered (see note)
* - host
- host
- host
Expand Down Expand Up @@ -440,13 +440,18 @@ You can define settings via fixture factory arguments, command line options, or

.. note::

If the ``executable`` factory argument is not provided, the plugin looks for ``pg_ctl`` first at
the configured path (``--postgresql-exec`` / the ``postgresql_exec`` ini option,
``/usr/lib/postgresql/14/bin/pg_ctl`` by default), then in the directory reported by
``pg_config --bindir``.
If the ``executable`` factory argument is not provided, the plugin looks for ``pg_ctl`` in
this order:

Only the ``executable`` factory argument is taken at face value; the other two are
verified to exist before being used. If neither is there, an ``ExecutableMissingException``
1. the configured path (``--postgresql-exec`` / the ``postgresql_exec`` ini option;
on Unix this defaults to ``/usr/lib/postgresql/14/bin/pg_ctl``, on Windows there is no
default path),
2. the directory reported by ``pg_config --bindir``,
3. on Windows, ``%ProgramFiles%\PostgreSQL\*\bin\pg_ctl.exe`` (newest version first),
4. ``pg_ctl`` on ``PATH`` (via ``shutil.which``).

Only the ``executable`` factory argument is taken at face value; the other locations are
verified to exist before being used. If none of them is there, an ``ExecutableMissingException``
is raised listing the locations that were checked.

.. note::
Expand Down
4 changes: 4 additions & 0 deletions newsfragments/1399.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
``pg_ctl`` discovery now finds a Windows PostgreSQL install without ``--postgresql-exec``.
The default executable path is empty on Windows, and discovery probes
``%ProgramFiles%\PostgreSQL\*\bin\pg_ctl.exe`` (newest version first) and ``PATH`` after
``pg_config --bindir``.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
12 changes: 12 additions & 0 deletions pytest_postgresql/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,18 @@ def not_in_bindir(cls, bindir: str, checked: Iterable[str]) -> "ExecutableMissin
checked,
)

@classmethod
def discovery_failed(cls, causes: Iterable[str], checked: Iterable[str]) -> "ExecutableMissingException":
"""None of the discovery strategies found a runnable pg_ctl.

:param causes: why each strategy came up empty, joined into one explanation
:param checked: every location that was probed
"""
cause = " ".join(causes)
if not cause:
cause = "No pg_ctl was found in any of the usual locations."
return cls._no_pg_ctl(cause, checked)


class PostgreSQLUnsupported(Exception):
"""Exception raised when unsupported postgresql would be detected."""
167 changes: 149 additions & 18 deletions pytest_postgresql/factories/_pg.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
import logging
import os
import platform
import shutil
import subprocess
from typing import Iterable
from pathlib import Path

from pytest_postgresql.config import PostgreSQLConfig
from pytest_postgresql.exceptions import ExecutableMissingException
Expand All @@ -25,24 +26,28 @@
Matches PostgreSQLExecutor's default subprocess timeout.
"""

_WINDOWS_PROGRAM_FILES_FALLBACK = r"C:\Program Files"

def _pg_bindir(checked: Iterable[str]) -> str:

def _pg_bindir() -> str:
"""Ask pg_config where PostgreSQL keeps its binaries.

pg_config comes from the client development package, which on most distributions
can be installed without the server, so its answer is a hint rather than an answer.

:param checked: locations probed so far, to name in the error if pg_config can't be run
:raises ExecutableMissingException: pg_config is missing, unusable, hanging or failing.
:raises OSError: pg_config is missing or not executable
:raises subprocess.SubprocessError: pg_config failed or hung.
TimeoutExpired is a SubprocessError, so it needs no branch of its own.
"""
try:
return subprocess.check_output(
bindir = subprocess.check_output(
["pg_config", "--bindir"], universal_newlines=True, timeout=PG_CONFIG_TIMEOUT
).strip()
except (OSError, subprocess.SubprocessError) as ex:
logger.debug("Could not read the binaries directory from pg_config: %s", ex)
raise ExecutableMissingException.pg_config_unusable(f"{type(ex).__name__}: {ex}", checked) from ex
raise
logger.debug("pg_config --bindir reported %s", bindir)
return bindir


def _is_executable(path: str) -> bool:
Expand All @@ -54,22 +59,148 @@ def _is_executable(path: str) -> bool:
return os.path.isfile(path) and os.access(path, os.X_OK)


def _pg_exe(executable: str | None, config: PostgreSQLConfig) -> str:
"""If executable is set, use it. Otherwise best effort to find the executable."""
# an explicitly passed executable is taken at face value, it's not ours to second-guess
if executable is not None:
return executable
def _windows_postgresql_root() -> Path:
"""Directory under which the Windows installer places versioned PostgreSQL copies."""
program_files = os.environ.get("ProgramFiles", _WINDOWS_PROGRAM_FILES_FALLBACK)
return Path(program_files) / "PostgreSQL"


def _windows_version_sort_key(name: str) -> tuple[int, tuple[int, ...] | str]:
"""Sort key for Windows PostgreSQL version directory names, newest first when reversed.

Numeric versions (``16``, ``16.4``) rank above non-numeric names so a real install
wins over stray folders; non-numeric names fall back to lexicographic comparison.
"""
parts = name.split(".")
try:
numbers = tuple(int(part) for part in parts)
except ValueError:
return (0, name)
return (1, numbers)


def _windows_program_files_pg_ctls() -> list[str]:
"""pg_ctl paths under Program Files/PostgreSQL/*/bin, newest version first."""
root = _windows_postgresql_root()
if not root.is_dir():
logger.debug("Windows PostgreSQL directory does not exist: %s", root)
return []
found: list[tuple[str, str]] = []
try:
version_dirs = [entry for entry in root.iterdir() if entry.is_dir()]
except OSError as ex:
logger.debug("Could not scan %s: %s", root, ex)
return []
for version_dir in version_dirs:
bindir = version_dir / "bin"
for name in PG_CTL_NAMES:
candidate = bindir / name
if candidate.is_file():
found.append((version_dir.name, str(candidate)))
found.sort(key=lambda item: _windows_version_sort_key(item[0]), reverse=True)
paths = [path for _, path in found]
logger.debug("Windows Program Files pg_ctl candidates (newest first): %s", paths)
return paths


def _which_pg_ctl() -> str | None:
"""Return pg_ctl from PATH, if it is a file this user can run."""
for name in PG_CTL_NAMES:
found = shutil.which(name)
if found is None:
logger.debug("which(%s) found nothing", name)
continue
logger.debug("which(%s) found %s", name, found)
if _is_executable(found):
return found
return None


def _remember(checked: list[str], candidate: str) -> None:
"""Append candidate to the probed-locations list unless it is already there."""
if candidate not in checked:
checked.append(candidate)


def _from_config_exec(config: PostgreSQLConfig, checked: list[str]) -> str | None:
"""Use postgresql_exec when it names a real executable."""
postgresql_ctl = config.exec
# check if that executable exists, as it's not on systems' PATH
if not postgresql_ctl:
logger.debug("No postgresql_exec configured; skipping that probe")
return None
logger.debug("Checking configured postgresql_exec: %s", postgresql_ctl)
if _is_executable(postgresql_ctl):
logger.debug("Using configured postgresql_exec: %s", postgresql_ctl)
return postgresql_ctl
checked = [postgresql_ctl]
bindir = _pg_bindir(checked)
_remember(checked, postgresql_ctl)
return None


def _from_pg_config(checked: list[str], causes: list[str]) -> str | None:
"""Use pg_ctl next to the binaries directory pg_config reports, if any."""
try:
bindir = _pg_bindir()
except (OSError, subprocess.SubprocessError) as ex:
reason = f"{type(ex).__name__}: {ex}"
logger.debug("pg_config unusable: %s", reason)
causes.append(
f"pg_config could not be run either ({reason}), so it could not be used to locate the PostgreSQL binaries."
)
return None
for name in PG_CTL_NAMES:
candidate = os.path.join(bindir, name)
if candidate in checked:
continue
checked.append(candidate)
_remember(checked, candidate)
if _is_executable(candidate):
logger.debug("Using pg_ctl from pg_config bindir: %s", candidate)
return candidate
causes.append(
f"pg_config reports PostgreSQL binaries live in {bindir}, but there's no pg_ctl there. "
f"That usually means only the PostgreSQL client libraries are installed "
f"(Debian/Ubuntu's libpq-dev, for example), without the matching server package."
)
return None


def _from_windows_program_files(checked: list[str]) -> str | None:
"""Use the newest Windows installer copy of pg_ctl, if this is Windows."""
if platform.system() != "Windows":
return None
logger.debug("Probing %s for pg_ctl", _windows_postgresql_root())
for candidate in _windows_program_files_pg_ctls():
_remember(checked, candidate)
if _is_executable(candidate):
logger.debug("Using pg_ctl from Program Files: %s", candidate)
return candidate
raise ExecutableMissingException.not_in_bindir(bindir, checked)
return None


def _from_path(checked: list[str]) -> str | None:
"""Use pg_ctl from PATH."""
found = _which_pg_ctl()
if found is None:
logger.debug("shutil.which did not find pg_ctl on PATH")
return None
_remember(checked, found)
if _is_executable(found):
logger.debug("Using pg_ctl from PATH: %s", found)
return found
return None


def _pg_exe(executable: str | None, config: PostgreSQLConfig) -> str:
"""If executable is set, use it. Otherwise best effort to find the executable."""
if executable is not None:
logger.debug("Using factory executable: %s", executable)
return executable
checked: list[str] = []
causes: list[str] = []
found = (
_from_config_exec(config, checked)
or _from_pg_config(checked, causes)
or _from_windows_program_files(checked)
or _from_path(checked)
)
if found is not None:
return found
logger.debug("pg_ctl discovery failed; checked %s", checked)
raise ExecutableMissingException.discovery_failed(causes, checked)
3 changes: 2 additions & 1 deletion pytest_postgresql/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
pytest_asyncio = None # type: ignore[assignment]

_help_executable = "Path to PostgreSQL executable"
_DEFAULT_POSTGRESQL_EXEC = "" if platform.system() == "Windows" else "/usr/lib/postgresql/14/bin/pg_ctl"
_help_host = "Host at which PostgreSQL will accept connections"
_help_port = "Port at which PostgreSQL will accept connections"
_help_port_search_count = "Number of times, pytest-postgresql will search for free port"
Expand Down Expand Up @@ -144,7 +145,7 @@ def pytest_asyncio_loop_factories(

def pytest_addoption(parser: Parser) -> None:
"""Configure options for pytest-postgresql."""
parser.addini(name="postgresql_exec", help=_help_executable, default="/usr/lib/postgresql/14/bin/pg_ctl")
parser.addini(name="postgresql_exec", help=_help_executable, default=_DEFAULT_POSTGRESQL_EXEC)

parser.addini(name="postgresql_host", help=_help_host, default="127.0.0.1")

Expand Down
Loading
Loading