CNN TürkGalatasaray'da Ugochukwu'nun bileti kesildiPunch81-year-old British author wins 50,000 Booker Prize, becomes oldest recipientInquirerAmihan, easterlies to bring rains over parts of PH on Nov. 11Bollywood HungamaNora Fatehi’s appearance in Sukesh Chandrashekhar Case comes under scrutiny as defence prepares for cross-examination한겨레부마항쟁 투쟁선언문·남북탁구단일팀 금메달 예비문화유산 됐다Fox NewsVeterans Day 2026 brings free meals at popular restaurants — and some deals don't end Nov. 11ZDF heuteEntdecken Sie das ZDF-Nachrichtenstudioسكاي نيوز عربيةدبي تعتمد 11 مليار درهم موازنة لمشاريع استراتيجية جديدةVanguardNERC approves N30bn for free meters, targets May 2027Observador DesportoCharles. Vento de 130 km/h na Madeira segue para continenteالنهارتعاون بين مقرّ المؤثّرين في الإمارات و"يوتيوب" لتدريب أكثر من 1000 صانع محتوىTagesschauMarktbericht: DAX-Rekord bleibt in Sichtweite
The Daily Newsstand · Free, Always
Wednesday, November 11, 2026

Python 3.15 включил UTF-8 по умолчанию: что ломается в cp1251 и как это найти до бухгалтерии

Translate

9 октября вышел Python 3.15, 25 октября подтянулся патч 3.15.2, а 1 октября закончилась поддержка 3.10. Самое время обновлять серверы и CI. В списке изменений 3.15 есть пункт, который легко пролистать: PEP 686, UTF-8 теперь кодировка по умолчанию. Это значит, что open("file.txt") без аргумента encoding читает и пишет UTF-8, какая бы локаль ни стояла в системе.

Звучит как чистое улучшение, и в целом так и есть. Но если ваш код работает с файлами в cp1251 (выгрузки из 1С, банковские выписки, CSV для Excel, старые утилиты) и где-то забыто encoding=, после обновления он либо упадёт, либо, что хуже, начнёт тихо писать файлы, которые соседняя система прочитает кракозябрами.

Ниже разбор по схеме «проблема → воспроизведение → исправление → проверка». Все команды и выводы взяты из реального прогона на Python 3.13.5, 3.14.10 и 3.15.2.

Кого это касается

На Linux с UTF-8-локалью (а это почти любой современный сервер и любой официальный Docker-образ Python) ничего не меняется: там кодировка по умолчанию и так была UTF-8. Под локалью C/POSIX Python ещё с 3.7 сам включал UTF-8-режим.

Меняется поведение там, где локаль не UTF-8:

  • русская Windows, где кодировка ANSI равна cp1251. Это машины бухгалтерии, терминальные серверы, планировщик задач с «маленьким скриптом на Python»;

  • старые Linux-серверы, у которых исторически стоит ru_RU.CP1251 или ru_RU.KOI8-R, потому что «так было надо для 1С/Samba/чего-то ещё».

Сами файлы на диске при обновлении не меняются. Меняется то, как Python их читает и пишет, если вы не сказали ему явно.

Воспроизведение

Windows у меня под рукой нет, поэтому воспроизводил на Linux с локалью ru_RU.CP1251. Для open(), subprocess и стандартных потоков механизм тот же: Python берёт кодировку из локали, если не включён UTF-8-режим. Локаль собрал в отдельный каталог через localedef и подключил переменной LOCPATH, чтобы не трогать систему.

Сначала проверим, что думает каждая версия:

export LOCPATH=$PWD/locales LC_ALL=ru_RU.CP1251
for v in 3.13 3.14 3.15; do
  uv run --no-project -p $v python -c \
    'import sys,locale; print(sys.version.split()[0], sys.flags.utf8_mode, locale.getpreferredencoding(False))'
done
3.13.5 0 CP1251
3.14.10 0 CP1251
3.15.2 1 utf-8

Вот и всё изменение: sys.flags.utf8_mode стал 1, и locale.getpreferredencoding(False), на который опирается open(), теперь возвращает utf-8.

Сценарий 1. Падение на чтении выгрузки

Выгрузка «из 1С» в cp1251, как её обычно и отдают:

# make_export.py
rows = [
    "Дата;Контрагент;Сумма;Назначение",
    "01.11.2026;ООО «Ромашка»;15000,00;Оплата по счёту №17",
    "05.11.2026;ИП Петров;2300,50;Возврат аванса",
]
with open("export_1c.csv", "w", encoding="cp1251", newline="") as f:
    f.write("\r\n".join(rows) + "\r\n")

И типичный скрипт, который годами работал:

# report.py
import csv

