PyInstaller: сборка exe из Python-скрипта
От скрипта к exe: PyInstaller анализирует импорты, кладёт интерпретатор, байткод и данные в один файл. Собираем бандл по шагам и проверяем его на чистой машине.
Редакция Питоники
Прошлые два урока решали проблему зависимости от Python наполовину: zipapp собрал код в один файл, embeddable добавил интерпретатор рядом. Но пользователь всё ещё получает папку. Самый популярный способ склеить всё в один исполняемый файл — PyInstaller: он кладёт в exe интерпретатор, ваш код и зависимости, и на машине пользователя не нужно ничего, кроме самой системы. Урок разбирает, как устроена сборка изнутри — и где она молча промахивается.
Установка и первая сборка
pip install pyinstaller
pyinstaller main.py
Команда делает три шага: анализирует импорты в main.py и во всём, что она тянет за собой; собирает найденное вместе с интерпретатором; записывает результат. В папке проекта появляются три вещи: папка build с черновиками, папка dist с готовым результатом и spec-файл — рецепт сборки:
mytool/
|-- main.py
|-- main.spec <- рецепт сборки
|-- build/ <- рабочая папка, можно удалять
`-- dist/
`-- main/
|-- main.exe <- запускать это
`-- _internal/ <- dll, библиотеки, байткод
Такой результат называется onedir — «одна папка»: exe рядом с папкой _internal, где лежит всё остальное. Запуск — двойной клик или команда из терминала; exe сам найдёт всё нужное в _internal рядом с собой:
dist\main\main.exe
Анализ импортов: что анализатор видит
PyInstaller не исполняет код при сборке — он читает его как текст: находит все операторы импорта и кладёт найденные модули в бандл. Повторим такой обход стандартным модулем ast, который разбирает исходник в дерево:
import ast
source = (
"import json\n"
"import sys\n"
"from pathlib import Path\n"
"\n"
"def load(path):\n"
" text = Path(path).read_text(encoding='utf-8')\n"
" return json.loads(text)\n"
)
found = []
for node in ast.walk(ast.parse(source)):
if isinstance(node, ast.Import):
for alias in node.names:
found.append(alias.name)
elif isinstance(node, ast.ImportFrom):
found.append(node.module)
print('Анализатор нашёл импорты:', sorted(found))
Анализатор нашёл импорты: ['json', 'pathlib', 'sys']
Три имени — три модуля в бандле. Реальный анализ PyInstaller глубже: он обходит и стандартную библиотеку, и зависимости с их собственными импортами, плюс знает особенности сотен популярных пакетов через встроенные хуки. Механика же та же: текст программы, найденные импорты.
Что внутри собранного exe
Соберём бандл сами и заглянем внутрь. Роль exe сыграет zip с точкой входа и модулем рядом — упрощённый, но честный по смыслу аналог бутстрапа, который распаковывает код и запускает его:
import zipfile, os, sys
with zipfile.ZipFile('app.exe', 'w') as z:
z.writestr('__main__.py', "from sales import total\nprint('Выручка:', total([100, 250, 80]))\n")
z.writestr('sales.py', "def total(prices):\n return sum(prices)\n")
with zipfile.ZipFile('app.exe') as z:
print('В бандле:', ', '.join(sorted(z.namelist())))
entry = z.read('__main__.py').decode('utf-8')
print('---')
print(entry, end='')
sys.path.insert(0, 'app.exe')
exec(compile(entry, 'app.exe/__main__.py', 'exec'), {'__name__': '__main__'})
sys.path.remove('app.exe')
os.remove('app.exe')
В бандле: __main__.py, sales.py
---
from sales import total
print('Выручка:', total([100, 250, 80]))
Выручка: 430Мы вывели содержимое, прочитали точку входа и исполнили её — программа заработала, найдя свой модуль внутри себя. Настоящий бандл PyInstaller сложнее: там свой бутстрап-код, архив PYZ с байткодом всех модулей и бинарные библиотеки. Исходников в читаемом виде внутри нет — ваш код лежит скомпилированным до байткода, о чём подробно говорили в уроках про pyc.
Файлы данных: параметр datas
Код — не всё: программы читают конфигурацию, шаблоны, справочники. Анализатор данных не видит — он ищет импорты. Файлы объявляют явно, в spec-файле это параметр datas, а мы покажем суть — данные, упакованные рядом с кодом:
import zipfile, os
with zipfile.ZipFile('app.exe', 'w') as z:
z.writestr('runtime.bin', 'interpreter')
z.writestr('data/prices.csv', 'item,price\nlatte,180\n')
with zipfile.ZipFile('app.exe') as z:
names = sorted(n for n in z.namelist() if n.startswith('data'))
print('Файлы данных в бандле:', ', '.join(names))
print(z.read('data/prices.csv').decode('utf-8'), end='')
os.remove('app.exe')
Файлы данных в бандле: data/prices.csv item,price latte,180
Spec-файл: рецепт сборки
После первой сборки PyInstaller оставляет main.spec — это обычный Python-код, описывающий сборку. Пересборка идёт по нему командой pyinstaller main.spec. Главное в спеке — вызов Analysis:
# main.spec — это Python-код: рецепт сборки
a = Analysis(
['main.py'],
datas=[('config.json', '.')], # файлы данных
hiddenimports=['plugins.exporter'], # невидимые анализатору импорты
)
pyz = Pyz(a.pure)
exe = EXE(pyz, a.binaries, a.datas, name='main')
Скрытые импорты: дыра в анализе
У анализа импортов есть слепая зона: import по имени, вычисленному во время работы. Анализатор читает текст — а имя модуля ещё не существует:
import ast
source = (
"import json\n"
"import importlib\n"
"\n"
"name = 'exporter'\n"
"plugin = importlib.import_module('plugins.' + name)\n"
)
found = []
for node in ast.walk(ast.parse(source)):
if isinstance(node, ast.Import):
for alias in node.names:
found.append(alias.name)
elif isinstance(node, ast.ImportFrom):
found.append(node.module)
print('Анализатор видит:', sorted(found))
Анализатор видит: ['importlib', 'json']
В анализе только два модуля — написанные текстом. Плагин plugins.exporter в бандл не попадёт, и на машине пользователя программа упадёт с ModuleNotFoundError, хотя у разработчика всё работало: рядом лежали исходники. Лечится явным перечислением — как в спеке выше, так и в команде через флаг --hidden-import:
seen = ['json', 'pathlib']
hiddenimports = ['PIL._imaging', 'plugins.exporter']
bundled = sorted(set(seen + hiddenimports))
print('Модули в бандле:', ', '.join(bundled))
Модули в бандле: PIL._imaging, json, pathlib, plugins.exporter
Антивирусы и платформы
Собранный exe устроен вызывающе для защитников: неподписанный код, который при запуске распаковывает сам себя. Эвристики антивирусов иногда ругаются на такие файлы. Стандартные средства — цифровая подпись exe и сборка через один и тот же, проверенный инструмент. И помнить про границы: сборка не кросс-платформенная — exe собирается на Windows для Windows, под macOS собирают на macOS, а внутри всегда интерпретатор той версии, которой собирали.
Команда для твоей системы
| Система | Команда сборки | Что получится |
|---|---|---|
| Windows | pyinstaller --onefile main.py | dist/main.exe — файл, который запускается двойным кликом |
| Linux | pyinstaller --onefile main.py | dist/main — исполняемый файл без расширения, запуск ./main |
| macOS | pyinstaller --windowed main.py | dist/main.app — приложение с иконкой, запуск кликом |
Команда одна и та же на всех системах (на macOS добавляется --windowed, чтобы получить .app-бандл вместо консольного бинарника) — меняется только результат: каждая система получает свой формат. Найти выход просто: он всегда в папке dist внутри вашего проекта.
Бандл собран и проверен. Осталась развилка, которую задаёт первая же команда: один файл или папка — onefile против onedir разбирается в следующем уроке, а после них мы сравним упаковщики с компиляторами — Nuitka, который вместо упаковки переводит код в C.
Сначала предскажи ответ в голове — это главный навык программиста.
import ast
source = (
"import json\n"
"from pathlib import Path\n"
)
found = []
for node in ast.walk(ast.parse(source)):
if isinstance(node, ast.Import):
for alias in node.names:
found.append(alias.name)
elif isinstance(node, ast.ImportFrom):
found.append(node.module)
print(sorted(found))
seen = ['json']
hiddenimports = ['requests', 'PIL._imaging']
bundled = sorted(set(seen + hiddenimports))
print(', '.join(bundled))
import zipfile, os
with zipfile.ZipFile('app.exe', 'w') as z:
z.writestr('data/app.conf', 'mode=fast\n')
with zipfile.ZipFile('app.exe') as z:
print(z.read('data/app.conf').decode('utf-8'), end='')
os.remove('app.exe')
1. Что делает команда pyinstaller main.py?
2. Что появляется в папке проекта после первой сборки?
3. В каком виде ваш код лежит внутри собранного exe?
4. Почему importlib.import_module('plugins.' + name) опасен для сборки?
5. Как попадает в бандл файл данных config.json?
6. Собранный на Windows exe запустится на macOS?
7. Какая честная проверка собранного exe на полноту?
Собери мини-бандл app.exe: zip с точкой входа __main__.py и модулем calc.py. Выведи список содержимого, поставь архив в путь поиска и исполни точку входа. Ожидаемый вывод: список и строка Итого: 102
Что делает PyInstaller простыми словами?
Складывает в один исполняемый файл интерпретатор Python, ваш скомпилированный до байткода код и все зависимости. На машине пользователя не нужен ни Python, ни pip: exe самодостаточен.
Урок 11: onefile против onedirПочему exe из Python-скрипта получается большим?
Потому что внутри целый интерпретатор плюс все подтянутые зависимости. Размер — цена самодостаточности; уменьшают его чистым окружением без лишних пакетов и исключением неиспользуемых модулей через excludes.
PyInstaller не видит мой модуль или файл данных. Что делать?
Данные объявить в datas, невидимые импорты — в hiddenimports (или флагом --hidden-import). Типичные жертвы анализа: import_module с вычисленным именем, плагины, файлы, открываемые по строковому пути.
Защищает ли exe мой код от копирования?
Слабо: внутри байткод, который декомпиляторы восстанавливают в читаемый вид для соответствующей версии Python. Сильнее скрывает код нативная компиляция — Nuitka переводит программу в C и машинный код.
Урок 15: декомпиляция pycПонравился урок? Сошлитесь на него
«PyInstaller не исполняет код при сборке — он читает его как текст: находит все операторы импорта и кладёт найденные модули в бандл.»
Скопируйте готовую ссылку в формате HTML, Markdown или чистый адрес и вставьте в статью на Habr, VC, Telegram-канал или свой блог — так о проекте узнают новые читатели.
Что дальше
Осталось в разделе: 20 из 20. Прогресс хранится в вашем браузере.
Что читать дальше
Компиляция · Урок 11
PyInstaller onefile против onedir: что выбрать
Один exe или exe с папкой _internal: как onefile и onedir влияют на старт, обновление, антивирусы и поиск файлов данных — с механикой бутстрапа на живом коде.
Компиляция · Урок 9
embeddable Python: портативный интерпретатор рядом с программой
Интерпретатор в одной папке с программой: что внутри embeddable-пакета python.org, как читается ._pth-конфигурация и почему site-packages выключен по умолчанию.
Компиляция · Урок 15
Декомпиляция pyc в Python: что видно из байткода
pyc не содержит исходного текста, но хранит имена, константы и структуру. Разбираем, что восстановит декомпилятор, что потеряно навсегда и где проходит этическая граница.
Похожие уроки по темам
Подобраны автоматически по пересечению тем и ключевых слов.
requests · Урок 6
POST-запросы в requests: формы data, JSON и загрузка файлов
POST отправляет данные в теле запроса: формы через data, JSON через json=, файлы через files=. Разбираем, чем они отличаются и где теряют данные.
requests post data jsonrequests files загрузка
pytest · Урок 17
Тесты для JSON и файлов: tmp_path на практике
Сохранить заказы в JSON, прочитать обратно и не разочароваться: round-trip тесты, кириллица с encoding utf-8, битые файлы через pytest.raises и tmp_path — папка, которая достаётся каждому тесту своя.
временная папка pytestтест записи в файл python
Компиляция · Урок 20
Проект: standalone-инструмент от исходника до pyz-архива
Финальный проект раздела: инструмент отчёта по продажам проходит путь от исходника до проверенного tasks.pyz. Проверка компиляции, zipapp с __main__.py, запуск архива, инвентаризация содержимого и чек-лист сборки.
python сборка exe проект__main__.py zipapp