Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
6c86cc3
Refactor PostgreSQLExecutor to support Windows compatibility for proc…
tboy1337 Jan 29, 2026
4d77539
Enhance PostgreSQL workflow for Windows compatibility and streamline …
tboy1337 Feb 11, 2026
9d1742a
Enhance PostgreSQL workflow and executor for improved cross-platform …
tboy1337 Feb 11, 2026
5fa0b10
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Feb 11, 2026
429d26c
Refactor PostgreSQLExecutor and enhance Windows compatibility in tests
tboy1337 Feb 11, 2026
f78f5d4
Improve process termination handling in PostgreSQLExecutor and refine…
tboy1337 Feb 11, 2026
79233aa
Update GitHub workflows to include editable package installation
tboy1337 Feb 15, 2026
d80f234
Update oldest-postgres.yml to install package without dependencies
tboy1337 Feb 15, 2026
c9bc3bc
Enhance PostgreSQL workflow error handling
tboy1337 Feb 15, 2026
411a03d
Refactor PostgreSQLExecutor command templates for platform compatibility
tboy1337 Feb 15, 2026
89d842b
Fix PostgreSQL path in Windows workflow
tboy1337 Feb 15, 2026
5033863
Update pytest configuration in test_postgres_options_plugin.py
tboy1337 Feb 15, 2026
7d8f38e
Update pytest_plugins declaration in test_postgres_options_plugin.py …
tboy1337 Feb 15, 2026
1aceba3
Enhance platform-specific command templates and test coverage for Pos…
tboy1337 Feb 16, 2026
686197a
Add Windows locale setup fixture and update test cases for password h…
tboy1337 Feb 16, 2026
b615a6a
Update locale handling in executor.py and remove Windows locale setup…
tboy1337 Feb 16, 2026
e268bee
Refactor socket directory handling in test_executor.py for PostgreSQL…
tboy1337 Feb 16, 2026
d3a8b41
Update path handling for pytest uploads in single-postgres.yml
tboy1337 Feb 16, 2026
e7c690b
Update pytest command options in single-postgres.yml to include --bas…
tboy1337 Feb 16, 2026
0054df8
Refine pytest upload path in single-postgres.yml for improved artifac…
tboy1337 Feb 16, 2026
0afbfdd
Update workflows to use pipenv-setup@v4.4.0 with editable flag
tboy1337 Feb 16, 2026
f89d3b3
Update workflow files to use pipenv-run@v4.2.1 and refine conditional…
tboy1337 Feb 16, 2026
0b7de46
Refactor socket directory handling in test_executor.py and clean up t…
tboy1337 Feb 23, 2026
1b1ff4b
Fix formatting issues in PostgreSQL command templates and update test…
tboy1337 Feb 23, 2026
9c3aa4a
Update test assertions for PostgreSQL command formatting in test_exec…
tboy1337 Feb 23, 2026
e92879b
Add FreeBSD to platform parameterization in test_executor.py
tboy1337 Feb 23, 2026
9a8dc2b
Fixed trailing whitespace in single-postgres.yml and test_executor.py
tboy1337 Mar 1, 2026
400bddd
Refine pytest temporary directory handling in single-postgres.yml
tboy1337 Mar 1, 2026
a8d9dd6
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Mar 1, 2026
ba73005
Update test documentation for Windows UNC paths in test_windows_compa…
tboy1337 Mar 3, 2026
44fc97e
Remove editable package changes extracted to separate PR
tboy1337 Mar 13, 2026
e4d8699
Refactor command quoting in PostgreSQLExecutor for consistency
tboy1337 Apr 14, 2026
bd12358
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Apr 14, 2026
e676a74
Enhance PostgreSQLExecutor to escape apostrophes in unixsocketdir
tboy1337 Apr 14, 2026
87abf24
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Apr 14, 2026
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
1 change: 1 addition & 0 deletions .github/workflows/dockerised-postgres.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ jobs:
with:
python-version: ${{ matrix.python-version }}
allow-prereleases: true
editable: true
- name: Run test noproc fixture on docker
uses: fizyk/actions-reuse/.github/actions/pipenv-run@v4.4.4
with:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/oldest-postgres.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ jobs:
run: |
sudo locale-gen de_DE.UTF-8
- name: install libpq
if: ${{ contains(inputs.python-versions, 'pypy') }}
if: ${{ contains(matrix.python-version, 'pypy') && runner.os == 'Linux' }}
run: sudo apt install libpq5
- name: Install oldest supported versions
uses: fizyk/actions-reuse/.github/actions/pipenv-run@v4.4.4
Expand Down
48 changes: 44 additions & 4 deletions .github/workflows/single-postgres.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,29 +43,69 @@ jobs:
- uses: ankane/setup-postgres@v1
with:
postgres-version: ${{ inputs.postgresql }}
- name: Detect PostgreSQL path on Windows
if: runner.os == 'Windows'
shell: pwsh
run: |
$pgPath = "C:\Program Files\PostgreSQL\${{ inputs.postgresql }}\bin\pg_ctl.exe"
if (Test-Path $pgPath) {
echo "POSTGRESQL_EXEC=$pgPath" >> $env:GITHUB_ENV
} else {
$pgPath = (Get-Command pg_ctl -ErrorAction SilentlyContinue).Source
if ($pgPath) {
echo "POSTGRESQL_EXEC=$pgPath" >> $env:GITHUB_ENV
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

# Verify that PostgreSQL was found
if (-not $pgPath) {
Write-Error "Error: pg_ctl not found in expected locations. Checked hardcoded path and system PATH."
exit 1
}
- name: Set PostgreSQL path for Unix/macOS
if: runner.os != 'Windows'
run: |
# Try to find pg_ctl dynamically for cross-platform compatibility
if command -v pg_ctl >/dev/null 2>&1; then
Comment thread
tboy1337 marked this conversation as resolved.
PG_CTL_PATH=$(command -v pg_ctl)
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
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
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
else
echo "Error: pg_ctl not found in expected locations"
exit 1
fi
- name: Check installed locales
if: runner.os != 'Windows'
run: |
locale -a
- name: update locale for tests
if: ${{ inputs.os == 'ubuntu-latest' }}
run: |
sudo locale-gen de_DE.UTF-8
- name: install libpq
if: ${{ contains(inputs.python-versions, 'pypy') }}
if: ${{ contains(matrix.python-version, 'pypy') && runner.os == 'Linux' }}
run: sudo apt install libpq5
- name: Run test
uses: fizyk/actions-reuse/.github/actions/pipenv-run@v4.4.4
with:
command: pytest -svv -p no:xdist --postgresql-exec="/usr/lib/postgresql/${{ inputs.postgresql }}/bin/pg_ctl" -k "not docker" --cov-report=xml
command: pytest -svv -p no:xdist --postgresql-exec="${{ env.POSTGRESQL_EXEC }}" -k "not docker" --cov-report=xml --basetemp="${{ runner.temp }}/pytest-basetemp"
- name: Run xdist test
uses: fizyk/actions-reuse/.github/actions/pipenv-run@v4.4.4
with:
command: pytest -n auto --dist loadgroup --max-worker-restart 0 --postgresql-exec="/usr/lib/postgresql/${{ inputs.postgresql }}/bin/pg_ctl" -k "not docker" --cov-report=xml:coverage-xdist.xml
command: pytest -n auto --dist loadgroup --max-worker-restart 0 --postgresql-exec="${{ env.POSTGRESQL_EXEC }}" -k "not docker" --cov-report=xml:coverage-xdist.xml --basetemp="${{ runner.temp }}/pytest-basetemp"
- uses: actions/upload-artifact@v7
if: failure()
with:
name: postgresql-${{ matrix.python-version }}-${{ inputs.postgresql }}
path: /tmp/pytest-of-runner/**
path: ${{ runner.temp }}/pytest-basetemp/**
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v6.0.0
with:
Expand Down
21 changes: 21 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,27 @@ jobs:
python-versions: '["3.13", "3.14"]'
secrets:
codecov_token: ${{ secrets.CODECOV_TOKEN }}
windows_postgres_18:
needs: [postgresql_18]
uses: ./.github/workflows/single-postgres.yml
with:
postgresql: 18
os: windows-latest
python-versions: '["3.12", "3.13", "3.14"]'
windows_postgres_17:
needs: [postgresql_17, windows_postgres_18]
uses: ./.github/workflows/single-postgres.yml
with:
postgresql: 17
os: windows-latest
python-versions: '["3.12", "3.13", "3.14"]'
windows_postgres_16:
needs: [postgresql_16, windows_postgres_17]
uses: ./.github/workflows/single-postgres.yml
with:
postgresql: 16
os: windows-latest
python-versions: '["3.13", "3.14"]'
docker_postgresql_18:
needs: [postgresql_18]
uses: ./.github/workflows/dockerised-postgres.yml
Expand Down
1 change: 1 addition & 0 deletions newsfragments/1182.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add Windows compatibility for ``PostgreSQLExecutor`` with platform-specific command templates (``UNIX_PROC_START_COMMAND`` / ``WINDOWS_PROC_START_COMMAND``) and a dedicated ``_windows_terminate_process`` method for graceful process termination on Windows.
96 changes: 85 additions & 11 deletions pytest_postgresql/executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
# along with pytest-postgresql. If not, see <http://www.gnu.org/licenses/>.
"""PostgreSQL executor crafter around pg_ctl."""

import logging
import os
import os.path
import platform
import re
Expand All @@ -32,10 +34,16 @@

from pytest_postgresql.exceptions import ExecutableMissingException, PostgreSQLUnsupported

logger = logging.getLogger(__name__)

_LOCALE = "C.UTF-8"

if platform.system() == "Darwin":
# Darwin does not have C.UTF-8, but en_US.UTF-8 is always available
_LOCALE = "en_US.UTF-8"
elif platform.system() == "Windows":
# Windows doesn't support C.UTF-8 or en_US.UTF-8, use plain "C" locale
_LOCALE = "C"


T = TypeVar("T", bound="PostgreSQLExecutor")
Expand All @@ -48,11 +56,26 @@ class PostgreSQLExecutor(TCPExecutor):
<http://www.postgresql.org/docs/current/static/app-pg-ctl.html>`_
"""

BASE_PROC_START_COMMAND = (
'{executable} start -D "{datadir}" '
# Unix command template - uses single quotes for PostgreSQL config value quoting
# which protects paths with spaces in unix_socket_directories.
# On Unix, mirakuru uses shlex.split() with shell=False, so single quotes
# inside double-quoted strings are preserved and passed to PostgreSQL's config parser.
UNIX_PROC_START_COMMAND = (
'"{executable}" start -D "{datadir}" '
"-o \"-F -p {port} -c log_destination='stderr' "
"-c logging_collector=off "
"-c unix_socket_directories='{unixsocketdir}' {postgres_options}\" "
"-c unix_socket_directories='{unixsocketdir}'{postgres_options}\" "
'-l "{logfile}" {startparams}'
Comment thread
tboy1337 marked this conversation as resolved.
)

# Windows command template - no single quotes (cmd.exe treats them as literals,
# not delimiters) and unix_socket_directories is omitted entirely since PostgreSQL
# ignores it on Windows. On Windows, mirakuru forces shell=True so the command
# goes through cmd.exe.
WINDOWS_PROC_START_COMMAND = (
'"{executable}" start -D "{datadir}" '
'-o "-F -p {port} -c log_destination=stderr '
'-c logging_collector=off{postgres_options}" '
'-l "{logfile}" {startparams}'
)

Expand Down Expand Up @@ -108,14 +131,22 @@ def __init__(
self.logfile = logfile
self.startparams = startparams
self.postgres_options = postgres_options
command = self.BASE_PROC_START_COMMAND.format(
if platform.system() == "Windows":
command_template = self.WINDOWS_PROC_START_COMMAND
else:
command_template = self.UNIX_PROC_START_COMMAND
# PostgreSQL GUC single-quoted strings double single-quotes to escape them
# (e.g. /tmp/o'hare → /tmp/o''hare). Apply this before interpolation so
# the generated unix_socket_directories value is always syntactically valid.
escaped_unixsocketdir = self.unixsocketdir.replace("'", "''")
command = command_template.format(
executable=self.executable,
datadir=self.datadir,
port=port,
unixsocketdir=self.unixsocketdir,
unixsocketdir=escaped_unixsocketdir,
logfile=self.logfile,
startparams=self.startparams,
postgres_options=self.postgres_options,
postgres_options=f" {self.postgres_options}" if self.postgres_options else "",
)
super().__init__(
command,
Expand Down Expand Up @@ -216,20 +247,63 @@ def running(self) -> bool:
"""Check if server is running."""
if not os.path.exists(self.datadir):
return False
status_code = subprocess.getstatusoutput(f'{self.executable} status -D "{self.datadir}"')[0]
return status_code == 0
result = subprocess.run(
[self.executable, "status", "-D", self.datadir],
check=False,
)
return result.returncode == 0

def _windows_terminate_process(self, _sig: Optional[int] = None) -> None:
"""Terminate process on Windows.

:param _sig: Signal parameter (unused on Windows but included for consistency)
"""
if self.process is None:
return

try:
# On Windows, try to terminate gracefully first
self.process.terminate()
# Give it a chance to terminate gracefully
try:
self.process.wait(timeout=5)
except subprocess.TimeoutExpired:
# If it doesn't terminate gracefully, force kill
self.process.kill()
try:
self.process.wait(timeout=10)
except subprocess.TimeoutExpired:
logger.warning(
"Process %s could not be cleaned up after kill() and may be a zombie process",
self.process.pid if self.process else "unknown",
)
except (OSError, AttributeError) as e:
# Process might already be dead or other issues
logger.debug(
"Exception during Windows process termination: %s: %s",
type(e).__name__,
e,
)

def stop(self: T, sig: Optional[int] = None, exp_sig: Optional[int] = None) -> T:
"""Issue a stop request to executable."""
subprocess.check_output(
f'{self.executable} stop -D "{self.datadir}" -m f',
shell=True,
[self.executable, "stop", "-D", self.datadir, "-m", "f"],
)
try:
super().stop(sig, exp_sig)
if platform.system() == "Windows":
self._windows_terminate_process(sig)
else:
super().stop(sig, exp_sig)
except ProcessFinishedWithError:
# Finished, leftovers ought to be cleaned afterwards anyway
pass
except AttributeError:
# Fallback for edge cases where os.killpg doesn't exist (e.g., Windows)
if not hasattr(os, "killpg"):
self._windows_terminate_process(sig)
else:
raise
return self
Comment thread
coderabbitai[bot] marked this conversation as resolved.

def __del__(self) -> None:
Expand Down
Loading
Loading