Статьи

Перенаправление всех видов стандартного вывода в Python

Обычная задача в Python (особенно во время тестирования или отладки) — перенаправить sys.stdout в поток или файл при выполнении некоторого фрагмента кода. Однако, просто «перенаправление stdout» иногда не так просто, как можно было бы ожидать; отсюда и немного странное название этого поста. В частности, становится интересным, когда вы хотите, чтобы код C, работающий внутри вашего процесса Python (включая, помимо прочего, модули Python, реализованные как расширения C), также перенаправлял свой стандартный вывод в соответствии с вашим желанием. Это оказывается хитрым и ведет нас в интересный мир файловых дескрипторов, буферов и системных вызовов.

Но давайте начнем с основ.

Чистый Питон

Самый простой случай возникает, когда базовый код Python записывает в стандартный вывод, вызывая print, sys.stdout.write или какой-либо эквивалентный метод. Если ваш код выполняет всю печать из Python, перенаправление очень просто. В Python 3.4 у нас даже есть встроенный инструмент в стандартной библиотеке для этой цели — contextlib.redirect_stdout. Вот как это использовать:

from contextlib import redirect_stdout

f = io.StringIO()
with redirect_stdout(f):
    print('foobar')
    print(12)
print('Got stdout: "{0}"'.format(f.getvalue()))

Когда этот код выполняется, фактические вызовы печати внутри блока with ничего не выводят на экран, и вы увидите их вывод, захваченный потоком f. Кстати, обратите внимание, насколько идеален оператор with для этой цели — все в блоке перенаправляется; как только блок сделан, все очищается для вас, и перенаправление прекращается.

Если вы застряли на старом и некрутом Python до 3.4 [1], что тогда? Что ж, redirect_stdout действительно легко реализовать самостоятельно. Я немного изменю его название, чтобы избежать путаницы:

from contextlib import contextmanager

@contextmanager
def stdout_redirector(stream):
    old_stdout = sys.stdout
    sys.stdout = stream
    try:
        yield
    finally:
        sys.stdout = old_stdout

Итак, мы вернулись в игру:

f = io.StringIO()
with stdout_redirector(f):
    print('foobar')
    print(12)
print('Got stdout: "{0}"'.format(f.getvalue()))

Перенаправление потоков уровня C

Теперь давайте возьмем наш блестящий перенаправитель для более сложной поездки:

import ctypes
libc = ctypes.CDLL(None)

f = io.StringIO()
with stdout_redirector(f):
    print('foobar')
    print(12)
    libc.puts(b'this comes from C')
    os.system('echo and this is from echo')
print('Got stdout: "{0}"'.format(f.getvalue()))

Я использую ctypes, чтобы напрямую вызывать функцию put библиотеки C [2]. Это имитирует то, что происходит, когда код C, вызываемый из нашего кода Python, печатается на стандартный вывод — то же самое применимо к модулю Python, использующему расширение C. Другим дополнением является системный вызов os.s для вызова подпроцесса, который также печатает в стандартный вывод. Что мы получаем из этого:

this comes from C
and this is from echo
Got stdout: "foobar
12
"

Эээ … ничего хорошего. Отпечатки были перенаправлены, как и ожидалось, но выходные данные пут и эхо пролетели мимо нашего перенаправителя и оказались в терминале, не будучи пойманными. Что дает?

Чтобы понять, почему это не сработало, нам нужно сначала понять, что такое sys.stdout в Python.

Обход — на файловых дескрипторах и потоках

Этот раздел погружается в некоторые внутренние компоненты операционной системы, библиотеки C и Python [3]. Если вы просто хотите узнать, как правильно перенаправить распечатки с C в Python, вы можете спокойно перейти к следующему разделу (хотя понять, как работает перенаправление, будет сложно).

Файлы открываются ОС, которая хранит общесистемную таблицу открытых файлов, некоторые из которых могут указывать на одни и те же базовые данные диска (два процесса могут одновременно открывать один и тот же файл, каждый из которых читает из другого места, и т.д.)

Файловые дескрипторы являются еще одной абстракцией, которая управляется для каждого процесса. Каждый процесс имеет свою собственную таблицу дескрипторов открытых файлов, которые указывают на общесистемную таблицу. Вот схема, взятая из интерфейса программирования Linux :

Файловые дескрипторы позволяют обмениваться открытыми файлами между процессами (например, при создании дочерних процессов с помощью fork). Они также полезны для перенаправления с одной записи на другую, которая имеет отношение к этой записи. Предположим, что мы делаем файловый дескриптор 5 копией файлового дескриптора 4. Тогда все записи в 5 будут вести себя так же, как и записи в 4. В сочетании с тем фактом, что стандартный вывод — это просто еще один файловый дескриптор в Unix (обычно индекс 1) Вы можете видеть, куда это идет. Полный код приведен в следующем разделе.