total = 0.0
with open("export_1c.csv", newline="") as f:          # кодировка не указана
    for row in csv.DictReader(f, delimiter=";"):
        total += float(row["Сумма"].replace(",", "."))

with open("report.txt", "w") as out:                  # и здесь тоже
    out.write(f"Итого за ноябрь: {total:.2f} руб.\n")
print("ok", total)

На 3.14 он печатает ok 17300.5. На 3.15.2:

Traceback (most recent call last):
  File "report.py", line 5, in <module>
    for row in csv.DictReader(f, delimiter=";"):
               ~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^
  File ".../lib/python3.15/csv.py", line 174, in __next__
    self.fieldnames
  File ".../lib/python3.15/csv.py", line 161, in fieldnames
    self._fieldnames = next(self.reader)
                       ~~~~^^^^^^^^^^^^^
  File "<frozen codecs>", line 325, in decode
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xc4 in position 0: invalid continuation byte

0xc4 здесь буква «Д» из слова «Дата» в cp1251. Этот случай ещё хороший: падение заметно сразу.

Сценарий 2. Тихие кракозябры на записи

Хуже, когда скрипт на 3.15 пишет файл, а читает его кто-то, кто по-прежнему ждёт cp1251: другой скрипт на 3.14, загрузка в учётную систему, макрос в Excel.

# writer.py — запускаем на 3.15
with open("report.txt", "w") as out:
    out.write("Сумма за ноябрь: 17300.50 руб.\n")
# reader.py — запускаем на 3.14, как и раньше
print(ascii(open("report.txt").read()))

Ошибки нет ни там, ни там. Но если вывести прочитанное на экран, получится знакомое:

РЎСѓРјРјР° Р·Р° РЅРѕСЏР±СЂСЊ: 17300.50 СЂСѓР±.

Исключений нет, логи чистые, отчёт ушёл. Узнаете вы об этом от бухгалтера.

Есть нюанс: в cp1251 нет символа с кодом 0x98, а это второй байт буквы «И» в UTF-8. Поэтому на части текстов старый читатель всё-таки упадёт, а на части молча покажет мусор. Как повезёт с буквами.

Сценарий 3. Вывод внешней утилиты

subprocess.run(..., text=True) декодирует вывод той же кодировкой по умолчанию. Имитируем старую утилиту, которая печатает в cp1251:

#!/bin/sh
# legacy_tool.sh
printf 'Статус: готово\n' | iconv -f UTF-8 -t CP1251
# call_tool.py
import subprocess, sys
r = subprocess.run(["./legacy_tool.sh"], capture_output=True, text=True)
print(sys.version.split()[0], repr(r.stdout))

На 3.14 получаем 'Статус: готово\n', на 3.15:

UnicodeDecodeError: 'utf-8' codec can't decode byte 0xd1 in position 0: invalid continuation byte

Сценарий 4. Ваш скрипт как «старая утилита»

Обратная сторона того же: если ваш Python-скрипт кто-то вызывает и читает его stdout через пайп, на 3.15 под cp1251-локалью sys.stdout.encoding стал utf-8 вместо cp1251. Вызывающая сторона, которая ждала cp1251, получит те же кракозябры, что и в сценарии 2.

Как найти все такие места

Есть два инструмента, и они дополняют друг друга.

1. EncodingWarning (PEP 597, есть с Python 3.10). Флаг -X warn_default_encoding заставляет интерпретатор предупреждать о каждом открытии текстового потока без encoding:

$ python3.14 -X warn_default_encoding report.py
report.py:4: EncodingWarning: 'encoding' argument not specified
report.py:8: EncodingWarning: 'encoding' argument not specified

Предупреждение срабатывает и на subprocess.run(..., text=True):

call_tool.py:2: EncodingWarning: 'encoding' argument not specified.

В тестах удобно превратить его в ошибку, тогда CI не пропустит новые такие места:

python -X warn_default_encoding -W error::EncodingWarning -m pytest

Ограничение: ловится только тот код, который реально выполнился. Ветка, которая срабатывает раз в месяц при закрытии периода, может проскочить.

2. Статический анализ. В ruff есть правило PLW1514 (unspecified-encoding). В ruff 0.17.4 оно пока в preview, без флага --preview ruff пишет, что выбор правила ни на что не влияет:

ruff check --preview --select PLW1514 .

На моих файлах оно нашло все четыре open() без кодировки, а вот вызов subprocess.run(text=True) не отметило. Поэтому одного линтера мало, флаг -X warn_default_encoding в тестах всё равно нужен.

Отдельно полезно найти сами данные, которые не являются UTF-8. Самый простой способ: прогнать файл через iconv из UTF-8 в UTF-8. На невалидной последовательности он вернёт ненулевой код:

