Перейти к содержанию

412 уроков, 25 библиотек и челлендж «Что выведет код?» — бесплатно, код прямо в браузере

Начать обучение
Урок 2 из 20 Средний 15 мин 100 XP

pyc-файл: устройство скомпилированного файла Python

pyc — это кодовый объект, упакованный в байты: 16 байт заголовка с magic-числом, временем и размером исходника, дальше marshal-блок с байткодом. Разбираем файл до последнего байта.

Редакция Питоники

После первого запуска любого импортируемого модуля рядом с ним появляется каталог __pycache__, а в нём — файл с расширением .pyc. Текстовым редактором его не открыть: внутри не текст, а скомпилированный байткод из первого урока, упакованный в байты вместе с паспортом программы. pyc — это кэш компиляции: интерпретатор кладёт его рядом с исходником, чтобы в следующий раз не компилировать тот же код заново. В этом уроке мы создадим pyc сами и прочтём его до последнего байта — формат проще, чем кажется.

Создаём первый pyc

Для опытов возьмём py_compile — модуль стандартной библиотеки, который компилирует файл и пишет результат на диск. Подробный разбор его настроек — в третьем уроке, здесь он просто поставщик подопытного файла:

компилируем модуль в pyc
import os
import py_compile

with open('app_demo.py', 'w', encoding='utf-8') as f:
    f.write('RATIO = 2\n\n\ndef scaled(n):\n    return n * RATIO\n')

py_compile.compile('app_demo.py')
print('исходник на месте:', os.path.exists('app_demo.py'))
print('каталог pyc появился:', os.path.isdir('__pycache__'))

import glob
for p in glob.glob('__pycache__/*.pyc'):
    os.remove(p)
if os.path.isdir('__pycache__'):
    os.rmdir('__pycache__')
os.remove('app_demo.py')
Вывод
исходник на месте: True
каталог pyc появился: True

Компиляция положила результат в каталог __pycache__ рядом с исходником. Имя файла внутри повторяет имя модуля и добавляет метку версии интерпретатора — у CPython 3.12 она выглядит как cpython-312, поэтому мы не печатаем имя целиком: на вашей машине хвост будет другим. Структура при этом одна и та же на всех версиях начиная с 3.7: 16 байт заголовка и marshal-блок.

Заголовок: первые 16 байт

Заголовок pyc — четыре числа по четыре байта каждое, записанные в младшем порядке (little-endian). Первое — magic-число версии, а три остальных — паспорт исходника:

СмещениеРазмерПолеСодержимое
0–34 байтаMagicЧисло версии Python, записавшей файл
4–74 байтаФлаги0 для обычных pyc, отдельные биты для hash-режима
8–114 байтаВремяmtime исходника на момент компиляции
12–154 байтаРазмерРазмер исходника в байтах

Читается заголовок обычной работой с байтами: читаем файл в режиме rb, режем срезами по четыре байта и собираем числа через int.from_bytes(..., 'little'). Первый опыт — сверить magic-число файла с тем, что выдает наш собственный интерпретатор через importlib.util.MAGIC_NUMBER:

magic и флаги из заголовка
import importlib.util
import os
import py_compile

with open('header_demo.py', 'w', encoding='utf-8') as f:
    f.write('N = 1\n')

pyc = py_compile.compile('header_demo.py')
with open(pyc, 'rb') as f:
    data = f.read()

print('файл прочитан, данных больше нуля:', len(data) > 0)
print('первые 4 байта совпали с MAGIC_NUMBER:', data[:4] == importlib.util.MAGIC_NUMBER)
print('магическое число занимает 4 байта:', len(importlib.util.MAGIC_NUMBER) == 4)
print('флаги равны нулю:', int.from_bytes(data[4:8], 'little') == 0)
print('время исходника записано:', int.from_bytes(data[8:12], 'little') > 0)

import glob
for p in glob.glob('__pycache__/*.pyc'):
    os.remove(p)
if os.path.isdir('__pycache__'):
    os.rmdir('__pycache__')
os.remove('header_demo.py')
Вывод
файл прочитан, данных больше нуля: True
первые 4 байта совпали с MAGIC_NUMBER: True
магическое число занимает 4 байта: True
флаги равны нулю: True
время исходника записано: True

Пять проверок, пять True. Magic-число самого файла совпало с magic-числом работающего интерпретатора — это и есть та самая проверка версии, из-за которой pyc с одной версии не запускается на другой (пятый урок). Флаги равны нулю — файл собран в обычном временном режиме, а не в hash-режиме для деплоя. Поле времени — не ноль: интерпретатор записал mtime исходника на момент компиляции.

размер исходника в заголовке
import glob
import os
import py_compile

