Поиск по сайту:

Что такое папка __pycache__ в Python?


Когда вы разрабатываете автономный скрипт Python, вы можете не заметить ничего необычного в структуре каталогов. Однако, как только ваш проект становится более сложным, вы часто решаете выделить часть функциональности в дополнительные модули или пакеты. Именно тогда вы можете начать видеть папку __pycache__, появляющуюся из ниоткуда рядом с исходными файлами в, казалось бы, случайных местах:

project/
│
├── mathematics/
│   │
│   ├── __pycache__/
│   │
│   ├── arithmetic/
│   │   ├── __init__.py
│   │   ├── add.py
│   │   └── sub.py
│   │
│   ├── geometry/
│   │   │
│   │   ├── __pycache__/
│   │   │
│   │   ├── __init__.py
│   │   └── shapes.py
│   │
│   └── __init__.py
│
└── calculator.py

Обратите внимание, что папка __pycache__ может присутствовать на разных уровнях дерева каталогов вашего проекта, если у вас есть несколько вложенных друг в друга подпакетов. В то же время другие пакеты или папки с вашими исходными файлами Python могут не содержать этот загадочный каталог кэша.

Вы можете столкнуться с аналогичной ситуацией после клонирования удаленного репозитория Git с проектом Python и запуска базового кода. Итак, что вызывает появление папки __pycache__ и с какой целью?

Вкратце: это ускоряет импорт модулей Python

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

Этот байт-код позволяет интерпретатору пропускать повторяющиеся шаги, такие как лексическое преобразование и анализ кода в абстрактное синтаксическое дерево и проверку его правильности каждый раз, когда вы запускаете одну и ту же программу. Пока базовый исходный код не изменился, Python может повторно использовать промежуточное представление, которое сразу же готово к выполнению. Это экономит время и ускоряет запуск вашего скрипта.

Помните, что загрузка скомпилированного байт-кода из __pycache__ ускоряет импорт модулей Python, но не влияет на скорость их выполнения!

Зачем вообще беспокоиться о байт-коде вместо того, чтобы компилировать код прямо в низкоуровневый машинный код? Хотя машинный код — это то, что выполняется на оборудовании, обеспечивая максимальную производительность, он не такой переносимый и не такой быстрый в создании, как байт-код.

Машинный код — это набор двоичных инструкций, понятных вашей конкретной архитектуре ЦП, завернутых в формат контейнера, такой как EXE, ELF или Mach-O, в зависимости от операционной системы. Напротив, байт-код обеспечивает независимый от платформы уровень абстракции и обычно быстрее компилируется.

Python использует локальные папки __pycache__ для хранения скомпилированного байт-кода импортированных модулей в вашем проекте. При последующих запусках интерпретатор попытается загрузить предварительно скомпилированные версии модулей из этих папок при условии, что они обновлены соответствующими исходными файлами. Обратите внимание, что этот механизм кэширования срабатывает только для модулей, которые вы импортируете в свой код, а не выполняете как сценарии в терминале.

В дополнение к этому кэшированию байт-кода на диске Python хранит кэш модулей в памяти, доступ к которому можно получить через sys.modules словарь. Это гарантирует, что при многократном импорте одного и того же модуля из разных мест вашей программы Python будет использовать уже импортированный модуль без необходимости его перезагрузки или перекомпиляции. Оба механизма работают вместе, чтобы уменьшить накладные расходы на импорт модулей Python.

Далее вы узнаете, насколько быстрее Python загружает кэшированный байт-код по сравнению с компиляцией исходного кода на лету при импорте модуля.

Насколько быстрее загружаются модули из кэша?

Кэширование происходит незаметно и обычно остается незамеченным, поскольку Python довольно быстро компилирует байт-код. Кроме того, если вы не часто запускаете недолговечные скрипты Python, шаг компиляции остается незначительным по сравнению с общим временем выполнения. Тем не менее, без кэширования накладные расходы, связанные с компиляцией байт-кода, могут увеличиться, если у вас много модулей и вы импортируете их много раз.

