2025, Sep 24 03:16
Как управлять дочерним PTY в Python и отслеживать экран терминала
Запуск программ в PTY на Python, интерпретация управляющих кодов и ведение виртуального экрана. Как читать состояние терминала и тестировать TUI на практике.
Управлять интерактивной программой через дочерний PTY и запрашивать состояние экрана в произвольных позициях кажется простой задачей — пока не попробуешь сделать это на Python. Цель понятна: запустить подпроцесс в псевдотерминале, считывать его вывод так, как это делает терминал, отправлять нажатия клавиш в ответ и в любой момент иметь возможность спросить: «какой символ находится в строке r, столбце c?» Сложность в том, что терминалы — это не простые текстовые потоки. Это устройства со внутренним состоянием, управляемые управляющими последовательностями, и это меняет картину целиком.
Постановка задачи
Ниже — упрощённый сценарий в виде короткого фрагмента. Мы запускаем программу в PTY, смотрим на конкретную ячейку экрана, подаём ввод и проверяем, что ячейка изменилась как ожидается.
# концептуальное использование
session = create_pty()
session.launch(app_to_run)
session.peek_cell(2, 4)  # ожидается "@"
session.feed_keys("A")
session.peek_cell(2, 4)  # ожидается "!" после того, как ввод изменит экран
Что на самом деле мешает
В стандартной библиотеке нет инструмента, который сделает это за вас. PTY операционной системы предоставляет поток байтов. Он не знает, что такое тип терминала; это знание находится на другой стороне соединения — в физическом терминале или эмуляторе терминала вроде xterm, putty, MacTerm, cmd или PowerShell. Чтобы отслеживать, что сейчас «на экране», недостающий компонент нужно реализовать самостоятельно.
Для этого подпроцесс должен стартовать с корректным типом терминала в окружении. Даже после этого его вывод нужно интерпретировать согласно этому типу терминала. То есть преобразовывать управляющие коды терминала в перемещения курсора, вывод символов, стирание и прокрутку, а затем поддерживать в памяти модель экрана, которая отражает то, что показал бы настоящий терминал. Задача нетривиальная.
Подход к решению
На практике подход состоит из двух шагов. Во‑первых, запустите дочерний процесс внутри PTY и убедитесь, что он видит нужный тип терминала. Во‑вторых, перехватывайте его вывод и интерпретируйте его с учётом этого типа, ведя собственный виртуальный экранный буфер, к которому можно обращаться по координатам. Запись в PTY отправляет ввод дочернему процессу — так же, как печать в оконном терминале. При проектировании взаимодействия с терминалом можно присмотреться к библиотеке curses, но ключевое требование не меняется: нужно интерпретировать вывод подпроцесса, чтобы отслеживать состояние экрана.
import os
import pty
def run_in_pty(cmd, argv):
    master_fd, slave_fd = pty.openpty()
    pid = os.fork()
    if pid == 0:
        # дочерний процесс
        env = os.environ.copy()
        env["TERM"] = "set-proper-term"  # укажите подходящий тип терминала
        os.setsid()
        os.dup2(slave_fd, 0)
        os.dup2(slave_fd, 1)
        os.dup2(slave_fd, 2)
        os.close(master_fd)
        os.close(slave_fd)
        os.execvpe(cmd, argv, env)
    else:
        # родительский процесс
        os.close(slave_fd)
        return pid, master_fd
class DisplayModel:
    def __init__(self, rows=24, cols=80):
        self.rows = rows
        self.cols = cols
        self.grid = [[" "] * cols for _ in range(rows)]
        # в реальной реализации также отслеживались бы состояние курсора и прокрутки
    def ingest(self, data_bytes):
        # Интерпретируйте data_bytes согласно типу терминала.
        # Преобразуйте управляющие последовательности в перемещения курсора, вывод и прокрутку.
        # Намеренно не реализовано; именно это и является сложной частью.
        pass
    def glyph_at(self, r, c):
        return self.grid[r][c]
def send_keys(fd, text):
    os.write(fd, text.encode())
# пример связки (неполный; цикл чтения опущен):
# pid, pty_fd = run_in_pty("my_program", ["my_program"]) 
# view = DisplayModel()
# ... read() from pty_fd and call view.ingest(...) repeatedly ...
# send_keys(pty_fd, "A")
# cell = view.glyph_at(2, 4)
Почему это важно
Автоматизация, скрейпинг и тестирование терминальных приложений часто требуют проверки состояния экрана после каждого нажатия клавиши. Без интерпретации вывода в рамках выбранного типа терминала любая попытка «прочитать конкретный символ в строке и столбце» будет ненадёжной. Сам PTY экрана не хранит — он лишь передаёт байты. Если вам нужен экран, его придётся построить из этих байтов, как это делает эмулятор терминала.
Итоги
Если вам нужно обращаться к дочернему PTY как к двумерной сетке со случайным доступом, закладывайте две обязанности: обеспечить дочернему процессу правильный тип терминала и реализовать слой, который преобразует его вывод в виртуальный экран. Для этого нет готовой функции в стандартной библиотеке, а основная сложность — именно логика интерпретации. Если вы работаете с терминальными интерфейсами, стоит посмотреть на библиотеку curses для паттернов взаимодействия, но ключевое требование остаётся прежним: необходимо переводить управляющие коды терминала в достоверную модель экрана, чтобы можно было надёжно отвечать на вопросы вроде «какой символ в строке r, столбце c?»