with open('size_demo.py', 'w', encoding='utf-8') as f:
    f.write('N = 1\nN += 2\n')

pyc = py_compile.compile('size_demo.py')
with open(pyc, 'rb') as f:
    data = f.read()
stored = int.from_bytes(data[12:16], 'little')
print('размер из заголовка совпал с исходником:', stored == os.path.getsize('size_demo.py'))

for p in glob.glob('__pycache__/*.pyc'):
    os.remove(p)
if os.path.isdir('__pycache__'):
    os.rmdir('__pycache__')
os.remove('size_demo.py')
Вывод
размер из заголовка совпал с исходником: True

В заголовке лежит точный размер исходника в байтах: N = 1 и N += 2 с переводами строк — тринадцать байт, и os.path.getsize дал то же число. Пара «время + размер» — это те самые два поля, по которым интерпретатор решает, устарел ли кэш: тема шестого урока про __pycache__.

Как выглядит заголовок в байтах

Вот настоящий дамп первых 16 байт pyc для исходника N = 1 (7 байт текста), разбитый на поля:

дамп заголовка pyc
f3 0d 0d 0a   magic
00 00 00 00   флаги = 0
d5 65 f0 6a   время изменения исходника
07 00 00 00   размер исходника = 7 байт
Дамп получен на CPython 3.13: байты magic и времени зависят от версии Python и момента компиляции и будут другими на вашей машине. Размер 07 00 00 00 одинаков всегда — исходник один и тот же.

Первые два байта magic меняются от версии к версии, последние два всегда 0d 0a — перевод строки в стиле Windows: это история формата, оставшаяся с ранних версий. Время d5 65 f0 6a — просто unix-timestamp компиляции. Проверить этот дамп на своей машине можно тем же кодом из предыдущего блока — только байты будут свои.

Тело файла: marshal-блок

С 16-го байта начинается тело — marshal-сериализация кодового объекта из первого урока. Marshal — встроенный формат CPython для своих объектов; его тонкости — седьмой урок. Здесь достаточно того, что marshal.loads превращает тело обратно в кодовый объект, который можно исполнить:

достаём кодовый объект из pyc
import glob
import marshal
import os
import py_compile
import types

with open('body_demo.py', 'w', encoding='utf-8') as f:
    f.write('def area(n):\n    return n * n\n')

pyc = py_compile.compile('body_demo.py')
with open(pyc, 'rb') as f:
    data = f.read()

code = marshal.loads(data[16:])
print('тело pyc — кодовый объект:', isinstance(code, types.CodeType))
print('имя кодового объекта модуля:', code.co_name)

ns = {}
exec(code, ns)
print('функция из pyc работает:', ns['area'](5))

for p in glob.glob('__pycache__/*.pyc'):
    os.remove(p)
if os.path.isdir('__pycache__'):
    os.rmdir('__pycache__')
os.remove('body_demo.py')
Вывод
тело pyc — кодовый объект: True
имя кодового объекта модуля: <module>
функция из pyc работает: 25

Три шага — и pyc полностью расшифрован: срез [16:] отделил заголовок, marshal.loads собрал кодовый объект, exec исполнил его как обычный модуль. Функция area из файла, который мы никогда не импортировали, честно вернула 25. Именно эти три шага выполняет интерпретатор, когда грузит модуль из кэша, — только заголовок он ещё и проверяет.

Чего внутри pyc нет

Внутри только байткод и паспорт исходника. Нет исходного текста — pyc не обфускация и не архив: вернуть исходник из байткода можно лишь декомпилятором конкретной версии, с потерями. Нет и ускорения исполнения: байткод тот же, что компилировался бы на лету; pyc экономит только время компиляции при загрузке модуля.

pyc пересобирается после удаления
import os
import py_compile

with open('rebuild_demo.py', 'w', encoding='utf-8') as f:
    f.write('K = 3\n')

pyc = py_compile.compile('rebuild_demo.py')
os.remove(pyc)
print('pyc после удаления существует:', os.path.exists(pyc))
py_compile.compile('rebuild_demo.py')
print('pyc после повторной компиляции:', os.path.exists(pyc))

os.remove(pyc)
os.rmdir('__pycache__')
os.remove('rebuild_demo.py')
Вывод
pyc после удаления существует: False
pyc после повторной компиляции: True

Удалённый pyc — не потеря: исходник на месте, компилятор соберёт файл заново при первом же обращении. Поэтому каталог __pycache__ смело исключают из git и чистят при сборке дистрибутива — в шестом уроке это разобрано вместе с правилами инвалидации.

