Skip to content

Commit 1e46801

Browse files
committed
feat: make subprocess executor robust against invalid encoding and capturing output while printing
1 parent 206523a commit 1e46801

2 files changed

Lines changed: 265 additions & 108 deletions

File tree

src/py_app_dev/core/subprocess.py

Lines changed: 99 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -1,88 +1,99 @@
1-
import shutil
2-
import subprocess # nosec
3-
from pathlib import Path
4-
from typing import Any
5-
6-
from .exceptions import UserNotificationException
7-
from .logging import logger
8-
9-
10-
def which(app_name: str) -> Path | None:
11-
"""Return the path to the app if it is in the PATH, otherwise return None."""
12-
app_path = shutil.which(app_name)
13-
return Path(app_path) if app_path else None
14-
15-
16-
class SubprocessExecutor:
17-
"""
18-
Execute a command in a subprocess.
19-
20-
Args:
21-
----
22-
capture_output: If True, the output of the command will be captured.
23-
print_output: If True, the output of the command will be printed to the logger.
24-
One can set this to false in order to get the output in the returned CompletedProcess object.
25-
26-
"""
27-
28-
def __init__(
29-
self,
30-
command: str | list[str | Path],
31-
cwd: Path | None = None,
32-
capture_output: bool = True,
33-
env: dict[str, str] | None = None,
34-
shell: bool = False,
35-
print_output: bool = True,
36-
):
37-
self.logger = logger.bind()
38-
self.command = command
39-
self.current_working_directory = cwd
40-
self.capture_output = capture_output
41-
self.env = env
42-
self.shell = shell
43-
self.print_output = print_output
44-
45-
@property
46-
def command_str(self) -> str:
47-
if isinstance(self.command, str):
48-
return self.command
49-
return " ".join(str(arg) if not isinstance(arg, str) else arg for arg in self.command)
50-
51-
def execute(self, handle_errors: bool = True) -> subprocess.CompletedProcess[Any] | None:
52-
"""Execute the command and return the CompletedProcess object if handle_errors is False."""
53-
try:
54-
completed_process = None
55-
stdout = ""
56-
stderr = ""
57-
self.logger.info(f"Running command: {self.command_str}")
58-
cwd_path = (self.current_working_directory or Path.cwd()).as_posix()
59-
with subprocess.Popen(
60-
args=self.command,
61-
cwd=cwd_path,
62-
stdout=(subprocess.PIPE if self.capture_output else subprocess.DEVNULL),
63-
stderr=(subprocess.STDOUT if self.capture_output else subprocess.DEVNULL),
64-
text=True,
65-
env=self.env,
66-
shell=self.shell,
67-
) as process: # nosec
68-
if self.capture_output and process.stdout is not None:
69-
if self.print_output:
70-
for line in iter(process.stdout.readline, ""):
71-
self.logger.info(line.strip())
72-
process.wait()
73-
else:
74-
stdout, stderr = process.communicate()
75-
76-
if handle_errors:
77-
# Check return code
78-
if process.returncode != 0:
79-
raise subprocess.CalledProcessError(process.returncode, self.command_str)
80-
else:
81-
completed_process = subprocess.CompletedProcess(process.args, process.returncode, stdout, stderr)
82-
except subprocess.CalledProcessError as e:
83-
raise UserNotificationException(f"Command '{self.command_str}' execution failed with return code {e.returncode}") from None
84-
except FileNotFoundError as e:
85-
raise UserNotificationException(f"Command '{self.command_str}' could not be executed. Failed with error {e}") from None
86-
except KeyboardInterrupt:
87-
raise UserNotificationException(f"Command '{self.command_str}' execution interrupted by user") from None
88-
return completed_process
1+
import locale
2+
import shutil
3+
import subprocess # nosec
4+
from pathlib import Path
5+
from typing import Any
6+
7+
from .exceptions import UserNotificationException
8+
from .logging import logger
9+
10+
11+
def which(app_name: str) -> Path | None:
12+
"""Return the path to the app if it is in the PATH, otherwise return None."""
13+
app_path = shutil.which(app_name)
14+
return Path(app_path) if app_path else None
15+
16+
17+
class SubprocessExecutor:
18+
"""
19+
Execute a command in a subprocess.
20+
21+
Args:
22+
----
23+
capture_output: If True, the output of the command will be captured.
24+
print_output: If True, the output of the command will be printed to the logger.
25+
One can set this to false in order to get the output in the returned CompletedProcess object.
26+
27+
"""
28+
29+
def __init__(
30+
self,
31+
command: str | list[str | Path],
32+
cwd: Path | None = None,
33+
capture_output: bool = True,
34+
env: dict[str, str] | None = None,
35+
shell: bool = False,
36+
print_output: bool = True,
37+
):
38+
self.logger = logger.bind()
39+
self.command = command
40+
self.current_working_directory = cwd
41+
self.capture_output = capture_output
42+
self.env = env
43+
self.shell = shell
44+
self.print_output = print_output
45+
46+
@property
47+
def command_str(self) -> str:
48+
if isinstance(self.command, str):
49+
return self.command
50+
return " ".join(str(arg) if not isinstance(arg, str) else arg for arg in self.command)
51+
52+
def execute(self, handle_errors: bool = True) -> subprocess.CompletedProcess[Any] | None:
53+
"""Execute the command and return the CompletedProcess object if handle_errors is False."""
54+
try:
55+
completed_process = None
56+
stdout = ""
57+
stderr = ""
58+
self.logger.info(f"Running command: {self.command_str}")
59+
cwd_path = (self.current_working_directory or Path.cwd()).as_posix()
60+
with subprocess.Popen(
61+
args=self.command,
62+
cwd=cwd_path,
63+
# Combine both streams to stdout (when captured)
64+
stdout=(subprocess.PIPE if self.capture_output else subprocess.DEVNULL),
65+
stderr=(subprocess.STDOUT if self.capture_output else subprocess.DEVNULL),
66+
# enables line buffering, line is flushed after each \n
67+
bufsize=1,
68+
text=True,
69+
# every new line is a \n
70+
universal_newlines=True,
71+
# decode bytes to str using current locale/system encoding
72+
encoding=locale.getpreferredencoding(False),
73+
# replace unknown characters with �
74+
errors="replace",
75+
env=self.env,
76+
shell=self.shell,
77+
) as process: # nosec
78+
if self.capture_output and process.stdout is not None:
79+
if self.print_output:
80+
for line in iter(process.stdout.readline, ""):
81+
self.logger.info(line.strip())
82+
stdout += line
83+
process.wait()
84+
else:
85+
stdout, stderr = process.communicate()
86+
87+
if handle_errors:
88+
# Check return code
89+
if process.returncode != 0:
90+
raise subprocess.CalledProcessError(process.returncode, self.command_str)
91+
else:
92+
completed_process = subprocess.CompletedProcess(process.args, process.returncode, stdout, stderr)
93+
except subprocess.CalledProcessError as e:
94+
raise UserNotificationException(f"Command '{self.command_str}' execution failed with return code {e.returncode}") from None
95+
except FileNotFoundError as e:
96+
raise UserNotificationException(f"Command '{self.command_str}' could not be executed. Failed with error {e}") from None
97+
except KeyboardInterrupt:
98+
raise UserNotificationException(f"Command '{self.command_str}' execution interrupted by user") from None
99+
return completed_process