Чтобы измерить разницу во времени времени импорта между кэшированным и некэшированным модулем, вы можете передать параметр -X importtime команде python или установить параметр эквивалентная переменная среды PYTHONPROFILEIMPORTTIME. Когда эта опция включена, Python отобразит таблицу, в которой будет указано, сколько времени потребовалось для импорта каждого модуля, включая совокупное время в случае, если модуль зависит от других модулей.

Предположим, у вас есть скрипт calculator.py, который импортирует и вызывает служебную функцию из локального модуля arithmetic.py:

from arithmetic import add

add(3, 4)

Импортированный модуль определяет одну функцию:

def add(a, b):
    return a + b

Как видите, основной скрипт делегирует сложение двух чисел, трех и четырех, функции add(), импортированной из модуля arithmetic.

При первом запуске сценария Python компилирует и сохраняет байт-код импортированного вами модуля в локальную папку __pycache__. Если такой папки еще не существует, Python автоматически создает ее, прежде чем двигаться дальше. Теперь, когда вы снова запустите свой скрипт, Python должен найти и загрузить кэшированный байт-код, если вы не изменили связанный исходный код.

Как только кеш прогреется, это будет способствовать более быстрому времени запуска вашего скрипта Python:

$ python -X importtime calculator.py
(...)
import time:     20092 |      20092 | arithmetic

$ python -X importtime calculator.py
(...)
import time:       232 |        232 | arithmetic

$ python -X importtime calculator.py
(...)
import time:       203 |        203 | arithmetic

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

Прирост производительности обычно едва отражается на времени запуска большинства скриптов Python, которое вы можете оценить с помощью команды time в Unix-подобных системах:

$ rm -rf __pycache__
$ time python calculator.py

real    0m0.088s
user    0m0.064s
sys     0m0.028s

$ time python calculator.py

real    0m0.086s
user    0m0.060s
sys     0m0.030s

Здесь общее время выполнения остается практически одинаковым независимо от того, существует кеш или нет. Удаление папки __pycache__ задерживает выполнение примерно на две миллисекунды, что незначительно для большинства приложений.

Компилятор байт-кода Python работает довольно быстро, если сравнивать его с более сложным аналогом в Java, который может использовать преимущества статической типизации.

Например, если у вас есть образец файла Calculator.java, вы можете заранее скомпилировать его в файл .class, что является обычным способом работы с кодом Java. или запустите файл .java напрямую. В последнем случае среда выполнения Java скомпилирует код в фоновом режиме во временное расположение перед его запуском:

$ javac Calculator.java
$ time java Calculator

real    0m0.039s
user    0m0.026s
sys     0m0.019s

$ time java Calculator.java

real    0m0.574s
user    0m1.182s
sys     0m0.069s

Когда вы вручную компилируете исходный код с помощью команды javac и запускаете полученный байт-код, выполнение занимает около сорока миллисекунд. С другой стороны, когда вы позволяете команде java управлять компиляцией, общее время выполнения возрастает до чуть более половины секунды. Поэтому, в отличие от Python, накладные расходы компилятора Java очень заметны даже при параллельной компиляции на нескольких ядрах ЦП!

Теперь, когда вы знаете назначение папки __pycache__, вам может быть интересно узнать ее содержимое.

Что находится внутри папки __pycache__?

Заглянув в папку __pycache__, вы увидите один или несколько файлов, заканчивающихся расширением .pyc. Это означает скомпилированный модуль Python:

$ ls -1 __pycache__
arithmetic.cpython-311.pyc
arithmetic.cpython-312.pyc
arithmetic.pypy310.opt-1.pyc
arithmetic.pypy310.opt-2.pyc
arithmetic.pypy310.pyc
solver.cpython-312.pyc
units.cpython-312.pyc

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

Например, файл с именем arithmetic.py310.opt-2.pyc — это байт-код вашего модуля arithmetic.py, скомпилированный PyPy 3.10 с уровнем оптимизации два. Эта оптимизация удаляет операторы assert и любые строки документации. И наоборот, arithmetic.cpython-312.pyc представляет тот же модуль, но скомпилированный для CPython 3.12 без каких-либо оптимизаций.

