Skip to content

Commit 36ec2c0

Browse files
authored
implement pseudo tty on stdout/stderr (#2711)
1 parent e26eb7b commit 36ec2c0

File tree

5 files changed

+88
-12
lines changed

5 files changed

+88
-12
lines changed

docs/changelog/1773.bugfix.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
If tox is running in a tty, allocate a pty (pseudo terminal) for commands
2+
and copy termios attributes to show colors and improve interactive use - by :user:`masenf`.

src/tox/execute/local_sub_process/__init__.py

Lines changed: 63 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from __future__ import annotations
33

44
import fnmatch
5+
import io
56
import logging
67
import os
78
import shutil
@@ -240,12 +241,19 @@ def __exit__(
240241

241242
@staticmethod
242243
def get_stream_file_no(key: str) -> Generator[int, Popen[bytes], None]:
243-
process = yield PIPE
244-
stream = getattr(process, key)
245-
if sys.platform == "win32": # explicit check for mypy # pragma: win32 cover
246-
yield stream.handle
244+
allocated_pty = _pty(key)
245+
if allocated_pty is not None:
246+
main_fd, child_fd = allocated_pty
247+
yield child_fd
248+
os.close(child_fd) # close the child process pipe
249+
yield main_fd
247250
else:
248-
yield stream.name
251+
process = yield PIPE
252+
stream = getattr(process, key)
253+
if sys.platform == "win32": # explicit check for mypy # pragma: win32 cover
254+
yield stream.handle
255+
else:
256+
yield stream.name
249257

250258
def set_out_err(self, out: SyncWrite, err: SyncWrite) -> tuple[SyncWrite, SyncWrite]:
251259
prev = self._out, self._err
@@ -256,6 +264,56 @@ def set_out_err(self, out: SyncWrite, err: SyncWrite) -> tuple[SyncWrite, SyncWr
256264
return prev
257265

258266

267+
def _pty(key: str) -> tuple[int, int] | None:
268+
"""
269+
Allocate a virtual terminal (pty) for a subprocess.
270+
271+
A virtual terminal allows a process to perform syscalls that fetch attributes related to the tty,
272+
for example to determine whether to use colored output or enter interactive mode.
273+
274+
The termios attributes of the controlling terminal stream will be copied to the allocated pty.
275+
276+
:param key: The stream to copy attributes from. Either "stdout" or "stderr".
277+
:return: (main_fd, child_fd) of an allocated pty; or None on error or if unsupported (win32).
278+
"""
279+
if sys.platform == "win32": # explicit check for mypy # pragma: win32 cover
280+
return None
281+
282+
stream: io.TextIOWrapper = getattr(sys, key)
283+
284+
# when our current stream is a tty, emulate pty for the child
285+
# to allow host streams traits to be inherited
286+
if not stream.isatty():
287+
return None
288+
289+
try:
290+
import fcntl
291+
import pty
292+
import struct
293+
import termios
294+
except ImportError: # pragma: no cover
295+
return None # cannot proceed on platforms without pty support
296+
297+
try:
298+
main, child = pty.openpty()
299+
except OSError: # could not open a tty
300+
return None # pragma: no cover
301+
302+
try:
303+
mode = termios.tcgetattr(stream)
304+
termios.tcsetattr(child, termios.TCSANOW, mode)
305+
except (termios.error, OSError): # could not inherit traits
306+
return None # pragma: no cover
307+
308+
# adjust sub-process terminal size
309+
columns, lines = shutil.get_terminal_size(fallback=(-1, -1))
310+
if columns != -1 and lines != -1:
311+
size = struct.pack("HHHH", columns, lines, 0, 0)
312+
fcntl.ioctl(child, termios.TIOCSWINSZ, size)
313+
314+
return main, child
315+
316+
259317
__all__ = (
260318
"SIG_INTERRUPT",
261319
"CREATION_FLAGS",

src/tox/execute/stream.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -55,12 +55,12 @@ def handler(self, content: bytes) -> None:
5555
at = content.rfind(b"\n")
5656
if at != -1: # pragma: no branch
5757
at = len(self._content) - len(content) + at + 1
58-
if at != -1:
59-
self._cancel()
60-
try:
58+
self._cancel()
59+
try:
60+
if at != -1:
6161
self._write(at)
62-
finally:
63-
self._start()
62+
finally:
63+
self._start()
6464

6565
def _start(self) -> None:
6666
self.timer = Timer(self.REFRESH_RATE, self._trigger_timer)

tests/execute/local_subprocess/test_local_subprocess.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,15 @@ def test_local_subprocess_tty(monkeypatch: MonkeyPatch, mocker: MockerFixture, t
267267
tty = tty_mode == "on"
268268
mocker.patch("sys.stdout.isatty", return_value=tty)
269269
mocker.patch("sys.stderr.isatty", return_value=tty)
270+
try:
271+
import termios # noqa: F401
272+
except ImportError:
273+
exp_tty = False # platforms without tty support at all
274+
else:
275+
# to avoid trying (and failing) to copy mode bits
276+
exp_tty = tty
277+
mocker.patch("termios.tcgetattr")
278+
mocker.patch("termios.tcsetattr")
270279

271280
executor = LocalSubProcessExecutor(colored=False)
272281
cmd: list[str] = [sys.executable, str(Path(__file__).parent / "tty_check.py")]
@@ -281,8 +290,8 @@ def test_local_subprocess_tty(monkeypatch: MonkeyPatch, mocker: MockerFixture, t
281290
assert outcome
282291
info = json.loads(outcome.out)
283292
assert info == {
284-
"stdout": False,
285-
"stderr": False,
293+
"stdout": exp_tty,
294+
"stderr": exp_tty,
286295
"stdin": False,
287296
"terminal": [100, 100],
288297
}

whitelist.txt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ nonlocal
108108
notset
109109
nox
110110
objtype
111+
openpty
111112
ov
112113
pathname
113114
pep517
@@ -119,6 +120,7 @@ posix
119120
prereleases
120121
prj
121122
psutil
123+
pty
122124
purelib
123125
py311
124126
py38
@@ -156,6 +158,11 @@ statemachine
156158
string2lines
157159
stringify
158160
subparsers
161+
tcgetattr
162+
TCSANOW
163+
tcsetattr
164+
TIOCSWINSZ
165+
termios
159166
termux
160167
testenv
161168
tmpdir

0 commit comments

Comments
 (0)