tests/test_subprocess.py

Lines changed: 166 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,166 @@
1-
from pathlib import Path
2-
3-
from py_app_dev.core.subprocess import SubprocessExecutor, which
4-
5-
6-
def test_get_app_path():
7-
assert which("python")
8-
9-
10-
def test_subprocess_executor(tmp_path: Path) -> None:
11-
SubprocessExecutor(["python", "-V"], cwd=tmp_path, capture_output=True).execute()
12-
13-
14-
def test_subprocess_executor_no_error_handling() -> None:
15-
process = SubprocessExecutor(["python", "-V"], capture_output=True).execute(handle_errors=False)
16-
assert process and process.returncode == 0
17-
assert process.stdout == ""
18-
process = SubprocessExecutor(["python", "-V"], capture_output=True, print_output=False).execute(handle_errors=False)
19-
assert process and process.returncode == 0
20-
assert "Python" in process.stdout
1+
import os
2+
import tempfile
3+
from pathlib import Path
4+
from unittest.mock import Mock, patch
5+
6+
import pytest
7+
8+
from py_app_dev.core.subprocess import SubprocessExecutor, which
9+
10+
11+
def test_get_app_path():
12+
"""Test the which function for finding executables in PATH."""
13+
assert which("python")
14+
15+
16+
class TestSubprocessExecutor:
17+
"""Test class for SubprocessExecutor functionality."""
18+
19+
@patch("loguru._logger.Logger.info")
20+
def test_no_error_handling_scenarios(self, mock_info: Mock) -> None:
21+
"""Test various scenarios when error handling is disabled."""
22+
# Test 1: Default behavior (print_output=True) - should log both command and output
23+
mock_info.reset_mock()
24+
process = SubprocessExecutor(["python", "-V"]).execute(handle_errors=False)
25+
assert process and process.returncode == 0
26+
assert "Python" in process.stdout
27+
28+
# Verify logger was called - should have at least 2 calls: command + python version output
29+
assert mock_info.call_count >= 2
30+
# Check that the command execution was logged
31+
command_logged = any("Running command: python -V" in str(call) for call in mock_info.call_args_list)
32+
assert command_logged, "Command execution should be logged"
33+
# Check that Python version output was logged
34+
python_output_logged = any("Python" in str(call) and "Running command" not in str(call) for call in mock_info.call_args_list)
35+
assert python_output_logged, "Python version output should be logged when print_output=True"
36+
37+
# Test 2: print_output=False - should only log command, not output
38+
mock_info.reset_mock()
39+
process = SubprocessExecutor(["python", "-V"], capture_output=True, print_output=False).execute(handle_errors=False)
40+
assert process and process.returncode == 0
41+
assert "Python" in process.stdout
42+
43+
# Should only log the command execution, not the output
44+
assert mock_info.call_count == 1
45+
assert "Running command: python -V" in str(mock_info.call_args_list[0])
46+
47+
# Test 3: capture_output=False - should only log command, no stdout captured
48+
mock_info.reset_mock()
49+
process = SubprocessExecutor(["python", "-V"], capture_output=False, print_output=False).execute(handle_errors=False)
50+
assert process and process.returncode == 0
51+
assert process.stdout == ""
52+
53+
# Should only log the command execution
54+
assert mock_info.call_count == 1
55+
assert "Running command: python -V" in str(mock_info.call_args_list[0])
56+
57+
@pytest.mark.parametrize(
58+
"capture_output,print_output,expected_stdout_empty",
59+
[
60+
(True, True, False), # Capture and print - should have stdout
61+
(True, False, False), # Capture but don't print - should have stdout
62+
(False, True, True), # Don't capture but print - should have empty stdout
63+
(False, False, True), # Don't capture or print - should have empty stdout
64+
],
65+
)
66+
def test_output_capture_combinations(self, capture_output: bool, print_output: bool, expected_stdout_empty: bool) -> None:
67+
"""Test different combinations of capture_output and print_output parameters."""
68+
process = SubprocessExecutor(["python", "-V"], capture_output=capture_output, print_output=print_output).execute(handle_errors=False)
69+
70+
assert process and process.returncode == 0
71+
72+
if expected_stdout_empty:
73+
assert process.stdout == ""
74+
else:
75+
assert "Python" in process.stdout
76+
77+
@pytest.mark.parametrize(
78+
"command, exp_stdout, exp_returncode",
79+
[
80+
(["python", "-c", "print('Hello World!')"], "Hello World!\n", 0),
81+
# SubprocessExecutor redirects stderr to stdout when capture_output=True
82+
(
83+
[
84+
"python",
85+
"-c",
86+
"import sys; print('Hello World!', file=sys.stderr)",
87+
],
88+
"Hello World!\n",
89+
0,
90+
),
91+
(["python", "-c", "exit(0)"], "", 0),
92+
(["python", "-c", "exit(1)"], "", 1),
93+
(["python", "-c", "exit(42)"], "", 42),
94+
],
95+
)
96+
def test_command_execution_scenarios(self, command, exp_stdout, exp_returncode):
97+
"""Test various command execution scenarios adapted from CommandLineExecutor tests."""
98+
# Arrange
99+
executor = SubprocessExecutor(command, capture_output=True, print_output=False)
100+
101+
# Act
102+
result = executor.execute(handle_errors=False)
103+
104+
# Assert
105+
assert result is not None
106+
assert result.stdout == exp_stdout
107+
# Note: SubprocessExecutor redirects stderr to stdout, so stderr is always None
108+
# This is different from CommandLineExecutor which returned empty string for stderr
109+
assert result.stderr is None
110+
assert result.returncode == exp_returncode
111+
112+
def test_junction_creation(self, tmp_path: Path) -> None:
113+
"""Test creating a junction link (Windows-specific test adapted from CommandLineExecutor)."""
114+
import platform
115+
116+
if platform.system() != "Windows":
117+
pytest.skip("Junction creation test is Windows-specific")
118+
119+
# Arrange
120+
test_path = tmp_path.joinpath("test")
121+
test_path.mkdir()
122+
link_path = test_path.joinpath("link")
123+
command = ["cmd", "/c", "mklink", "/J", str(link_path), str(test_path)]
124+
executor = SubprocessExecutor(command, capture_output=True, print_output=False)
125+
126+
# Act
127+
result = executor.execute(handle_errors=False)
128+
129+
# Assert
130+
assert result is not None
131+
assert result.returncode == 0
132+
133+
@pytest.mark.parametrize(
134+
"stream_type, test_data, expected_text_parts",
135+
[
136+
("stdout", b"Hello\x85World\n", ["Hello", "World"]),
137+
("stderr", b"Error\x85Message\n", ["Error", "Message"]),
138+
],
139+
)
140+
def test_undecodable_bytes_handling(self, stream_type: str, test_data: bytes, expected_text_parts: list[str]) -> None:
141+
"""Test that undecodable bytes in stdout/stderr are handled gracefully."""
142+
# Arrange
143+
with tempfile.NamedTemporaryFile(mode="wb", delete=False) as tmp:
144+
# Write bytes that are invalid in UTF-8 (e.g., 0x85)
145+
tmp.write(test_data)
146+
tmp_path = tmp.name
147+
148+
try:
149+
if stream_type == "stdout":
150+
py_cmd = ["python", "-c", f"import sys; sys.stdout.buffer.write(open(r'{tmp_path}', 'rb').read())"]
151+
else: # stderr
152+
py_cmd = ["python", "-c", f"import sys; sys.stderr.buffer.write(open(r'{tmp_path}', 'rb').read())"]
153+
154+
executor = SubprocessExecutor(py_cmd, capture_output=True, print_output=False)
155+
156+
# Act
157+
result = executor.execute(handle_errors=False)
158+
159+
# Assert
160+
assert result is not None
161+
for expected_part in expected_text_parts:
162+
assert expected_part in result.stdout
163+
# Should not raise UnicodeDecodeError due to errors="replace" in subprocess.py
164+
assert result.returncode == 0
165+
finally:
166+
os.remove(tmp_path)

0 commit comments

Comments
 (0)