Однако файловые дескрипторы — это не конец истории. Вы можете читать и писать им с помощью системных вызовов read и write, но обычно это не так. Библиотека времени выполнения C предоставляет удобную абстракцию вокруг файловых дескрипторов — потоков. Они представляются программисту как непрозрачная структура FILE с набором функций, которые воздействуют на нее (например, fprintf и fgets).

FILE является довольно сложной структурой, но самое важное, что нужно знать о ней, это то, что она содержит файловый дескриптор, на который направлены фактические системные вызовы, и обеспечивает буферизацию , чтобы гарантировать, что системный вызов (который дорог) звонил слишком часто. Предположим, вы записываете что-то в двоичный файл, байт или два за раз. Небуферизованная запись в файловый дескриптор с записью будет довольно дорогой, потому что каждая запись вызывает системный вызов. С другой стороны, использование fwrite намного дешевле, потому что обычный вызов этой функции просто копирует ваши данные во внутренний буфер и перемещает указатель. Только изредка (в зависимости от размера буфера и флагов) будет выполнен фактический системный вызов записи.

Имея эту информацию в руках, должно быть легко понять, что такое стандартный вывод для программы на Си. stdout — это глобальный объект FILE, хранящийся для нас библиотекой C, и он буферизует вывод в дескриптор файла номер 1. Вызывает такие функции, как printf, и помещает данные добавления в этот буфер. fflush принудительно сбрасывает его в дескриптор файла и так далее.

Но здесь мы говорим о Python, а не C. Так как Python переводит вызовы sys.stdout.write в реальный вывод?

Python использует собственную абстракцию над базовым файловым дескриптором — файловым объектом . Более того, в Python 3 этот файловый объект является еще одной оберткой в ​​io.TextIOWrapper, потому что то, что мы передаем для печати, является строкой Unicode, но лежащие в основе системные вызовы write принимают двоичные данные, поэтому кодирование должно происходить в пути.

Важный вывод из этого: Python и расширение C, загруженное им (это также относится к коду C, вызываемому через ctypes), выполняются в одном и том же процессе и совместно используют базовый дескриптор файла для стандартного вывода. Однако, хотя Python имеет свою собственную высокоуровневую оболочку — sys.stdout, код C использует свой собственный объект FILE. Следовательно, простая замена sys.stdout не может, в принципе, повлиять на вывод кода C. Чтобы сделать замену глубже, мы должны коснуться чего-то общего для сред выполнения Python и C — дескриптора файла.

Перенаправление с дублированием файлового дескриптора

Без лишних слов, вот улучшенный stdout_redirector, который также перенаправляет вывод из кода C [4]:

from contextlib import contextmanager
import ctypes
import io
import os, sys
import tempfile

libc = ctypes.CDLL(None)
c_stdout = ctypes.c_void_p.in_dll(libc, 'stdout')

@contextmanager
def stdout_redirector(stream):
    # The original fd stdout points to. Usually 1 on POSIX systems.
    original_stdout_fd = sys.stdout.fileno()

    def _redirect_stdout(to_fd):
        """Redirect stdout to the given file descriptor."""
        # Flush the C-level buffer stdout
        libc.fflush(c_stdout)
        # Flush and close sys.stdout - also closes the file descriptor (fd)
        sys.stdout.close()
        # Make original_stdout_fd point to the same file as to_fd
        os.dup2(to_fd, original_stdout_fd)
        # Create a new sys.stdout that points to the redirected fd
        sys.stdout = io.TextIOWrapper(os.fdopen(original_stdout_fd, 'wb'))

    # Save a copy of the original stdout fd in saved_stdout_fd
    saved_stdout_fd = os.dup(original_stdout_fd)
    try:
        # Create a temporary file and redirect stdout to it
        tfile = tempfile.TemporaryFile(mode='w+b')
        _redirect_stdout(tfile.fileno())
        # Yield to caller, then redirect stdout back to the saved fd
        yield
        _redirect_stdout(saved_stdout_fd)
        # Copy contents of temporary file to the given stream
        tfile.flush()
        tfile.seek(0, io.SEEK_SET)
        stream.write(tfile.read())
    finally:
        tfile.close()
        os.close(saved_stdout_fd)

Здесь есть много деталей (например, управление временным файлом, в который перенаправляется вывод), которые могут скрыть ключевой подход: использование dup и dup2 для манипулирования дескрипторами файлов. Эти функции позволяют нам дублировать файловые дескрипторы и указывать любой дескриптор на любой файл. Я не буду тратить на них больше времени — читайте их документацию, если вам интересно. В объездном разделе должно быть достаточно информации, чтобы понять это.