заглянуть в чужой pyc из консоли
python -m dis __pycache__/app_demo.cpython-312.pyc
Командная строка недоступна в песочнице. python -m dis печатает инструкции байткода прямо из файла pyc — имейте в виду, что имя файла внутри __pycache__ содержит метку версии вашей машины, а вид инструкций зависит от версии Python.
проверка флагов заголовка одной строкой
python -c "data = open('__pycache__/app_demo.cpython-312.pyc', 'rb').read(16); print(data[4:8] == b'\x00\x00\x00\x00')"
Вывод
True
Тоже командная строка: чтение первых 16 байт и проверка поля флагов на ноль. Метка версии в имени файла (cpython-312) зависит от машины — подставьте свою.
  • pyc — кэш компиляции: кодовый объект, записанный на диск рядом с исходником;
  • заголовок — 16 байт: magic версии, флаги, mtime и размер исходника, все числа little-endian;
  • тело — marshal-блок: marshal.loads(data[16:]) возвращает кодовый объект, exec исполняет его;
  • исходного текста внутри нет, ускоряется только загрузка модуля, а не его исполнение;
  • pyc привязан к версии: magic-число и формат байткода меняются в каждой минорной версии.

Компилировать файлы руками не обязательно — интерпретатор делает это сам при импорте. Но для сборки дистрибутива, проверки синтаксиса без запуска и подготовки кэша у стандартной библиотеки есть два инструмента: py_compile и compileall в третьем уроке.

Что выведет код?

Сначала предскажи ответ в голове — это главный навык программиста.

import importlib.util
print(len(importlib.util.MAGIC_NUMBER))
data = bytes([0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 7, 0, 0, 0])
print(int.from_bytes(data[8:12], 'little') > 0)
print(int.from_bytes(data[12:16], 'little'))
import marshal, types
data = b'\x00' * 16
print(isinstance(marshal.loads(data), types.CodeType))
Проверь себя
0 / 7

1. Из каких частей состоит pyc-файл?

2. Что хранится в первых 4 байтах pyc?

3. Как из pyc получить кодовый объект?

4. Что экономит pyc при импорте модуля?

5. Зачем в заголовке pyc время и размер исходника?

6. Исходный текст программы внутри pyc?

7. Что произойдёт после удаления pyc при работающем исходнике?

Карточки терминов
Запомнено: 0 / 7
Практика

Скомпилируйте файл demo.py со строкой N = 5, прочитайте заголовок pyc и проверьте, что размер исходника из заголовка совпал с реальным размером файла. Напечатайте результат проверки.

practice.py
Вопросы и ответы по уроку

Что такое pyc файл простыми словами?

Это кэш компиляции: Python переводит исходник в байткод и сохраняет результат рядом с модулем, чтобы при следующем импорте пропустить компиляцию. Внутри 16 байт заголовка (версия, флаги, время, размер исходника) и marshal-блок с кодовым объектом.

Урок 3: py_compile и compileall

Как открыть или прочитать pyc файл?

Текстовым редактором — никак: это бинарный файл. Содержимое смотрят дизассемблером (python -m dis файл.pyc) или читают программно: marshal.loads(data[16:]) вернёт кодовый объект, у которого доступны co_name, co_consts и байткод co_code.

Урок 4: дизассемблер dis

Почему pyc не запускается на другой версии Python?

В заголовке записано magic-число версии, и оно меняется с каждым минорным релизом. Интерпретатор сверяет его со своим и отказывается грузить файл с чужим числом. Внутри, к тому же, байткод другой версии — простая правка заголовка не поможет.

Урок 5: версии Python и magic-число

Можно ли запускать программу только из pyc без исходника?

Да, если pyc собран ровно той версией интерпретатора: загрузчик SourcelessFileLoader грузит модули без исходника. Но это не защита кода и не ускорение: байткод декомпилируется инструментами той же версии, а исполняется не быстрее исходника.

Чем pyc отличается от exe?

pyc — кэш байткода для того же интерпретатора Python, exe — самодостаточная программа, в которую PyInstaller или Nuitka упаковывают интерпретатор или компилируют код в машинные команды. pyc без Python не запускается.

Урок 10: сборка exe через PyInstaller

Понравился урок? Сошлитесь на него

«pyc — это кэш компиляции: интерпретатор кладёт его рядом с исходником, чтобы в следующий раз не компилировать тот же код заново.»

Скопируйте готовую ссылку в формате HTML, Markdown или чистый адрес и вставьте в статью на Habr, VC, Telegram-канал или свой блог — так о проекте узнают новые читатели.

TelegramVK

Что дальше

Осталось в разделе: 20 из 20. Прогресс хранится в вашем браузере.

Похожие уроки по темам

Подобраны автоматически по пересечению тем и ключевых слов.