Skip to content

Commit b02c61b

Browse files
committed
feat: add comprehensive Windows compatibility fixes for PostgreSQLExecutor
1 parent 7c17701 commit b02c61b

File tree

4 files changed

+252
-13
lines changed

4 files changed

+252
-13
lines changed

.github/workflows/pre-commit.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,4 @@ on:
99

1010
jobs:
1111
pre-commit:
12-
uses: fizyk/actions-reuse/.github/workflows/shared-pre-commit.yml@v4.1.1
12+
uses: fizyk/actions-reuse/.github/workflows/shared-pre-commit.yml@v4.2.1

.pre-commit-config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ repos:
2525
- id: debug-statements
2626

2727
- repo: https://github.com/astral-sh/ruff-pre-commit
28-
rev: v0.14.11
28+
rev: v0.14.13
2929
hooks:
3030
- id: ruff-check
3131
args: [--fix, --exit-non-zero-on-fix, --respect-gitignore, --show-fixes]

pytest_postgresql/executor.py

Lines changed: 46 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
# along with pytest-postgresql. If not, see <http://www.gnu.org/licenses/>.
1818
"""PostgreSQL executor crafter around pg_ctl."""
1919

20+
import os
2021
import os.path
2122
import platform
2223
import re
@@ -48,13 +49,17 @@ class PostgreSQLExecutor(TCPExecutor):
4849
<http://www.postgresql.org/docs/current/static/app-pg-ctl.html>`_
4950
"""
5051

51-
BASE_PROC_START_COMMAND = (
52-
'{executable} start -D "{datadir}" '
53-
"-o \"-F -p {port} -c log_destination='stderr' "
54-
"-c logging_collector=off "
55-
"-c unix_socket_directories='{unixsocketdir}' {postgres_options}\" "
56-
'-l "{logfile}" {startparams}'
57-
)
52+
def _get_base_command(self) -> str:
53+
"""Get the base PostgreSQL command, cross-platform compatible."""
54+
# Use unified format without single quotes around values
55+
# This format works on both Windows and Unix systems
56+
return (
57+
'{executable} start -D "{datadir}" '
58+
'-o "-F -p {port} -c log_destination=stderr '
59+
"-c logging_collector=off "
60+
'-c unix_socket_directories={unixsocketdir} {postgres_options}" '
61+
'-l "{logfile}" {startparams}'
62+
)
5863

5964
VERSION_RE = re.compile(r".* (?P<version>\d+(?:\.\d+)?)")
6065
MIN_SUPPORTED_VERSION = parse("14")
@@ -108,7 +113,7 @@ def __init__(
108113
self.logfile = logfile
109114
self.startparams = startparams
110115
self.postgres_options = postgres_options
111-
command = self.BASE_PROC_START_COMMAND.format(
116+
command = self._get_base_command().format(
112117
executable=self.executable,
113118
datadir=self.datadir,
114119
port=port,
@@ -219,17 +224,47 @@ def running(self) -> bool:
219224
status_code = subprocess.getstatusoutput(f'{self.executable} status -D "{self.datadir}"')[0]
220225
return status_code == 0
221226

227+
def _windows_terminate_process(self, sig: Optional[int] = None) -> None:
228+
"""Terminate process on Windows.
229+
230+
:param sig: Signal parameter (unused on Windows but included for consistency)
231+
"""
232+
if self.process is None:
233+
return
234+
235+
try:
236+
# On Windows, try to terminate gracefully first
237+
self.process.terminate()
238+
# Give it a chance to terminate gracefully
239+
try:
240+
self.process.wait(timeout=5)
241+
except subprocess.TimeoutExpired:
242+
# If it doesn't terminate gracefully, force kill
243+
self.process.kill()
244+
self.process.wait()
245+
except (OSError, AttributeError):
246+
# Process might already be dead or other issues
247+
pass
248+
222249
def stop(self: T, sig: Optional[int] = None, exp_sig: Optional[int] = None) -> T:
223250
"""Issue a stop request to executable."""
224251
subprocess.check_output(
225-
f'{self.executable} stop -D "{self.datadir}" -m f',
226-
shell=True,
252+
[self.executable, "stop", "-D", self.datadir, "-m", "f"],
227253
)
228254
try:
229-
super().stop(sig, exp_sig)
255+
if platform.system() == "Windows":
256+
self._windows_terminate_process(sig)
257+
else:
258+
super().stop(sig, exp_sig)
230259
except ProcessFinishedWithError:
231260
# Finished, leftovers ought to be cleaned afterwards anyway
232261
pass
262+
except AttributeError as e:
263+
# Fallback for edge cases where os.killpg doesn't exist
264+
if "killpg" in str(e):
265+
self._windows_terminate_process(sig)
266+
else:
267+
raise
233268
return self
234269

235270
def __del__(self) -> None:
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
"""Test Windows compatibility fixes for pytest-postgresql."""
2+
3+
import subprocess
4+
from unittest.mock import MagicMock, patch
5+
6+
from pytest_postgresql.executor import PostgreSQLExecutor
7+
8+
9+
class TestWindowsCompatibility:
10+
"""Test Windows-specific functionality."""
11+
12+
def test_get_base_command_unified(self):
13+
"""Test that base command is unified across platforms."""
14+
executor = PostgreSQLExecutor(
15+
executable="/path/to/pg_ctl",
16+
host="localhost",
17+
port=5432,
18+
datadir="/tmp/data",
19+
unixsocketdir="/tmp/socket",
20+
logfile="/tmp/log",
21+
startparams="-w",
22+
dbname="test",
23+
)
24+
25+
# Test that command format is consistent across platforms
26+
with patch("platform.system", return_value="Windows"):
27+
windows_command = executor._get_base_command()
28+
29+
with patch("platform.system", return_value="Linux"):
30+
unix_command = executor._get_base_command()
31+
32+
# Both should be the same now
33+
assert windows_command == unix_command
34+
35+
# Both should use the simplified format without single quotes
36+
assert "log_destination=stderr" in windows_command
37+
assert "log_destination='stderr'" not in windows_command
38+
assert "unix_socket_directories={unixsocketdir}" in windows_command
39+
assert "unix_socket_directories='{unixsocketdir}'" not in windows_command
40+
41+
def test_windows_terminate_process(self):
42+
"""Test Windows process termination."""
43+
executor = PostgreSQLExecutor(
44+
executable="/path/to/pg_ctl",
45+
host="localhost",
46+
port=5432,
47+
datadir="/tmp/data",
48+
unixsocketdir="/tmp/socket",
49+
logfile="/tmp/log",
50+
startparams="-w",
51+
dbname="test",
52+
)
53+
54+
# Mock process
55+
mock_process = MagicMock()
56+
executor.process = mock_process
57+
58+
# No need to mock platform.system() since the method doesn't check it anymore
59+
executor._windows_terminate_process()
60+
61+
# Should call terminate first
62+
mock_process.terminate.assert_called_once()
63+
mock_process.wait.assert_called()
64+
65+
def test_windows_terminate_process_force_kill(self):
66+
"""Test Windows process termination with force kill on timeout."""
67+
executor = PostgreSQLExecutor(
68+
executable="/path/to/pg_ctl",
69+
host="localhost",
70+
port=5432,
71+
datadir="/tmp/data",
72+
unixsocketdir="/tmp/socket",
73+
logfile="/tmp/log",
74+
startparams="-w",
75+
dbname="test",
76+
)
77+
78+
# Mock process that times out
79+
mock_process = MagicMock()
80+
mock_process.wait.side_effect = [subprocess.TimeoutExpired(cmd="test", timeout=5), None]
81+
executor.process = mock_process
82+
83+
# No need to mock platform.system() since the method doesn't check it anymore
84+
executor._windows_terminate_process()
85+
86+
# Should call terminate, wait (timeout), then kill, then wait again
87+
mock_process.terminate.assert_called_once()
88+
mock_process.kill.assert_called_once()
89+
assert mock_process.wait.call_count == 2
90+
91+
def test_stop_method_windows(self):
92+
"""Test stop method on Windows."""
93+
executor = PostgreSQLExecutor(
94+
executable="/path/to/pg_ctl",
95+
host="localhost",
96+
port=5432,
97+
datadir="/tmp/data",
98+
unixsocketdir="/tmp/socket",
99+
logfile="/tmp/log",
100+
startparams="-w",
101+
dbname="test",
102+
)
103+
104+
# Mock subprocess and process
105+
with (
106+
patch("subprocess.check_output") as mock_subprocess,
107+
patch("platform.system", return_value="Windows"),
108+
patch.object(executor, "_windows_terminate_process") as mock_terminate,
109+
):
110+
result = executor.stop()
111+
112+
# Should call pg_ctl stop and Windows terminate
113+
mock_subprocess.assert_called_once()
114+
mock_terminate.assert_called_once()
115+
assert result is executor
116+
117+
def test_stop_method_unix(self):
118+
"""Test stop method on Unix systems."""
119+
executor = PostgreSQLExecutor(
120+
executable="/path/to/pg_ctl",
121+
host="localhost",
122+
port=5432,
123+
datadir="/tmp/data",
124+
unixsocketdir="/tmp/socket",
125+
logfile="/tmp/log",
126+
startparams="-w",
127+
dbname="test",
128+
)
129+
130+
# Mock subprocess and super().stop
131+
with (
132+
patch("subprocess.check_output") as mock_subprocess,
133+
patch("platform.system", return_value="Linux"),
134+
patch("pytest_postgresql.executor.TCPExecutor.stop") as mock_super_stop,
135+
):
136+
mock_super_stop.return_value = executor
137+
result = executor.stop()
138+
139+
# Should call pg_ctl stop and parent class stop
140+
mock_subprocess.assert_called_once()
141+
mock_super_stop.assert_called_once_with(None, None)
142+
assert result is executor
143+
144+
def test_stop_method_fallback_on_killpg_error(self):
145+
"""Test stop method falls back to Windows termination on killpg AttributeError."""
146+
executor = PostgreSQLExecutor(
147+
executable="/path/to/pg_ctl",
148+
host="localhost",
149+
port=5432,
150+
datadir="/tmp/data",
151+
unixsocketdir="/tmp/socket",
152+
logfile="/tmp/log",
153+
startparams="-w",
154+
dbname="test",
155+
)
156+
157+
# Mock subprocess and super().stop to raise AttributeError
158+
with (
159+
patch("subprocess.check_output") as mock_subprocess,
160+
patch("platform.system", return_value="Linux"),
161+
patch(
162+
"pytest_postgresql.executor.TCPExecutor.stop",
163+
side_effect=AttributeError("module 'os' has no attribute 'killpg'"),
164+
),
165+
patch.object(executor, "_windows_terminate_process") as mock_terminate,
166+
):
167+
result = executor.stop()
168+
169+
# Should call pg_ctl stop, fail on super().stop, then use Windows terminate
170+
mock_subprocess.assert_called_once()
171+
mock_terminate.assert_called_once()
172+
assert result is executor
173+
174+
def test_command_formatting_windows(self):
175+
"""Test that command is properly formatted for Windows."""
176+
with patch("platform.system", return_value="Windows"):
177+
executor = PostgreSQLExecutor(
178+
executable="C:/Program Files/PostgreSQL/bin/pg_ctl.exe",
179+
host="localhost",
180+
port=5555,
181+
datadir="C:/temp/data",
182+
unixsocketdir="C:/temp/socket",
183+
logfile="C:/temp/log.txt",
184+
startparams="-w -s",
185+
dbname="testdb",
186+
postgres_options="-c shared_preload_libraries=test",
187+
)
188+
189+
# The command should be properly formatted without quotes around stderr
190+
expected_parts = [
191+
"C:/Program Files/PostgreSQL/bin/pg_ctl.exe start",
192+
'-D "C:/temp/data"',
193+
'-o "-F -p 5555 -c log_destination=stderr',
194+
"-c logging_collector=off",
195+
"-c unix_socket_directories=C:/temp/socket",
196+
'-c shared_preload_libraries=test"',
197+
'-l "C:/temp/log.txt"',
198+
"-w -s",
199+
]
200+
201+
# Check if all expected parts are in the command
202+
command = executor.command
203+
for part in expected_parts:
204+
assert part in command, f"Expected '{part}' in command: {command}"

0 commit comments

Comments
 (0)