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

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

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

PyInstaller: сборка exe из Python-скрипта

От скрипта к exe: PyInstaller анализирует импорты, кладёт интерпретатор, байткод и данные в один файл. Собираем бандл по шагам и проверяем его на чистой машине.

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

Прошлые два урока решали проблему зависимости от Python наполовину: zipapp собрал код в один файл, embeddable добавил интерпретатор рядом. Но пользователь всё ещё получает папку. Самый популярный способ склеить всё в один исполняемый файл — PyInstaller: он кладёт в exe интерпретатор, ваш код и зависимости, и на машине пользователя не нужно ничего, кроме самой системы. Урок разбирает, как устроена сборка изнутри — и где она молча промахивается.

Установка и первая сборка

терминал: установка и сборка
pip install pyinstaller
pyinstaller main.py
Это команды терминала, поэтому блок не запускается в браузерной песочнице: PyInstaller — нативный инструмент, он работает на реальной машине с компилятором-упаковщиком.

Команда делает три шага: анализирует импорты в main.py и во всём, что она тянет за собой; собирает найденное вместе с интерпретатором; записывает результат. В папке проекта появляются три вещи: папка build с черновиками, папка dist с готовым результатом и spec-файл — рецепт сборки:

папка проекта после сборки
mytool/
|-- main.py
|-- main.spec          <- рецепт сборки
|-- build/             <- рабочая папка, можно удалять
`-- dist/
    `-- main/
        |-- main.exe   <- запускать это
        `-- _internal/ <- dll, библиотеки, байткод
Это структура папки, а не вывод программы, поэтому блок не запускается. Имена в _internal зависят от версии Python и платформы, поэтому пофайлистый список не приводим.

Такой результат называется onedir — «одна папка»: exe рядом с папкой _internal, где лежит всё остальное. Запуск — двойной клик или команда из терминала; exe сам найдёт всё нужное в _internal рядом с собой:

терминал: запуск собранного приложения
dist\main\main.exe
Команда терминала для Windows; в песочнице не выполняется — собранного exe в браузере нет. На macOS и Linux путь будет через слэш.

Анализ импортов: что анализатор видит

PyInstaller не исполняет код при сборке — он читает его как текст: находит все операторы импорта и кладёт найденные модули в бандл. Повторим такой обход стандартным модулем ast, который разбирает исходник в дерево:

анализ импортов как в PyInstaller
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 — упрощённый рецепт
# 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')
Спек-файл выполняется PyInstaller'ом на реальной машине, в песочнице он не запускается. Настоящие спеки длиннее — здесь оставлена суть: вход, данные, скрытые импорты, имя exe.

Скрытые импорты: дыра в анализе

У анализа импортов есть слепая зона: 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, а внутри всегда интерпретатор той версии, которой собирали.

Команда для твоей системы

СистемаКоманда сборкиЧто получится
Windowspyinstaller --onefile main.pydist/main.exe — файл, который запускается двойным кликом
Linuxpyinstaller --onefile main.pydist/main — исполняемый файл без расширения, запуск ./main
macOSpyinstaller --windowed main.pydist/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')
Проверь себя
0 / 7

1. Что делает команда pyinstaller main.py?

2. Что появляется в папке проекта после первой сборки?

3. В каком виде ваш код лежит внутри собранного exe?

4. Почему importlib.import_module('plugins.' + name) опасен для сборки?

5. Как попадает в бандл файл данных config.json?

6. Собранный на Windows exe запустится на macOS?

7. Какая честная проверка собранного exe на полноту?

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

Собери мини-бандл app.exe: zip с точкой входа __main__.py и модулем calc.py. Выведи список содержимого, поставь архив в путь поиска и исполни точку входа. Ожидаемый вывод: список и строка Итого: 102

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

Что делает 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-канал или свой блог — так о проекте узнают новые читатели.

TelegramVK

Что дальше

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

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

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