#!/bin/sh
# find_non_utf8.sh
find "${1:-.}" -type f \( -name '*.csv' -o -name '*.txt' -o -name '*.xml' -o -name '*.ini' \) |
while read -r f; do
  iconv -f UTF-8 -t UTF-8 "$f" >/dev/null 2>&1 || echo "не UTF-8: $f"
done
$ ./find_non_utf8.sh .
не UTF-8: ./export_1c.csv

Исправление

Правило простое: кодировку всегда указывать явно, и выбирать её по тому, откуда данные.

  • Чужой файл, кодировку которого вы знаете (выгрузка 1С, выписка банка): encoding="cp1251".

  • Свои файлы, которые вы и пишете, и читаете: encoding="utf-8" с обеих сторон.

  • Действительно нужна кодировка текущей системы (редко, но бывает): encoding="locale". Такой вариант есть с Python 3.10 и в 3.15 продолжает возвращать кодировку локали.

  • Вывод внешних программ: encoding= в subprocess.run.

# report_fixed.py
import csv
import subprocess

# 1. Чужой файл: кодировку знаем, пишем её явно
total = 0.0
with open("export_1c.csv", encoding="cp1251", newline="") as f:
    for row in csv.DictReader(f, delimiter=";"):
        total += float(row["Сумма"].replace(",", "."))

# 2. Свой файл: фиксируем UTF-8 и на запись, и на чтение
with open("report.txt", "w", encoding="utf-8") as out:
    out.write(f"Сумма за ноябрь: {total:.2f} руб.\n")

# 3. Вывод внешней утилиты: тоже явно
r = subprocess.run(["./legacy_tool.sh"], capture_output=True,
                   text=True, encoding="cp1251")

print(ascii(r.stdout), total)

Если ваш скрипт сам отдаёт текст в пайп, а вызывающая сторона ждёт cp1251, задайте кодировку потока явно через PYTHONIOENCODING=cp1251 в окружении вызова или через sys.stdout.reconfigure(encoding="cp1251") в начале скрипта.

Временный откат

Если обновиться нужно сегодня, а исправить всё сразу нельзя, старое поведение возвращается так:

PYTHONUTF8=0 python report.py
# или
python -X utf8=0 report.py

Проверил: с любым из этих вариантов report.py на 3.15.2 снова отрабатывает и печатает ok 17300.5. Но это костыль, а не решение. Переменную легко потерять при переезде в другой сервис, юнит systemd или задачу планировщика.

Проверка

Прогоняем исправленный скрипт на трёх версиях, превращая любое неявное открытие в ошибку, и сравниваем результат побайтно:

for v in 3.13 3.14 3.15; do
  rm -f report.txt
  uv run --no-project -p $v python -X warn_default_encoding -W error::EncodingWarning report_fixed.py
  sha256sum report.txt
done
'\u0421\u0442\u0430\u0442\u0443\u0441: \u0433\u043e\u0442\u043e\u0432\u043e\n' 17300.5
205bf6ce64db8806e62948be08e34718f7dee508b070a3f095d531d43f4869d5  report.txt
'\u0421\u0442\u0430\u0442\u0443\u0441: \u0433\u043e\u0442\u043e\u0432\u043e\n' 17300.5
205bf6ce64db8806e62948be08e34718f7dee508b070a3f095d531d43f4869d5  report.txt
'\u0421\u0442\u0430\u0442\u0443\u0441: \u0433\u043e\u0442\u043e\u0432\u043e\n' 17300.5
205bf6ce64db8806e62948be08e34718f7dee508b070a3f095d531d43f4869d5  report.txt

Ни одного EncodingWarning, сумма одна и та же, вывод утилиты читается, а report.txt побайтно совпадает на 3.13, 3.14 и 3.15. Линтер тоже молчит:

$ ruff check --preview --select PLW1514 report_fixed.py
All checks passed!

Короткий чек-лист перед переходом на 3.15

  1. Прогнать тесты с -X warn_default_encoding -W error::EncodingWarning.

  2. Включить в ruff PLW1514 (пока через --preview).

  3. Найти во входных данных файлы не в UTF-8 и прописать их кодировку явно.

  4. Договориться с соседними системами, в какой кодировке вы отдаёте файлы и stdout, и зафиксировать это в коде, а не в локали сервера.

  5. PYTHONUTF8=0 использовать только как временную меру и с задачей в трекере на его удаление.

А у вас что-нибудь сломалось после перехода на 3.15? Или вы давно пишете encoding= везде и для вас это изменение прошло незаметно? Особенно интересно, сколько cp1251 ещё живёт в ваших интеграциях с 1С и банками.

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.