Skip to content

Commit 7bc8cc0

Browse files
authored
Improve: run_before_script() (#959)
#958 causes the captured output to produce too many newlines. Modernize our `run_before_script()`.
2 parents 425bf3c + 03e6e68 commit 7bc8cc0

File tree

3 files changed

+119
-31
lines changed

3 files changed

+119
-31
lines changed

CHANGES

+4
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ $ pipx install --suffix=@next 'tmuxp' --pip-args '\--pre' --force
1919

2020
<!-- Maintainers, insert changes / features for the next release here -->
2121

22+
### Bug fixes
23+
24+
- `run_before_script()`: Fix output issue (#959)
25+
2226
## tmuxp 1.52.0 (2025-02-02)
2327

2428
_Maintenance only, no bug fixes or new features_

src/tmuxp/util.py

+61-30
Original file line numberDiff line numberDiff line change
@@ -28,41 +28,72 @@ def run_before_script(
2828
script_file: str | pathlib.Path,
2929
cwd: pathlib.Path | None = None,
3030
) -> int:
31-
"""Execute a shell script, wraps :meth:`subprocess.check_call()` in a try/catch."""
31+
"""Execute shell script, ``tee``-ing output to both terminal (if TTY) and buffer."""
32+
script_cmd = shlex.split(str(script_file))
33+
3234
try:
3335
proc = subprocess.Popen(
34-
shlex.split(str(script_file)),
35-
stderr=subprocess.PIPE,
36-
stdout=subprocess.PIPE,
36+
script_cmd,
3737
cwd=cwd,
38-
text=True,
38+
stdout=subprocess.PIPE,
39+
stderr=subprocess.PIPE,
40+
text=True, # decode to str
3941
errors="backslashreplace",
40-
encoding="utf-8",
4142
)
42-
if proc.stdout is not None:
43-
for line in iter(proc.stdout.readline, ""):
44-
sys.stdout.write(line)
45-
proc.wait()
46-
47-
if proc.returncode and proc.stderr is not None:
48-
stderr = proc.stderr.read()
49-
proc.stderr.close()
50-
stderr_strlist = stderr.split("\n")
51-
stderr_str = "\n".join(list(filter(None, stderr_strlist))) # filter empty
52-
53-
raise exc.BeforeLoadScriptError(
54-
proc.returncode,
55-
os.path.abspath(script_file), # NOQA: PTH100
56-
stderr_str,
57-
)
58-
except OSError as e:
59-
if e.errno == 2:
60-
raise exc.BeforeLoadScriptNotExists(
61-
e,
62-
os.path.abspath(script_file), # NOQA: PTH100
63-
) from e
64-
raise
65-
return proc.returncode
43+
except FileNotFoundError as e:
44+
raise exc.BeforeLoadScriptNotExists(
45+
e,
46+
os.path.abspath(script_file), # NOQA: PTH100
47+
) from e
48+
49+
out_buffer = []
50+
err_buffer = []
51+
52+
# While process is running, read lines from stdout/stderr
53+
# and write them to this process's stdout/stderr if isatty
54+
is_out_tty = sys.stdout.isatty()
55+
is_err_tty = sys.stderr.isatty()
56+
57+
# You can do a simple loop reading in real-time:
58+
while True:
59+
# Use .poll() to check if the child has exited
60+
return_code = proc.poll()
61+
62+
# Read one line from stdout, if available
63+
line_out = proc.stdout.readline() if proc.stdout else ""
64+
65+
# Read one line from stderr, if available
66+
line_err = proc.stderr.readline() if proc.stderr else ""
67+
68+
if line_out:
69+
out_buffer.append(line_out)
70+
if is_out_tty:
71+
sys.stdout.write(line_out)
72+
sys.stdout.flush()
73+
74+
if line_err:
75+
err_buffer.append(line_err)
76+
if is_err_tty:
77+
sys.stderr.write(line_err)
78+
sys.stderr.flush()
79+
80+
# If no more data from pipes and process ended, break
81+
if not line_out and not line_err and return_code is not None:
82+
break
83+
84+
# At this point, the process has finished
85+
return_code = proc.wait()
86+
87+
if return_code != 0:
88+
# Join captured stderr lines for your exception
89+
stderr_str = "".join(err_buffer).strip()
90+
raise exc.BeforeLoadScriptError(
91+
return_code,
92+
os.path.abspath(script_file), # NOQA: PTH100
93+
stderr_str,
94+
)
95+
96+
return return_code
6697

6798

6899
def oh_my_zsh_auto_title() -> None:

tests/test_util.py

+54-1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
from __future__ import annotations
44

5+
import pathlib
6+
import sys
57
import typing as t
68

79
import pytest
@@ -35,8 +37,59 @@ def test_run_before_script_raise_BeforeLoadScriptError_if_retcode() -> None:
3537
run_before_script(script_file)
3638

3739

38-
def test_return_stdout_if_ok(capsys: pytest.CaptureFixture[str]) -> None:
40+
@pytest.fixture
41+
def temp_script(tmp_path: pathlib.Path) -> pathlib.Path:
42+
"""Fixture of an example script that prints "Hello, world!"."""
43+
script = tmp_path / "test_script.sh"
44+
script.write_text(
45+
"""#!/bin/sh
46+
echo "Hello, World!"
47+
exit 0
48+
"""
49+
)
50+
script.chmod(0o755)
51+
return script
52+
53+
54+
@pytest.mark.parametrize(
55+
["isatty_value", "expected_output"],
56+
[
57+
(True, "Hello, World!"), # if stdout is a TTY, output should be passed through
58+
(False, ""), # if not a TTY, output is not written to sys.stdout
59+
],
60+
)
61+
def test_run_before_script_isatty(
62+
temp_script: pathlib.Path,
63+
monkeypatch: pytest.MonkeyPatch,
64+
capsys: pytest.CaptureFixture[str],
65+
isatty_value: bool,
66+
expected_output: str,
67+
) -> None:
68+
"""Verify behavior of ``isatty()``, which we mock in `run_before_script()`."""
69+
# Mock sys.stdout.isatty() to return the desired value.
70+
monkeypatch.setattr(sys.stdout, "isatty", lambda: isatty_value)
71+
72+
# Run the script.
73+
returncode = run_before_script(temp_script)
74+
75+
# Assert that the script ran successfully.
76+
assert returncode == 0
77+
78+
out, _err = capsys.readouterr()
79+
80+
# In TTY mode, we expect the output; in non-TTY mode, we expect it to be suppressed.
81+
assert expected_output in out
82+
83+
84+
def test_return_stdout_if_ok(
85+
capsys: pytest.CaptureFixture[str],
86+
monkeypatch: pytest.MonkeyPatch,
87+
) -> None:
3988
"""run_before_script() returns stdout if script succeeds."""
89+
# Simulate sys.stdout.isatty() + sys.stderr.isatty()
90+
monkeypatch.setattr(sys.stdout, "isatty", lambda: True)
91+
monkeypatch.setattr(sys.stderr, "isatty", lambda: True)
92+
4093
script_file = FIXTURE_PATH / "script_complete.sh"
4194

4295
run_before_script(script_file)

0 commit comments

Comments
 (0)