Skip to content

Commit 6a0d5f0

Browse files
xxthundercuinixam
authored andcommitted
feat: make subprocess executor robust against invalid encoding and capturing output while printing
1 parent 206523a commit 6a0d5f0

4 files changed

Lines changed: 280 additions & 121 deletions

File tree

README.md

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -38,24 +38,47 @@
3838

3939
My application development modules.
4040

41-
## Installation
41+
## Start developing
4242

43-
Install this via pip (or your favourite package manager):
43+
The project uses UV for dependencies management and packaging and the [pypeline](https://github.com/cuinixam/pypeline) for streamlining the development workflow.
44+
Use pipx (or your favorite package manager) to install the `pypeline` in an isolated environment:
4445

45-
`pip install py-app-dev`
46-
47-
## Usage
46+
```shell
47+
pipx install pypeline-runner
48+
```
4849

49-
Start by importing it:
50+
To bootstrap the project and run all the steps configured in the `pypeline.yaml` file, execute the following command:
5051

51-
```python
52-
import py_app_dev
52+
```shell
53+
pypeline run
5354
```
5455

56+
For those using [VS Code](https://code.visualstudio.com/) there are tasks defined for the most common commands:
5557

56-
## Credits
58+
- run tests
59+
- run pre-commit checks (linters, formatters, etc.)
60+
- generate documentation
61+
62+
See the `.vscode/tasks.json` for more details.
63+
64+
## Committing changes
65+
66+
This repository uses [commitlint](https://github.com/conventional-changelog/commitlint) for checking if the commit message meets the [conventional commit format](https://www.conventionalcommits.org/en).
5767

58-
[![Copier](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/copier-org/copier/master/img/badge/badge-grayscale-inverted-border-orange.json)](https://github.com/copier-org/copier)
68+
## Contributors ✨
69+
70+
Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)):
71+
72+
<!-- prettier-ignore-start -->
73+
<!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->
74+
<!-- markdownlint-disable -->
75+
<!-- markdownlint-enable -->
76+
<!-- ALL-CONTRIBUTORS-LIST:END -->
77+
<!-- prettier-ignore-end -->
78+
79+
This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome!
80+
81+
## Credits
5982

6083
This package was created with
6184
[Copier](https://copier.readthedocs.io/) and the

docs/conf.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,9 +66,7 @@
6666
traceability_collapse_links = True
6767

6868
# The suffix of source filenames.
69-
source_suffix = [
70-
".md",
71-
]
69+
source_suffix = [".md", ".rst"]
7270

7371
templates_path = ["_templates"]
7472
exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]

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

0 commit comments

Comments
 (0)