Всего существует пять вариантов байт-кода, скомпилированных из одного модуля arithmetic.py в папке __pycache__ выше, которые выделены цветом.

Такая схема именования файлов обеспечивает совместимость различных версий и разновидностей Python. Когда вы запускаете тот же сценарий с использованием PyPy или более ранней версии CPython, интерпретатор скомпилирует все импортированные модули в своей собственной среде выполнения, чтобы иметь возможность повторно использовать их позже. Версия Python вместе с другими метаданными также хранится в самом файле .pyc.

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

Когда Python создает папки кэша?

Интерпретатор будет хранить скомпилированный байт-код модулей Python в папке __pycache__ только тогда, когда вы импортируете эти модули или иногда их родительский пакет. Папка кэша не будет создана при запуске обычного скрипта Python, который не импортирует какие-либо модули или пакеты. Это основано на предположении, что модули с меньшей вероятностью будут изменяться и что вы можете импортировать их несколько раз за одно выполнение.

Когда вы импортируете отдельный модуль из пакета, Python создаст соответствующий файл .pyc и кэширует его в папке __pycache__, расположенной внутри этого пакета. . Он также скомпилирует файл __init__.py пакета, но не затронет другие модули или вложенные подпакеты. Однако если импортированный модуль сам импортирует другие модули, эти модули также будут скомпилированы и так далее.

Вот пример, демонстрирующий самый простой случай, при условии, что вы поместили подходящий оператор import в скрипт calculator.py ниже:

project/
│
├── arithmetic/
│   │
│   ├── __pycache__/
│   │   ├── __init__.cpython-312.pyc
│   │   └── add.cpython-312.pyc
│   │
│   ├── __init__.py
│   ├── add.py
│   └── sub.py
│
└── calculator.py

После первого запуска сценария Python обеспечит наличие папки __pycache__ в пакете arithmetic. Затем он скомпилирует __init__.py пакета вместе со всеми модулями, импортированными из этого пакета. В этом случае вы запросили только импорт модуля arithmetic.add, поэтому вы можете увидеть файл .pyc, связанный с add.py, но не с sub.py.

Все следующие операторы import дадут тот же результат, что и изображенный выше:

import arithmetic.add
from arithmetic import add
from arithmetic.add import add_function

Независимо от того, как вы импортируете модуль Python и импортируете ли вы его целиком или только определенный символ, например класс или константу, интерпретатор компилирует модуль целиком, поскольку он не может читать модули частично.

И наоборот, импорт всего пакета обычно приводит к тому, что Python компилирует только __init__.py в этом пакете. Однако пакеты довольно часто предоставляют свои внутренние модули или подпакеты из __init__.py для более удобного доступа. Например, рассмотрим следующее:

from arithmetic import add
from arithmetic.sub import sub_function

Импорт внутри __init__.py, подобный этому, создает дополнительные файлы .pyc, даже если вы используете простой оператор import arithmetic в своем скрипте.

Если вы импортировали подпакет или глубоко вложенный модуль или символ, то все промежуточные пакеты, ведущие к пакету верхнего уровня, также будут скомпилированы и помещены в файлы __init__.py. в соответствующих папках кэша. Однако Python не пойдет в другом направлении, рекурсивно сканируя вложенные подпакеты, поскольку в этом нет необходимости. Он скомпилирует только те модули, которые вам действительно нужны, импортировав их явно или косвенно.

Что делать, если Python уже скомпилировал ваш модуль в файл .pyc, но вы решили изменить его исходный код в исходном файле .py? Вы узнаете дальше!

Какие действия делают кэш недействительным?

Запуск устаревшего байт-кода может вызвать ошибку или, что еще хуже, привести к совершенно непредсказуемому поведению. К счастью, Python достаточно умен, чтобы обнаружить изменение исходного кода скомпилированного модуля и перекомпилировать его при необходимости.

