Недавно возникла необходимость предоставить нашему QA-отделу один из модулей на Python в виде автономного бинарика, который не требовал бы установки и настройки окружения. Следуя за необходимостью был сформирован интерес к существующим для этого средствам.
Один из вариантов был использовать Docker, но я от него отказался по причине того, что окружение для Docker тоже надо будет готовить. Потом надо будет правильно запустить этот образ и правильно с ним взаимодействовать. Конечно, для упрощения можно использовать docker compose, но это не сильно снижает сложность для конечного пользователя. Кроме того, образ будет достаточно большим.
Поэтому я после некоторых размышлений обратился к таким инструментам как Python Compilers, а именно - Nuitka и PyInstaller и провёл небольшое исследование на предмет их пригодности для моих нужд.
Оба инструмента упаковывают Python-приложение со всеми зависимостями в один пакет таким образом, что конечный пользователь приложения может обойтись без установки Python на свою машину.
Есть два варианта того, что мы получаем от их работы в качестве результата (кроме эмоциональных ощущений):
Python-приложение представлено одним каталогом с бинариком для запуска и всеми зависимостями в виде отдельных файлов
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 у меня получаются такие:
оба решения работают без специальных танцов с бубном, что хорошо. Правда, возникли сложности со статикой приложения, но это недоработка со стороны самого приложения, а не PyInstaller или Nuitka
на этапе разработки приложений обязательно нужно принять правильное решение, о том где будут храниться файлы с данными и прочая статика, и как будем их запаковывать или передавать пользователям
что касается скорости сборки, то PyInstaller в этом деле значительно обходит Nuitka. Оно и понятно - PyInstaller не перекомпилирует исходники всего приложения и его зависимостей, а только перекладывает в целевой каталог. Время сборки важно учитывать, когда мы строим пайплайны
по размеру получившихся каталогов/файлов PyInstaller тоже обходит Nuitka, хоть и не значительно. Возможно, на больших проектах эта разница уже не будет такой заметной
Для себя я бы выбрал Nuitka, т.к. есть подозрение (хотя я специально этого не проверял, но I Want to Believe), что скомпилированный код будет работать быстрее.
А какой инструмент выберете сегодня вы?