2026, Jan 13 12:02

Нормализация переводов строк в Python-тестах: text=True и stdout без CRLF

Почему в Windows stdout даёт CRLF и ломает тесты. Как использовать subprocess.run(text=True) или отключить перевод строк, чтобы вывод совпадал с эталоном.

Преобразовать список слов английский–латынь в словарь латынь–английский кажется простым делом, пока ОС незаметно не подсовывает невидимый символ и не ломает ваши тесты. Сама задача проста: прочитать один или несколько текстовых файлов, где каждая строка выглядит как «english - latin1, latin2, ...», инвертировать соответствия, убрать дубликаты переводов между файлами и вывести результат. Подвох в том, что тестовый раннер перехватывает stdout и сравнивает его с эталонным файлом, ожидая переносы строк в формате Unix. На Windows это означает, что формально корректный вывод будет отличаться лишним символом возврата каретки.

Задача, ожидаемый вывод и где всё идёт не так

Входные файлы выглядят так:

apple - malum, pomum, popula
fruit - baca, bacca, popum
punishment - malum, multa

Требуемый вывод:

baca - fruit
bacca - fruit
malum - apple, punishment
multa - punishment
pomum - apple
popula - apple
popum - fruit

Но в Windows обычный print с «\n» превращает его в «\r\n» в stdout, и прямое сравнение строки с файлом, где используется «\n», падает. Это видно на минимальном примере, воспроизводящем проблему:

import sys
for src_path in sys.argv[1:]:
    chunks = ['Hello, ', 'World!']
    print('\n'.join(chunks))

В тесте, который читает stdout как байты и декодирует, фактический вывод содержит «\r\n», а в ожидаемом — только «\n», из‑за чего сравнение не сходится.

Почему так происходит

В Windows стандартный TextIOWrapper для sys.stdout при выводе в консоль преобразует «\n» в «\r\n». Рассматриваемый тест запускает скрипт через subprocess, перехватывает stdout через pipe, затем вручную декодирует байты и сравнивает с файлом, прочитанным как текст. Ключевой момент: тест не нормализует переводы строк перед сравнением, поэтому получается «\r\n» против «\n». В итоге тест оказывается непереносимым между ОС.

Правильное исправление — в самом тесте

Надёжнее всего поручить декодирование и обработку переводов строк самому subprocess. С text=True, subprocess.run возвращает строку и нормализует переводы строк, так что полученный текст совпадает с тем, как читается эталонный файл. Ручное декодирование не нужно.

import os
import subprocess
run_info = subprocess.run(
    ["python", os.path.join(SOLUTION_FOLDER_PATH, "task3.py"), test_input_path],
    stdout=subprocess.PIPE,
    text=True
)
actual_text = run_info.stdout.strip()
with open(expected_path, "r") as fh:
    expected_text = fh.read().strip()
assert actual_text == expected_text

Так сравнение остаётся стабильным на всех платформах и отпадает хрупкая схема «байты → декодирование».

Если тест менять нельзя: отключите преобразование переводов строк в своём скрипте

Если тест трогать нельзя, настройте stdout в своём скрипте так, чтобы при печати «\n» записывалось буквально «\n» (а не «\r\n»). Оберните sys.stdout в TextIOWrapper, который отключает преобразование переводов строк. Поместите это сразу после импортов.

import io
import sys
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, newline='')

Учтите, что некоторые IDE перенаправляют sys.stdout так, что у него нет .buffer или используется нестандартный TextIOWrapper. В обычном Python из командной строки на Windows этот подход работает как задумано. В отдельных IDE — возможно, нет.

Скрипт словаря с безопасной обработкой переводов строк на stdout

Этот фрагмент читает файлы из argv, инвертирует соответствия английский–латынь в латынь–английский, сортирует ключи и печатает строки, соединённые «\n». Обработка переводов строк стабилизирована на уровне stdout, как показано выше. Логика программы не меняется.

import io
import sys
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, newline='')
for input_path in sys.argv[1:]:
    reversed_map = {}
    with open(input_path, 'r', encoding='utf-8') as handle:
        for raw in handle.readlines():
            eng_term = raw.split()[0]
            latin_parts = raw.strip().replace(',', '').split()[2:]
            for lt in latin_parts:
                if lt in reversed_map:
                    reversed_map[lt].append(eng_term)
                else:
                    reversed_map.setdefault(lt, [eng_term])
    lines_out = []
    for lexeme, eng_list in sorted(reversed_map.items()):
        lines_out.append(lexeme + ' - ' + ', '.join(eng_list))
    print('\n'.join(lines_out))

Почему это важно для ваших проектов и CI

Концы строк — классическая ловушка переносимости. Когда stdout сравнивают побайтно с «золотым» файлом, платформенное преобразование переводов строк даёт ложные падения. Подкрутить настройки редактора или заменить символы постфактум не помогает — преобразование происходит на уровне ввода-вывода. Исправлять это нужно там, где выполняются декодирование и нормализация: в тесте через text=True, либо на стороне писателя — отключив трансляцию на stdout, когда требуется строгое соответствие.

Если эталон берётся из файлов, следите, чтобы обе стороны сравнения нормализовались одинаково. Если вы контролируете раннер, читайте ожидаемое содержимое в текстовом режиме и сравнивайте с текстовым выводом из subprocess. Если под контролем только сам скрипт, переобёртка stdout, как выше, — рабочий обходной путь, пока окружение предоставляет sys.stdout.buffer.

Итоги

Предпочитайте text=True в subprocess.run и сравнивайте текст с текстом — так тесты остаются переносимыми, а ручное декодирование не требуется. Если изменить раннер нельзя, переоберните sys.stdout, чтобы «\n» не превращалось в «\r\n». Осторожнее с IDE, которые подменяют sys.stdout собственными объектами. И при работе с эталонными файлами держите окончания строк согласованными по обе стороны сравнения, чтобы не получать падения «не по делу».