Чтобы определить, нуждается ли модуль в перекомпиляции, Python использует одну из двух стратегий аннулирования кэша:

  1. На основе временных меток
  2. На основе хеша

Первый сравнивает размер исходного файла и его временную метку последнего изменения с метаданными, хранящимися в соответствующем файле .pyc. Позже вы узнаете, как эти значения сохраняются в файле .pyc вместе с другими метаданными.

Напротив, вторая стратегия вычисляет хэш-значение исходного файла и сверяет его со специальным полем в заголовке файла (PEP 552), которое было представлено в Python 3.7. Эта стратегия более безопасна и детерминирована, но также немного медленнее. Вот почему стратегия, основанная на временных метках, на данный момент остается по умолчанию.

Когда вы искусственно обновляете время модификации (mtime) исходного файла, например, с помощью команды touch в macOS или Linux, вы заставляете Python скомпилировать модуль еще раз:

$ tree -D --dirsfirst
[Apr 26 09:48]  .
├── [Apr 26 09:48]  __pycache__
│     └── [Apr 26 09:48]  arithmetic.cpython-312.pyc
├── [Apr 26 09:48]  arithmetic.py
└── [Apr 26 09:48]  calculator.py

2 directories, 3 files

$ touch arithmetic.py
$ python calculator.py

$ tree -D --dirsfirst
[Apr 26 09:48]  .
├── [Apr 26 09:52]  __pycache__
│     └── [Apr 26 09:52]  arithmetic.cpython-312.pyc
├── [Apr 26 09:52]  arithmetic.py
└── [Apr 26 09:48]  calculator.py

2 directories, 3 files

Первоначально кэшированный файл arithmetic.cpython-312.pyc был последний раз изменен в 09:48. После обращения к исходному файлу arithmetic.py Python считает скомпилированный байт-код устаревшим и перекомпилирует модуль при запуске сценария, импортирующего arithmetic. В результате создается новый файл .pyc с обновленной отметкой времени 09:52.

Для создания файлов .pyc на основе хеша необходимо использовать модуль Python compileall с соответствующей опцией --invalidation-mode. Например, эта команда скомпилирует все модули в текущей папке и подпапках в так называемый вариант checked на основе хеша:

$ python -m compileall --invalidation-mode checked-hash

Официальная документация объясняет разницу между проверенными и непроверенными вариантами файлов .pyc на основе хеша следующим образом:

Для проверенных файлов .pyc на основе хеша Python проверяет файл кэша, хешируя исходный файл и сравнивая полученный хеш с хешем в файле кеша. Если проверенный файл кэша на основе хэша оказывается недействительным, Python восстанавливает его и записывает новый файл кэша на основе проверенного хэша. Для непроверенных файлов .pyc на основе хеша Python просто предполагает, что файл кэша действителен, если он существует. (Источник)

Тем не менее, вы всегда можете переопределить поведение проверки по умолчанию для файлов .pyc на основе хэша с помощью параметра --check-hash-based-pycs при запуске интерпретатора Python.

Знание того, когда и где Python создает папки __pycache__, а также когда обновляет их содержимое, даст вам представление о том, безопасно ли их удалять.

Безопасно ли удалять папку кэша?

Да, хотя вы должны спросить себя, действительно ли вам следует это делать! На этом этапе вы понимаете, что удаление папки __pycache__ безвредно, поскольку Python восстанавливает кеш при каждом вызове. В любом случае удаление отдельных папок кэша вручную — утомительная работа. Кроме того, это продлится только до следующего запуска кода.

Хорошей новостью является то, что вы можете автоматизировать удаление этих папок кэша из вашего проекта, если вы действительно настаиваете, что вы и сделаете сейчас.

Как рекурсивно удалить все папки кэша?

Хорошо, вы уже поняли, что удаление кэшированного байт-кода, который компилирует Python, не составляет большого труда. Однако проблема с этими папками __pycache__ заключается в том, что они могут располагаться в нескольких подкаталогах, если у вас сложная структура проекта. Найти и удалить их вручную было бы утомительно, тем более что они восстают, как феникс из пепла, каждый раз, когда вы запускаете Python.