Давайте попробуем это:

f = io.BytesIO()

with stdout_redirector(f):
    print('foobar')
    print(12)
    libc.puts(b'this comes from C')
    os.system('echo and this is from echo')
print('Got stdout: "{0}"'.format(f.getvalue().decode('utf-8')))

Дает нам:

Got stdout: "and this is from echo
this comes from C
foobar
12
"

Успех! Несколько вещей, на которые стоит обратить внимание:

  1. Порядок вывода может не соответствовать ожидаемому. Это связано с буферизацией. Если важно сохранить порядок между различными видами вывода (например, между C и Python), требуется дальнейшая работа по отключению буферизации во всех соответствующих потоках.
  2. Вы можете удивиться, почему вывод echo вообще был перенаправлен? Ответ заключается в том, что файловые дескрипторы наследуются подпроцессами. Так как мы настроили fd 1, чтобы он указывал на наш файл вместо стандартного вывода до разветвления echo, это то место, куда пошёл его вывод.
  3. Мы используем BytesIO здесь. Это потому, что на самом низком уровне файловые дескрипторы являются двоичными. Может быть возможно выполнить декодирование при копировании из временного файла в данный поток, но это может скрыть проблемы. У Python есть понимание Unicode в памяти, но кто знает, какова правильная кодировка для данных, распечатанных из основного кода C? Вот почему этот конкретный подход перенаправления оставляет декодирование вызывающей стороне.
  4. Вышеприведенное также делает этот код специфичным для Python 3. В этом нет никакой магии, и перенос на Python 2 тривиален, но некоторые сделанные здесь предположения не верны (например, sys.stdout является io.TextIOWrapper).

Перенаправление stdout дочернего процесса

Мы только что увидели, что подход дублирования файловых дескрипторов позволяет нам также получать выходные данные дочерних процессов. Но это не всегда самый удобный способ для достижения этой цели. В общем случае вы обычно используете модуль подпроцесса для запуска дочерних процессов и можете запускать несколько таких процессов либо в конвейере, либо по отдельности. Некоторые программы даже манипулируют несколькими подпроцессами, запущенными таким образом, в разных потоках. Более того, пока эти подпроцессы запущены, вы можете захотеть передать что-то на стандартный вывод, и вы не хотите, чтобы этот вывод был захвачен.

Таким образом, управление дескриптором файла stdout в общем случае может быть грязным; это также не нужно, потому что есть гораздо более простой способ.

Класс Popen швейцарского ножа модуля подпроцесса (который служит основой для большей части остальной части модуля) принимает параметр stdout, который мы можем использовать, чтобы попросить его получить доступ к stdout дочернего элемента:

import subprocess

echo_cmd = ['echo', 'this', 'comes', 'from', 'echo']
proc = subprocess.Popen(echo_cmd, stdout=subprocess.PIPE)
output = proc.communicate()[0]
print('Got stdout:', output)

Аргумент subprocess.PIPE может использоваться для установки фактических дочерних каналов процесса (а-ля оболочки), но в простейшем воплощении он захватывает выходные данные процесса.

Если вы одновременно запускаете только один дочерний процесс и заинтересованы в его выводе, есть еще более простой способ:

output = subprocess.check_output(echo_cmd)
print('Got stdout:', output)

check_output захватит и вернет вам стандартный вывод ребенка; это также вызовет исключение, если дочерний элемент существует с ненулевым кодом возврата.

Вывод

Надеюсь, я рассмотрел большинство распространенных случаев, когда в Python требуется «перенаправление stdout». Естественно, все то же самое относится и к другому стандартному выходному потоку — stderr. Кроме того, я надеюсь, что фон файловых дескрипторов был достаточно ясен, чтобы объяснить код перенаправления; втиснуть эту тему в такой короткий промежуток времени сложно. Дайте мне знать, если останутся какие-либо вопросы или есть что-то, что я мог бы объяснить лучше.

Наконец, хотя это концептуально просто, код для перенаправителя довольно длинный; Я буду рад услышать, если вы найдете более короткий путь для достижения того же эффекта.


[1] Не отчаивайся. По состоянию на февраль 2015 года значительная часть программистов Python во всем мире находится в одной лодке.
[2] Обратите внимание, что байты переданы на путы. Будучи Python 3, мы должны быть осторожны, так как libc не понимает Unicode-строки Python.
[3] Следующее описание посвящено системам Unix / POSIX; Кроме того, это обязательно частично. На эту тему написаны большие главы книг — я просто пытаюсь представить некоторые ключевые концепции, относящиеся к перенаправлению потока.
[4] Этот подход основан на ответе о переполнении стека .