Python на вынос: PyInstaller и Nuitka

bd54dd3927868db5945cd595b379a805.jpg

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

Один из вариантов был использовать Docker, но я от него отказался по причине того, что окружение для Docker тоже надо будет готовить. Потом надо будет правильно запуск этот образ и правильно с ним взаимодействовать. Конечно, для упрощения можно использовать docker compose, но это не сильно снижает сложность для конечного пользователя. Кроме того, образ будет достаточно большим.

Поэтому я после некоторых размышлений обратился к таким инструментам как Python Compilers, а именно — Nuitka и PyInstaller и провёл небольшое исследование на предмет их пригодности для моих нужд.

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

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

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

  2. Python-приложение и все его зависимости упакованы в один бинарь

PyInstaller

Эксперимент проводился на версии 6.10.0

% pyinstaller -version
6.10.0

Приложение в каталоге

% time pyinstaller generator/main.py
pyinstaller generator/main.py  18.63s user 2.72s system 95% cpu 22.376 total

На выходе получили два каталога — build и dist

% du -sh build dist
 56M    build
 75M    dist
% ls -l build/main dist/main/*
-rwxr-xr-x  1 max  staff  17177744 Aug 14 12:00 dist/main/main

build/main:
total 113656
-rw-r--r--  1 max  staff    999486 Aug 14 12:00 Analysis-00.toc
-rw-r--r--  1 max  staff    562354 Aug 14 12:00 COLLECT-00.toc
-rw-r--r--  1 max  staff      2974 Aug 14 12:00 EXE-00.toc
-rw-r--r--  1 max  staff      2780 Aug 14 12:00 PKG-00.toc
-rw-r--r--  1 max  staff  16906333 Aug 14 12:00 PYZ-00.pyz
-rw-r--r--  1 max  staff    435304 Aug 14 12:00 PYZ-00.toc
-rw-r--r--  1 max  staff   1443565 Aug 14 11:59 base_library.zip
drwxr-xr-x  6 max  staff       192 Aug 14 12:00 localpycs
-rwxr-xr-x  1 max  staff  17177744 Aug 14 12:00 main
-rw-r--r--  1 max  staff  16937430 Aug 14 12:00 main.pkg
-rw-r--r--  1 max  staff     17259 Aug 14 12:00 warn-main.txt
-rw-r--r--  1 max  staff   3680264 Aug 14 12:00 xref-main.html

dist/main/_internal:
total 15976
drwxr-xr-x   7 max  staff      224 Aug 14 12:00 IPython
drwxr-xr-x   9 max  staff      288 Aug 14 12:00 PIL
lrwxr-xr-x   1 max  staff       37 Aug 14 12:00 Python -> Python.framework/Versions/3.11/Python
drwxr-xr-x   5 max  staff      160 Aug 14 12:00 Python.framework
-rwxr-xr-x   1 max  staff   234176 Aug 14 12:00 _cffi_backend.cpython-311-darwin.so
-rw-r--r--   1 max  staff  1443565 Aug 14 12:00 base_library.zip
drwxr-xr-x   3 max  staff       96 Aug 14 12:00 cryptography
drwxr-xr-x  10 max  staff      320 Aug 14 12:00 cryptography-41.0.1.dist-info
drwxr-xr-x  10 max  staff      320 Aug 14 12:00 email_validator-2.2.0.dist-info
drwxr-xr-x   9 max  staff      288 Aug 14 12:00 factory_boy-3.3.0.dist-info
drwxr-xr-x   3 max  staff       96 Aug 14 12:00 faker
drwxr-xr-x   3 max  staff       96 Aug 14 12:00 jedi
drwxr-xr-x  60 max  staff     1920 Aug 14 12:00 lib-dynload
drwxr-xr-x   7 max  staff      224 Aug 14 12:00 lib2to3
lrwxr-xr-x   1 max  staff       30 Aug 14 12:00 libXau.6.0.0.dylib -> PIL/.dylibs/libXau.6.0.0.dylib
lrwxr-xr-x   1 max  staff       39 Aug 14 12:00 libbrotlicommon.1.1.0.dylib -> PIL/.dylibs/libbrotlicommon.1.1.0.dylib
lrwxr-xr-x   1 max  staff       36 Aug 14 12:00 libbrotlidec.1.1.0.dylib -> PIL/.dylibs/libbrotlidec.1.1.0.dylib
-rwxr-xr-x   1 max  staff  4222928 Aug 14 12:00 libcrypto.3.dylib
lrwxr-xr-x   1 max  staff       31 Aug 14 12:00 libfreetype.6.dylib -> PIL/.dylibs/libfreetype.6.dylib
lrwxr-xr-x   1 max  staff       31 Aug 14 12:00 libharfbuzz.0.dylib -> PIL/.dylibs/libharfbuzz.0.dylib
lrwxr-xr-x   1 max  staff       32 Aug 14 12:00 libjpeg.62.4.0.dylib -> PIL/.dylibs/libjpeg.62.4.0.dylib
lrwxr-xr-x   1 max  staff       28 Aug 14 12:00 liblcms2.2.dylib -> PIL/.dylibs/liblcms2.2.dylib
lrwxr-xr-x   1 max  staff       27 Aug 14 12:00 liblzma.5.dylib -> PIL/.dylibs/liblzma.5.dylib
-rwxr-xr-x   1 max  staff   189360 Aug 14 12:00 libmpdec.4.dylib
lrwxr-xr-x   1 max  staff       34 Aug 14 12:00 libopenjp2.2.5.2.dylib -> PIL/.dylibs/libopenjp2.2.5.2.dylib
lrwxr-xr-x   1 max  staff       29 Aug 14 12:00 libpng16.16.dylib -> PIL/.dylibs/libpng16.16.dylib
lrwxr-xr-x   1 max  staff       31 Aug 14 12:00 libsharpyuv.0.dylib -> PIL/.dylibs/libsharpyuv.0.dylib
-rwxr-xr-x   1 max  staff  1240816 Aug 14 12:00 libsqlite3.0.dylib
-rwxr-xr-x   1 max  staff   838736 Aug 14 12:00 libssl.3.dylib
lrwxr-xr-x   1 max  staff       27 Aug 14 12:00 libtiff.6.dylib -> PIL/.dylibs/libtiff.6.dylib
lrwxr-xr-x   1 max  staff       27 Aug 14 12:00 libwebp.7.dylib -> PIL/.dylibs/libwebp.7.dylib
lrwxr-xr-x   1 max  staff       32 Aug 14 12:00 libwebpdemux.2.dylib -> PIL/.dylibs/libwebpdemux.2.dylib
lrwxr-xr-x   1 max  staff       30 Aug 14 12:00 libwebpmux.3.dylib -> PIL/.dylibs/libwebpmux.3.dylib
lrwxr-xr-x   1 max  staff       30 Aug 14 12:00 libxcb.1.1.0.dylib -> PIL/.dylibs/libxcb.1.1.0.dylib
lrwxr-xr-x   1 max  staff       28 Aug 14 12:00 libz.1.3.1.dylib -> PIL/.dylibs/libz.1.3.1.dylib
drwxr-xr-x   3 max  staff       96 Aug 14 12:00 markupsafe
drwxr-xr-x   3 max  staff       96 Aug 14 12:00 ossl-modules
drwxr-xr-x   4 max  staff      128 Aug 14 12:00 parso
drwxr-xr-x   3 max  staff       96 Aug 14 12:00 pydantic_core
drwxr-xr-x   3 max  staff       96 Aug 14 12:00 setuptools
drwxr-xr-x   3 max  staff       96 Aug 14 12:00 text_unidecode
drwxr-xr-x   9 max  staff      288 Aug 14 12:00 typeguard-4.3.0.dist-info
drwxr-xr-x   9 max  staff      288 Aug 14 12:00 wheel-0.43.0.dist-info

Как видно в каталог собраны все зависимости приложения и кроме того сам интерпретатор Python. Все они, включая Python, представлены разделяемыми библиотеками.

% file dist.folder/main/_internal/Python.framework/Versions/3.11/Python
dist.folder/main/_internal/Python.framework/Versions/3.11/Python: Mach-O 64-bit dynamically linked shared library arm64

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

Подробности:  https://pyinstaller.org/en/stable/advanced-topics.html#the-bootstrap-process-in-detail

Приложение в одном файле

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

По этой причине приложение в едином файле занимает больше времени для запуска, чем приложение в виде каталога

% time pyinstaller --onefile generator/main.py
pyinstaller --onefile generator/main.py  22.72s user 2.49s system 96% cpu 26.063 total
% du -sh build dist
 56M    build
 33M    dist
% ls -l build/main dist/main
-rwxr-xr-x  1 max  staff  34381088 Aug 15 08:45 dist/main

build/main:
total 114872
-rw-r--r--  1 max  staff   1000578 Aug 15 08:45 Analysis-00.toc
-rw-r--r--  1 max  staff    564818 Aug 15 08:45 EXE-00.toc
-rw-r--r--  1 max  staff    564630 Aug 15 08:45 PKG-00.toc
-rw-r--r--  1 max  staff  17070667 Aug 15 08:45 PYZ-00.pyz
-rw-r--r--  1 max  staff    436396 Aug 15 08:45 PYZ-00.toc
-rw-r--r--  1 max  staff   1443565 Aug 15 08:45 base_library.zip
drwxr-xr-x  6 max  staff       192 Aug 15 08:45 localpycs
-rw-r--r--  1 max  staff  34007396 Aug 15 08:45 main.pkg
-rw-r--r--  1 max  staff     18491 Aug 15 08:45 warn-main.txt
-rw-r--r--  1 max  staff   3693075 Aug 15 08:45 xref-main.html

Здесь видно, что на выходе мы получили единый бинарный файл.

Запуск того, что получилось

% time dist/main/main --no-serve-files
FileNotFoundError: [Errno 2] No such file or directory: '/Users/max/work/wectory/qa-automation/dist/main/_internal/generator/data.json'
[PYI-4129:ERROR] Failed to execute script 'main' due to unhandled exception!
dist/main/main --no-serve-files  0.35s user 0.04s system 97% cpu 0.406 total

Судя по всему дистрибутив нужно доукомплектовать некоторыми файлами

% cp generator/data.json dist/main/_internal/generator/

Пробуем ещё раз и получаем

FileNotFoundError: [Errno 2] No such file or directory: '/Users/max/work/wectory/qa-automation/dist/main/_internal/generator/templates/default'
[PYI-4343:ERROR] Failed to execute script 'main' due to unhandled exception!

Ну что ж, переносим и это

% cp -r generator/templates dist/main/_internal/generator

На этот раз всё прошло успешно:

┏━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ Landlord  ┃ Katherine Francesca Mills (Company)               ┃
┡━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
│ birthdate │ 30.05.1969                                        │
│ email     │ developers+l240815084019@wectory.com              │
│ phone     │ +447181970103                                     │
│ address   │ 3046 Powell Union Suite 769, North Rita, NH 65169 │
└───────────┴───────────────────────────────────────────────────┘
┏━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ Agent     ┃ Chelsea Hazel Williams                          ┃
┡━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
│ birthdate │ 22.11.1988                                      │
│ email     │ developers+a240815084019@wectory.com            │
│ phone     │ +447197971413                                   │
│ address   │ 2310 Bolton Lodge Apt. 402, Jonesstad, WA 61453 │
└───────────┴─────────────────────────────────────────────────┘
┏━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ Agency         ┃ Jones, Bradley and Murphy             ┃
┡━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
│ email          │ developers+a240815084019@wectory.com  │
│ sort code      │ 846683                                │
│ account number │ 73977208                              │
│ address        │ 577 Hull Drives, Curtisberg, WY 87473 │
└────────────────┴───────────────────────────────────────┘

Results 1: /Users/max/work/wectory/qa-automation/dist/main/_internal/generator/generated/2024-08-15-08-40-19

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

% time dist/main --no-serve-files
FileNotFoundError: [Errno 2] No such file or directory: '/var/folders/dw/vk1bw2wd18zdpgs2m8lfzw4r0000gn/T/_MEIzYrZXM/generator/data.json'
[PYI-5126:ERROR] Failed to execute script 'main' due to unhandled exception!
dist/main --no-serve-files  0.93s user 0.87s system 14% cpu 12.197 total

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

Попробуем с командной строкой:

% pyinstaller --runtime-tmpdir main.tmp --onefile generator/main.py
% dist/main --no-serve-files
FileNotFoundError: [Errno 2] No such file or directory: '/Users/max/work/wectory/qa-automation/main.tmp/_MEIXIoz7Y/generator/data.json'
[PYI-7163:ERROR] Failed to execute script 'main' due to unhandled exception!

Как можно заметить в качестве временного каталога действительно был использован заданный ранее main.tmp, но с нюансом — само приложение было распаковано в подкаталог _MEIXIoz7Y, который был автоматически удалён после завершения выполнения

% ls -l main.tmp
total 0

Тоже самое, если указать петь через переменную окружения (пришлось пересобрать приложение без ключа --runtime-tmpdir). Путь должен быть создан заранее

% mkdir main.tmp2
% env TMPDIR=main.tmp2 dist/main --no-serve-files
FileNotFoundError: [Errno 2] No such file or directory: '/Users/max/work/wectory/qa-automation/main.tmp2/_MEIbatKsa/generator/data.json'
[PYI-7744:ERROR] Failed to execute script 'main' due to unhandled exception!

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

Nuitka

Nuitka отличается от PyInstaller тем, что транспилирует код на Python в код на C и затем компилирует его в нативный запускаемый файл. Но чтобы сделать приложение полностью переносимым нужно использовать опцию --standalone, иначе приложение будет зависеть от библиотек, которые придётся устанавливать на целевой машине.

Версия

% nuitka --version
2.4.5
Commercial: None
Python: 3.11.9 (main, Apr  2 2024, 08:25:04) [Clang 15.0.0 (clang-1500.3.9.4)]
Flavor: Homebrew Python
Executable: /Users/max/work/wectory/qa-automation/env/bin/python3.11
OS: Darwin
Arch: arm64
macOSRelease: 14.6
Version C compiler: /usr/bin/clang (clang 15.0.0).

Приложение в виде каталога

% time nuitka --standalone generator
nuitka --standalone generator  1616.68s user 210.46s system 473% cpu 6:26.05 total

Сборка выполнялась заметно дольше (даже ногам стало тепло в процессе), чем это делал PyInstaller. Но оно и понятно — в процессе происходить полноценная компиляция не только кода нашего приложения, но и всех зависимостей.

% du -sh generator.build generator.dist
442M    generator.build
104M    generator.dist

Не буду приводить здесь вывод ls -ltr generator.build generator.dist — он получается очень большой из-за количества модулей.

Приложение в одном файле

% time nuitka --standalone --onefile generator
nuitka --standalone --onefile generator  301.93s user 106.49s system 202% cpu 3:21.68 total

Что получаем

% du -sh generator.build generator.dist generator.onefile-build generator.bin
440M    generator.build
104M    generator.dist
 23M    generator.onefile-build
 23M    generator.bin

Запуск того, что получилось

% time generator.dist/generator.bin --no-serve-files
FileNotFoundError: [Errno 2] No such file or directory: '/Users/max/work/wectory/qa-automation/generator.dist/generator/data.json'
generator.dist/generator.bin  0.23s user 0.03s system 97% cpu 0.274 total

И видим ошибку, которая похожа на ту, что мы видели с PyInstaller. Благо, понятно что делать

% cp -r generator/data.json generator/templates generator.dist/generator/

Попытка №2

% time generator.dist/generator.bin --no-serve-files

И неминуемый успех

┏━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ Landlord  ┃ Carly Ashley Roberts (Individual)        ┃
┡━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
│ birthdate │ 26.08.1988                               │
│ email     │ developers+l240815105459@wectory.com     │
│ phone     │ +447417436058                            │
│ address   │ 597 Allison Shoal, North James, TN 27518 │
└───────────┴──────────────────────────────────────────┘
┏━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ Agent     ┃ Jenna Lorraine Humphreys                      ┃
┡━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
│ birthdate │ 17.05.1975                                    │
│ email     │ developers+a240815105459@wectory.com          │
│ phone     │ +447934521633                                 │
│ address   │ 551 Justin Light Apt. 663, Hessberg, HI 69859 │
└───────────┴───────────────────────────────────────────────┘
┏━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ Agency         ┃ Smith Group                                            ┃
┡━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
│ email          │ developers+a240815105459@wectory.com                   │
│ sort code      │ 563919                                                 │
│ account number │ 04717704                                               │
│ address        │ 0237 Haley Mountain Suite 776, Meredithmouth, ME 27066 │
└────────────────┴────────────────────────────────────────────────────────┘

Results 1: /Users/max/work/wectory/qa-automation/generator.dist/generator/generated/2024-08-15-10-54-59
generator.dist/generator.bin --no-serve-files  1.18s user 0.33s system 94% cpu 1.590 total

Теперь попробуем однофайловый вариант

% time ./generator.bin
FileNotFoundError: [Errno 2] No such file or directory: '/private/var/folders/dw/vk1bw2wd18zdpgs2m8lfzw4r0000gn/T/onefile_20785_1723701790_311895/generator/data.json'
./generator.bin  0.77s user 0.24s system 15% cpu 6.668 total

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

Заключение

Выводы по итогам знакомства с PyInstaller и Nuitka у меня получаются такие:

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

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

  3. что касается скорости сборки, то PyInstaller в этом деле значительно обходит Nuitka. Оно и понятно — PyInstaller не перекомпилирует исходники всего приложения и его зависимостей, а только перекладывает в целевой каталог. Время сборки важно учитывать, когда мы строим пайплайны

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

Для себя я бы выбрал Nuitka. А какой инструмент выберете сегодня вы?

© Habrahabr.ru