Ниже вы найдете специфичные для платформы команды, которые рекурсивно удаляют все папки __pycache__ из текущего каталога и всех его вложенных подкаталогов за один раз:

Первый фрагмент кода следует использовать в Windows, а второй фрагмент кода — для Linux + macOS:

PS> $dirs = Get-ChildItem -Path . -Filter __pycache__ -Recurse -Directory
PS> $dirs | Remove-Item -Recurse -Force
$ find . -type d -name __pycache__ -exec rm -rf {} +

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

Удаление папок __pycache__ может привести к очистке рабочего пространства вашего проекта, но только временно. Если вас все еще раздражает необходимость многократного запуска команды рекурсивного удаления, то вы можете в первую очередь взять на себя управление обработкой папки кэша. Далее вы изучите два подхода к решению этой проблемы.

Как запретить Python создавать папки кеша?

Если вы не хотите, чтобы Python кэшировал скомпилированный байт-код, вы можете передать параметр -B команде python при запуске скрипта. Это предотвратит появление папок __pycache__, если они еще не существуют. Тем не менее, Python продолжит использовать любые файлы .pyc, которые он сможет найти в существующих папках кэша. Он просто не будет записывать новые файлы на диск.

Для более постоянного и глобального эффекта, распространяющегося на несколько интерпретаторов Python, вы можете установить переменную среды PYTHONDONTWRITEBYTECODE в вашей оболочке или ее файле конфигурации:

export PYTHONDONTWRITEBYTECODE=1

Это повлияет на любой интерпретатор Python, в том числе в виртуальной среде, которую вы активировали.

Тем не менее, вам следует тщательно подумать о том, является ли подавление компиляции байт-кода правильным подходом для вашего случая использования. Альтернативный вариант — указать Python создать отдельные папки __pycache__ в одном общем месте в вашей файловой системе.

Как хранить кеш в централизованной папке?

Когда вы полностью отключите компиляцию байт-кода, вы получите более чистое рабочее пространство, но потеряете преимущества кэширования, ускоряющего загрузку. Если вы хотите объединить лучшее из обоих миров, вы можете поручить Python записать файлы .pyc в параллельное дерево с корнем в указанном каталоге, используя -X pycache_prefix вариант:

$ python -X pycache_prefix=/tmp/pycache calculator.py

В этом случае вы указываете Python кэшировать скомпилированный байт-код во временной папке, расположенной по адресу /tmp/pycache в вашей файловой системе. Когда вы запустите эту команду, Python больше не будет пытаться создать локальные папки __pycache__ в вашем проекте. Вместо этого он отразит структуру каталогов вашего проекта в указанной корневой папке и сохранит там все файлы .pyc:

tmp/
└── pycache/
    └── home/
        └── user/
            │
            ├── other_project/
            │   └── solver.cpython-312.pyc
            │
            └── project/
                │
                └── mathematics/
                    │
                    ├── arithmetic/
                    │   ├── __init__.cpython-312.pyc
                    │   ├── add.cpython-312.pyc
                    │   └── sub.cpython-312.pyc
                    │
                    ├── geometry/
                    │   ├── __init__.cpython-312.pyc
                    │   └── shapes.cpython-312.pyc
                    │
                    └── __init__.cpython-312.pyc

Обратите внимание на две вещи. Во-первых, поскольку каталог кэша хранится отдельно от исходного кода, нет необходимости вкладывать скомпилированные файлы .pyc в папки __pycache__. Во-вторых, поскольку иерархия внутри такого централизованного кэша соответствует структуре вашего проекта, вы можете использовать эту папку кэша для нескольких проектов.

Другие преимущества этой настройки включают более простую очистку, поскольку вы можете удалить все файлы .pyc, принадлежащие одному проекту, одним нажатием клавиши, без необходимости вручную просматривать все каталоги. Кроме того, вы можете хранить папку кеша на отдельном физическом диске, чтобы воспользоваться преимуществами параллельного чтения, или хранить кеш на постоянном томе при работе с контейнерами Docker.

