Skip to content

Commit

Permalink
pty: attempt to allocate psuedo tty for processes
Browse files Browse the repository at this point in the history
The purpose of allocating a pty is to allow better interactive use and
enable commands to detect that they are running interactively.

Based on starter code on issue tox-dev#1773. Fixes issue tox-dev#1773.
  • Loading branch information
masenf committed Dec 16, 2022
1 parent 9b06df5 commit 39e1f00
Show file tree
Hide file tree
Showing 3 changed files with 76 additions and 7 deletions.
2 changes: 2 additions & 0 deletions docs/changelog/1773.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
If tox is running in a tty, allocate a pty (pseudo terminal) for commands
and copy termios attributes to show colors and improve interactive use - by :user:`masenf`.
68 changes: 63 additions & 5 deletions src/tox/execute/local_sub_process/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from __future__ import annotations

import fnmatch
import io
import logging
import os
import shutil
Expand Down Expand Up @@ -240,12 +241,19 @@ def __exit__(

@staticmethod
def get_stream_file_no(key: str) -> Generator[int, Popen[bytes], None]:
process = yield PIPE
stream = getattr(process, key)
if sys.platform == "win32": # explicit check for mypy # pragma: win32 cover
yield stream.handle
allocated_pty = _pty(key)
if allocated_pty is not None:
main_fd, child_fd = allocated_pty
yield child_fd
os.close(child_fd) # close the child process pipe
yield main_fd
else:
yield stream.name
process = yield PIPE
stream = getattr(process, key)
if sys.platform == "win32": # explicit check for mypy # pragma: win32 cover
yield stream.handle
else:
yield stream.name

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


def _pty(key: str) -> tuple[int, int] | None:
"""
Allocate a virtual terminal (pty) for a subprocess.
A virtual terminal allows a process to perform syscalls that fetch attributes related to the tty,
for example to determine whether to use colored output or enter interactive mode.
The termios attributes of the controlling terminal stream will be copied to the allocated pty.
:param key: The stream to copy attributes from. Either "stdout" or "stderr".
:return: (main_fd, child_fd) of an allocated pty; or None on error or if unsupported (win32).
"""
if sys.platform == "win32": # explicit check for mypy # pragma: win32 cover
return None

stream: io.TextIOWrapper = getattr(sys, key)

# when our current stream is a tty, emulate pty for the child
# to allow host streams traits to be inherited
if not stream.isatty():
return None

try:
import fcntl
import pty
import struct
import termios
except ImportError: # pragma: no cover
return None # cannot proceed on platforms without pty support

try:
main, child = pty.openpty()
except OSError: # could not open a tty
return None # pragma: no cover

try:
mode = termios.tcgetattr(stream)
termios.tcsetattr(child, termios.TCSANOW, mode)
except (termios.error, OSError): # could not inherit traits
return None # pragma: no cover

# adjust sub-process terminal size
columns, lines = shutil.get_terminal_size(fallback=(-1, -1))
if columns != -1 and lines != -1:
size = struct.pack("HHHH", columns, lines, 0, 0)
fcntl.ioctl(child, termios.TIOCSWINSZ, size)

return main, child


__all__ = (
"SIG_INTERRUPT",
"CREATION_FLAGS",
Expand Down
13 changes: 11 additions & 2 deletions tests/execute/local_subprocess/test_local_subprocess.py
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,15 @@ def test_local_subprocess_tty(monkeypatch: MonkeyPatch, mocker: MockerFixture, t
tty = tty_mode == "on"
mocker.patch("sys.stdout.isatty", return_value=tty)
mocker.patch("sys.stderr.isatty", return_value=tty)
try:
import termios # noqa: F401
except ImportError:
exp_tty = False # platforms without tty support at all
else:
# to avoid trying (and failing) to copy mode bits
exp_tty = tty
mocker.patch("termios.tcgetattr")
mocker.patch("termios.tcsetattr")

executor = LocalSubProcessExecutor(colored=False)
cmd: list[str] = [sys.executable, str(Path(__file__).parent / "tty_check.py")]
Expand All @@ -281,8 +290,8 @@ def test_local_subprocess_tty(monkeypatch: MonkeyPatch, mocker: MockerFixture, t
assert outcome
info = json.loads(outcome.out)
assert info == {
"stdout": False,
"stderr": False,
"stdout": exp_tty,
"stderr": exp_tty,
"stdin": False,
"terminal": [100, 100],
}
Expand Down

0 comments on commit 39e1f00

Please sign in to comment.