Skip to content

Commit 1a2f6da

Browse files
authored
Fix initdb error on Windows (#99)
1 parent 846c05f commit 1a2f6da

12 files changed

+207
-122
lines changed

Diff for: setup.py

+4-4
Original file line numberDiff line numberDiff line change
@@ -27,16 +27,16 @@
2727
readme = f.read()
2828

2929
setup(
30-
version='1.9.2',
30+
version='1.9.3',
3131
name='testgres',
32-
packages=['testgres', 'testgres.operations'],
32+
packages=['testgres', 'testgres.operations', 'testgres.helpers'],
3333
description='Testing utility for PostgreSQL and its extensions',
3434
url='https://github.com/postgrespro/testgres',
3535
long_description=readme,
3636
long_description_content_type='text/markdown',
3737
license='PostgreSQL',
38-
author='Ildar Musin',
39-
author_email='[email protected]',
38+
author='Postgres Professional',
39+
author_email='[email protected]',
4040
keywords=['test', 'testing', 'postgresql'],
4141
install_requires=install_requires,
4242
classifiers=[],

Diff for: testgres/__init__.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@
5252
from .operations.local_ops import LocalOperations
5353
from .operations.remote_ops import RemoteOperations
5454

55+
from .helpers.port_manager import PortManager
56+
5557
__all__ = [
5658
"get_new_node",
5759
"get_remote_node",
@@ -62,6 +64,6 @@
6264
"XLogMethod", "IsolationLevel", "NodeStatus", "ProcessType", "DumpFormat",
6365
"PostgresNode", "NodeApp",
6466
"reserve_port", "release_port", "bound_ports", "get_bin_path", "get_pg_config", "get_pg_version",
65-
"First", "Any",
67+
"First", "Any", "PortManager",
6668
"OsOperations", "LocalOperations", "RemoteOperations", "ConnectionParams"
6769
]

Diff for: testgres/helpers/__init__.py

Whitespace-only changes.

Diff for: testgres/helpers/port_manager.py

+40
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import socket
2+
import random
3+
from typing import Set, Iterable, Optional
4+
5+
6+
class PortForException(Exception):
7+
pass
8+
9+
10+
class PortManager:
11+
def __init__(self, ports_range=(1024, 65535)):
12+
self.ports_range = ports_range
13+
14+
@staticmethod
15+
def is_port_free(port: int) -> bool:
16+
"""Check if a port is free to use."""
17+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
18+
try:
19+
s.bind(("", port))
20+
return True
21+
except OSError:
22+
return False
23+
24+
def find_free_port(self, ports: Optional[Set[int]] = None, exclude_ports: Optional[Iterable[int]] = None) -> int:
25+
"""Return a random unused port number."""
26+
if ports is None:
27+
ports = set(range(1024, 65535))
28+
29+
if exclude_ports is None:
30+
exclude_ports = set()
31+
32+
ports.difference_update(set(exclude_ports))
33+
34+
sampled_ports = random.sample(tuple(ports), min(len(ports), 100))
35+
36+
for port in sampled_ports:
37+
if self.is_port_free(port):
38+
return port
39+
40+
raise PortForException("Can't select a port")

Diff for: testgres/node.py

+4-4
Original file line numberDiff line numberDiff line change
@@ -623,8 +623,8 @@ def status(self):
623623
"-D", self.data_dir,
624624
"status"
625625
] # yapf: disable
626-
status_code, out, err = execute_utility(_params, self.utils_log_file, verbose=True)
627-
if 'does not exist' in err:
626+
status_code, out, error = execute_utility(_params, self.utils_log_file, verbose=True)
627+
if error and 'does not exist' in error:
628628
return NodeStatus.Uninitialized
629629
elif 'no server running' in out:
630630
return NodeStatus.Stopped
@@ -717,7 +717,7 @@ def start(self, params=[], wait=True):
717717

718718
try:
719719
exit_status, out, error = execute_utility(_params, self.utils_log_file, verbose=True)
720-
if 'does not exist' in error:
720+
if error and 'does not exist' in error:
721721
raise Exception
722722
except Exception as e:
723723
msg = 'Cannot start node'
@@ -791,7 +791,7 @@ def restart(self, params=[]):
791791

792792
try:
793793
error_code, out, error = execute_utility(_params, self.utils_log_file, verbose=True)
794-
if 'could not start server' in error:
794+
if error and 'could not start server' in error:
795795
raise ExecUtilException
796796
except ExecUtilException as e:
797797
msg = 'Cannot restart node'

Diff for: testgres/operations/local_ops.py

+74-59
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,7 @@
88
import psutil
99

1010
from ..exceptions import ExecUtilException
11-
from .os_ops import ConnectionParams, OsOperations
12-
from .os_ops import pglib
11+
from .os_ops import ConnectionParams, OsOperations, pglib, get_default_encoding
1312

1413
try:
1514
from shutil import which as find_executable
@@ -22,6 +21,14 @@
2221
error_markers = [b'error', b'Permission denied', b'fatal']
2322

2423

24+
def has_errors(output):
25+
if output:
26+
if isinstance(output, str):
27+
output = output.encode(get_default_encoding())
28+
return any(marker in output for marker in error_markers)
29+
return False
30+
31+
2532
class LocalOperations(OsOperations):
2633
def __init__(self, conn_params=None):
2734
if conn_params is None:
@@ -33,72 +40,80 @@ def __init__(self, conn_params=None):
3340
self.remote = False
3441
self.username = conn_params.username or self.get_user()
3542

36-
# Command execution
37-
def exec_command(self, cmd, wait_exit=False, verbose=False,
38-
expect_error=False, encoding=None, shell=False, text=False,
39-
input=None, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
40-
get_process=None, timeout=None):
41-
"""
42-
Execute a command in a subprocess.
43-
44-
Args:
45-
- cmd: The command to execute.
46-
- wait_exit: Whether to wait for the subprocess to exit before returning.
47-
- verbose: Whether to return verbose output.
48-
- expect_error: Whether to raise an error if the subprocess exits with an error status.
49-
- encoding: The encoding to use for decoding the subprocess output.
50-
- shell: Whether to use shell when executing the subprocess.
51-
- text: Whether to return str instead of bytes for the subprocess output.
52-
- input: The input to pass to the subprocess.
53-
- stdout: The stdout to use for the subprocess.
54-
- stderr: The stderr to use for the subprocess.
55-
- proc: The process to use for subprocess creation.
56-
:return: The output of the subprocess.
57-
"""
58-
if os.name == 'nt':
59-
with tempfile.NamedTemporaryFile() as buf:
60-
process = subprocess.Popen(cmd, stdout=buf, stderr=subprocess.STDOUT)
61-
process.communicate()
62-
buf.seek(0)
63-
result = buf.read().decode(encoding)
64-
return result
65-
else:
43+
@staticmethod
44+
def _raise_exec_exception(message, command, exit_code, output):
45+
"""Raise an ExecUtilException."""
46+
raise ExecUtilException(message=message.format(output),
47+
command=command,
48+
exit_code=exit_code,
49+
out=output)
50+
51+
@staticmethod
52+
def _process_output(encoding, temp_file_path):
53+
"""Process the output of a command from a temporary file."""
54+
with open(temp_file_path, 'rb') as temp_file:
55+
output = temp_file.read()
56+
if encoding:
57+
output = output.decode(encoding)
58+
return output, None # In Windows stderr writing in stdout
59+
60+
def _run_command(self, cmd, shell, input, stdin, stdout, stderr, get_process, timeout, encoding):
61+
"""Execute a command and return the process and its output."""
62+
if os.name == 'nt' and stdout is None: # Windows
63+
with tempfile.NamedTemporaryFile(mode='w+b', delete=False) as temp_file:
64+
stdout = temp_file
65+
stderr = subprocess.STDOUT
66+
process = subprocess.Popen(
67+
cmd,
68+
shell=shell,
69+
stdin=stdin or subprocess.PIPE if input is not None else None,
70+
stdout=stdout,
71+
stderr=stderr,
72+
)
73+
if get_process:
74+
return process, None, None
75+
temp_file_path = temp_file.name
76+
77+
# Wait process finished
78+
process.wait()
79+
80+
output, error = self._process_output(encoding, temp_file_path)
81+
return process, output, error
82+
else: # Other OS
6683
process = subprocess.Popen(
6784
cmd,
6885
shell=shell,
69-
stdout=stdout,
70-
stderr=stderr,
86+
stdin=stdin or subprocess.PIPE if input is not None else None,
87+
stdout=stdout or subprocess.PIPE,
88+
stderr=stderr or subprocess.PIPE,
7189
)
7290
if get_process:
73-
return process
74-
91+
return process, None, None
7592
try:
76-
result, error = process.communicate(input, timeout=timeout)
93+
output, error = process.communicate(input=input.encode(encoding) if input else None, timeout=timeout)
94+
if encoding:
95+
output = output.decode(encoding)
96+
error = error.decode(encoding)
97+
return process, output, error
7798
except subprocess.TimeoutExpired:
7899
process.kill()
79100
raise ExecUtilException("Command timed out after {} seconds.".format(timeout))
80-
exit_status = process.returncode
81-
82-
error_found = exit_status != 0 or any(marker in error for marker in error_markers)
83101

84-
if encoding:
85-
result = result.decode(encoding)
86-
error = error.decode(encoding)
87-
88-
if expect_error:
89-
raise Exception(result, error)
90-
91-
if exit_status != 0 or error_found:
92-
if exit_status == 0:
93-
exit_status = 1
94-
raise ExecUtilException(message='Utility exited with non-zero code. Error `{}`'.format(error),
95-
command=cmd,
96-
exit_code=exit_status,
97-
out=result)
98-
if verbose:
99-
return exit_status, result, error
100-
else:
101-
return result
102+
def exec_command(self, cmd, wait_exit=False, verbose=False, expect_error=False, encoding=None, shell=False,
103+
text=False, input=None, stdin=None, stdout=None, stderr=None, get_process=False, timeout=None):
104+
"""
105+
Execute a command in a subprocess and handle the output based on the provided parameters.
106+
"""
107+
process, output, error = self._run_command(cmd, shell, input, stdin, stdout, stderr, get_process, timeout, encoding)
108+
if get_process:
109+
return process
110+
if process.returncode != 0 or (has_errors(error) and not expect_error):
111+
self._raise_exec_exception('Utility exited with non-zero code. Error `{}`', cmd, process.returncode, error)
112+
113+
if verbose:
114+
return process.returncode, output, error
115+
else:
116+
return output
102117

103118
# Environment setup
104119
def environ(self, var_name):
@@ -210,7 +225,7 @@ def read(self, filename, encoding=None, binary=False):
210225
if binary:
211226
return content
212227
if isinstance(content, bytes):
213-
return content.decode(encoding or 'utf-8')
228+
return content.decode(encoding or get_default_encoding())
214229
return content
215230

216231
def readlines(self, filename, num_lines=0, binary=False, encoding=None):

Diff for: testgres/operations/os_ops.py

+7-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import locale
2+
13
try:
24
import psycopg2 as pglib # noqa: F401
35
except ImportError:
@@ -14,6 +16,10 @@ def __init__(self, host='127.0.0.1', ssh_key=None, username=None):
1416
self.username = username
1517

1618

19+
def get_default_encoding():
20+
return locale.getdefaultlocale()[1] or 'UTF-8'
21+
22+
1723
class OsOperations:
1824
def __init__(self, username=None):
1925
self.ssh_key = None
@@ -75,7 +81,7 @@ def write(self, filename, data, truncate=False, binary=False, read_and_write=Fal
7581
def touch(self, filename):
7682
raise NotImplementedError()
7783

78-
def read(self, filename):
84+
def read(self, filename, encoding, binary):
7985
raise NotImplementedError()
8086

8187
def readlines(self, filename):

0 commit comments

Comments
 (0)