Помните, что вы должны использовать параметр -X pycache_prefix каждый раз при запуске команды python, чтобы это работало последовательно. В качестве альтернативы вы можете установить путь к папке общего кэша через переменную среды PYTHONPYCACHEPREFIX:

export PYTHONPYCACHEPREFIX=/tmp/pycache

В любом случае вы можете программно проверить, будет ли Python использовать указанный каталог кэша или вернуться к поведению по умолчанию и создать локальные папки __pycache__:

>>> import sys
>>> sys.pycache_prefix
'/tmp/pycache'

Переменная sys.pycache_prefix может быть либо строкой, либо None.

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

Что находится внутри кэшированного файла .pyc?

Файл .pyc состоит из заголовка с метаданными, за которым следует сериализованный объект кода, который будет выполняться во время выполнения. Заголовок файла начинается с магического числа, которое однозначно идентифицирует конкретную версию Python, для которой был скомпилирован байт-код. Далее, в PEP 552 определено битовое поле, которое определяет одну из трех стратегий аннулирования кэша, описанных ранее.

В файлах на основе временных меток .pyc битовое поле заполняется нулями, за которыми следуют два четырехбайтовых поля. Эти поля соответствуют времени последней модификации Unix и размеру исходного файла .py соответственно:

Offset Field Size Field Description
0 4 Magic number Identifies the Python version
4 4 Bit field Filled with zeros
8 4 Timestamp The time of .py file’s modification
12 4 File size Concerns the source .py file

И наоборот, для файлов на основе хэша .pyc битовое поле может быть равно одному, что указывает на непроверенный вариант, или трем, что означает вариант проверенный. Тогда вместо метки времени и размера файла будет только одно восьмибайтовое поле с хеш-значением исходного кода Python:

Offset Field Size Field Description
0 4 Magic number Identifies the Python version
4 4 Bit field Equals 1 (unchecked) or 3 (checked)
8 8 Hash value Source code’s hash value

В обоих случаях длина заголовка составляет шестнадцать байт, и его можно пропустить, если вы не заинтересованы в чтении закодированных метаданных. Сделав это, вы перейдете прямо к объекту кода, сериализованному с помощью модуля marshal, который занимает оставшуюся часть файла .pyc.

Используя эту информацию, вы можете выполнить рентгеновский анализ одного из скомпилированных файлов .pyc и напрямую выполнить базовый байт-код, даже если у вас больше нет исходного файла .py с связанный исходный код.

Как прочитать и выполнить кэшированный байт-код?

Сам по себе Python будет запускать сопутствующий файл .pyc только в том случае, если исходный файл .py все еще существует. Если вы удалите исходный модуль после того, как он уже скомпилирован, Python откажется запускать файл .pyc. Это задумано. Однако при желании вы всегда можете запустить байт-код вручную.

Следующий скрипт Python показывает, как можно прочитать заголовок файла .pyc, а также как десериализовать и выполнить прилагаемый к нему байт-код:

import marshal
from datetime import datetime, timezone
from importlib.util import MAGIC_NUMBER
from pathlib import Path
from pprint import pp
from py_compile import PycInvalidationMode
from sys import argv
from types import SimpleNamespace

def main(path):
    metadata, code = load_pyc(path)
    pp(vars(metadata))
    if metadata.magic_number == MAGIC_NUMBER:
        exec(code, globals())
    else:
        print("Bytecode incompatible with this interpreter")

def load_pyc(path):
    with Path(path).open(mode="rb") as file:
        return (
            parse_header(file.read(16)),
            marshal.loads(file.read()),
        )

