Skip to content

Commit f1f6d30

Browse files
committed
Refactor PostgreSQLExecutor to support Windows compatibility for process management
- Convert _get_base_command method to BASE_PROC_START_COMMAND class attribute - Use unified command format without single quotes around PostgreSQL config values - Add _windows_terminate_process method for graceful Windows process termination - Update stop() method to use list args instead of shell=True for security - Add platform-specific termination logic with killpg fallback - Add comprehensive Windows compatibility test suite - Rename parameter in _windows_terminate_process method for clarity
1 parent bf896f1 commit f1f6d30

File tree

2 files changed

+225
-5
lines changed

2 files changed

+225
-5
lines changed

pytest_postgresql/executor.py

Lines changed: 39 additions & 5 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,11 +49,14 @@ class PostgreSQLExecutor(TCPExecutor):
4849
<http://www.postgresql.org/docs/current/static/app-pg-ctl.html>`_
4950
"""
5051

52+
# Base PostgreSQL start command template - cross-platform compatible
53+
# Use unified format without single quotes around values
54+
# This format works on both Windows and Unix systems
5155
BASE_PROC_START_COMMAND = (
5256
'{executable} start -D "{datadir}" '
53-
"-o \"-F -p {port} -c log_destination='stderr' "
57+
'-o "-F -p {port} -c log_destination=stderr '
5458
"-c logging_collector=off "
55-
"-c unix_socket_directories='{unixsocketdir}' {postgres_options}\" "
59+
'-c unix_socket_directories={unixsocketdir} {postgres_options}" '
5660
'-l "{logfile}" {startparams}'
5761
)
5862

@@ -219,17 +223,47 @@ def running(self) -> bool:
219223
status_code = subprocess.getstatusoutput(f'{self.executable} status -D "{self.datadir}"')[0]
220224
return status_code == 0
221225

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

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

0 commit comments

Comments
 (0)