def parse_header(header):
    metadata = SimpleNamespace()
    metadata.magic_number = header[0:4]
    metadata.magic_int = int.from_bytes(header[0:4][:2], "little")
    metadata.python_version = f"3.{(metadata.magic_int - 2900) // 50}"
    metadata.bit_field = int.from_bytes(header[4:8], "little")
    metadata.pyc_type = {
        0: PycInvalidationMode.TIMESTAMP,
        1: PycInvalidationMode.UNCHECKED_HASH,
        3: PycInvalidationMode.CHECKED_HASH,
    }.get(metadata.bit_field)
    if metadata.pyc_type is PycInvalidationMode.TIMESTAMP:
        metadata.timestamp = datetime.fromtimestamp(
            int.from_bytes(header[8:12], "little"),
            timezone.utc,
        )
        metadata.file_size = int.from_bytes(header[12:16], "little")
    else:
        metadata.hash_value = header[8:16]
    return metadata

if __name__ == "__main__":
    main(argv[1])

Здесь происходит несколько вещей, поэтому вы можете разбить их построчно:

  • Строка 11 получает кортеж, содержащий метаданные, проанализированные из заголовка файла, и десериализованный объект кода, готовый к выполнению. Оба загружаются из файла .pyc, указанного в качестве единственного обязательного аргумента командной строки.
  • Строка 12 красиво выводит декодированные метаданные на экран.
  • Строки с 13 по 16 условно выполняют байт-код из файла .pyc с помощью exec() или выводят сообщение об ошибке. Чтобы определить, был ли файл скомпилирован для текущей версии интерпретатора, этот фрагмент кода сравнивает магическое число, полученное из заголовка, с магическим числом интерпретатора. Если все прошло успешно, символы загруженного модуля импортируются в globals().
  • Строки с 18 по 23 открывают файл .pyc в двоичном режиме с помощью модуля pathlib, анализируют заголовок и демаршалируют объект кода.
  • Строки с 25 по 44 анализируют поля заголовка, используя соответствующие им смещения и размеры байтов, интерпретируя многобайтовые значения с прямым порядком байтов.
  • Строки 28 и 29 извлекают версию Python из магического числа, которое увеличивается с каждой дополнительной версией Python по формуле 2900 + 50n, где n — это дополнительная версия Python 3.11 или новее.
  • Строки с 31 по 35 определяют тип файла .pyc на основе предыдущего битового поля (PEP 552).
  • Строки с 37 по 40 преобразуют время модификации исходного файла в объект datetime в часовом поясе UTC.

Вы можете запустить сценарий X-ray, описанный выше, для файла .pyc по вашему выбору. Когда вы включите интерактивный режим Python (-i), вы сможете проверять переменные и состояние программы после ее завершения:

$ python -i xray.py __pycache__/arithmetic.cpython-312.pyc
{'magic_number': b'\xcb\r\r\n',
 'magic_int': 3531,
 'python_version': '3.12',
 'bit_field': 0,
 'pyc_type': <PycInvalidationMode.TIMESTAMP: 1>,
 'timestamp': datetime.datetime(2024, 4, 26, 17, 34, 57, tzinfo=….utc),
 'file_size': 32}
>>> add(3, 4)
7

Скрипт распечатывает декодированные поля заголовка, включая магическое число и время модификации исходного файла. Сразу после этого, благодаря параметру python -i, вы попадаете в интерактивный REPL Python, где вызываете add(), который был импортирован в глобальное пространство имен с помощью выполнение байт-кода модуля.

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

$ python -i xray.py __pycache__/arithmetic.cpython-311.pyc
{'magic_number': b'\xa7\r\r\n',
 'magic_int': 3495,
 'python_version': '3.11',
 'bit_field': 0,
 'pyc_type': <PycInvalidationMode.TIMESTAMP: 1>,
 'timestamp': datetime.datetime(2024, 4, 25, 14, 40, 26, tzinfo=….utc),
 'file_size': 32}
Bytecode incompatible with this interpreter
>>> add(3, 4)
Traceback (most recent call last):
  ...
NameError: name 'add' is not defined

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

Теперь, если вы просканируете файл .pyc на основе хэша (проверенный или непроверенный), то вы можете получить следующее:

$ python xray.py __pycache__/arithmetic.cpython-312.pyc
{'magic_number': b'\xcb\r\r\n',
 'magic_int': 3531,
 'python_version': '3.12',
 'bit_field': 3,
 'pyc_type': <PycInvalidationMode.CHECKED_HASH: 2>,
 'hash_value': b'\xf3\xdd\x87j\x8d>\x0e)'}

Python может сравнивать хеш-значение, встроенное в файл .pyc, со значением, которое он вычисляет из связанного файла .py, вызывая source_hash() в исходный код:

>>> from importlib.util import source_hash
>>> from pathlib import Path
>>> source_hash(Path("arithmetic.py").read_bytes())
b'\xf3\xdd\x87j\x8d>\x0e)'

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

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

Может ли байт-код запутывать программы Python?

Ранее вы узнали, что Python не будет импортировать модуль из файла .pyc, если связанный файл .py не найден. Однако есть одно заметное исключение: именно это происходит, когда вы импортируете код Python из ZIP-файла, указанного в PYTHONPATH. Такие архивы обычно содержат только скомпилированные файлы .pyc без сопутствующего исходного кода.

Возможность импортировать скомпилированные модули вручную или через эти ZIP-файлы позволяет реализовать элементарный механизм обфускации кода. К сожалению, это не будет особенно надежным, поскольку более технически подкованные пользователи могут попытаться декомпилировать ваши файлы .pyc обратно в высокоуровневый код Python, используя специальные инструменты, такие как uncompyle6 или pycdc.

Но даже без этих внешних инструментов вы можете разобрать байт-код Python на удобочитаемые коды операций, что делает анализ и реверс-инжиниринг ваших программ достаточно доступными. Правильный способ скрыть исходный код Python — скомпилировать его в машинный код. Например, вы можете помочь себе с помощью таких инструментов, как Cython, или переписать основные части вашего кода на языке программирования более низкого уровня, таком как C, C++ или Rust.

Как дизассемблировать кэшированный байт-код?

Как только вы получите дескриптор объекта кода в Python, вы можете использовать модуль dis из стандартной библиотеки для дизассемблирования скомпилированного байт-кода. В качестве примера вы быстро сгенерируете объект кода самостоятельно, не полагаясь на файлы .pyc, кэшированные Python:

>>> from pathlib import Path
>>> source_code = Path("arithmetic.py").read_text(encoding="utf-8")
>>> module = compile(source_code, "arithmetic.py", mode="exec")
>>> module
<code object <module> at 0x7d09a9c92f50, file "arithmetic.py", line 1>

Вы вызываете встроенную функцию compile() с режимом "exec" в качестве параметра для компиляции модуля Python. Теперь вы можете отобразить удобочитаемые имена кодов операций результирующего объекта кода, используя dis:

>>> from dis import dis
>>> dis(module)
  0           0 RESUME                   0

  1           2 LOAD_CONST               0 (<code object add at 0x7d...>)
              4 MAKE_FUNCTION            0
              6 STORE_NAME               0 (add)
              8 RETURN_CONST             1 (None)

Disassembly of <code object add at 0x7d...>:
  1           0 RESUME                   0

  2           2 LOAD_FAST                0 (a)
              4 LOAD_FAST                1 (b)
              6 BINARY_OP                0 (+)
             10 RETURN_VALUE

Коды операций MAKE_FUNCTION и STORE_NAME сообщают вам, что в этом байт-коде есть функция с именем add(). Если вы внимательно посмотрите на дизассемблированный объект кода этой функции, вы увидите, что она принимает два аргумента, называемые a и b, и добавляет их с помощью оператора двоичного плюса (+) и возвращает вычисленное значение.

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

>>> from dis import Bytecode
>>> from types import CodeType

>>> def traverse(code):
...     print(code.co_name, code.co_varnames)
...     for instruction in Bytecode(code):
...         if isinstance(instruction.argval, CodeType):
...             traverse(instruction.argval)
...
>>> traverse(module)
<module> ()
add ('a', 'b')

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

Заключение

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

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