zipapp: скрипт-архив .pyz — программа в одном файле
Один файл вместо папки: собираем запускаемый zip-архив с точкой входа __main__.py, дописываем shebang и разбираем, когда .pyz достаточно без exe.
Редакция Питоники
До сих пор наши программы были одиночными .py-файлами. Реальный инструмент — чаще папка: главный скрипт, модули рядом, иногда данные. Отдавать папку пользователю неудобно: при копировании легко потерять файл, а при обновлении — перепутать версии. Первый шаг к одному файлу встроен в стандартную библиотеку: модуль zipapp собирает папку с кодом в запускаемый zip-архив с расширением .pyz. В этом уроке мы соберём такой архив, запустим его и разберём, что происходит внутри.
Python умеет исполнять zip напрямую
Фундамент, на котором стоит zipapp, — особенность самого интерпретатора: если передать ему путь к zip-файлу, Python не растеряется. Он откроет архив, найдёт в корне файл __main__.py и выполнит его. Проверим механизм вручную: соберём архив стандартным zipfile, а исполним функцией runpy.run_path — в песочнице она сыграет роль команды python tool.pyz:
import zipfile, runpy, os
with zipfile.ZipFile('tool.pyz', 'w') as z:
z.writestr('__main__.py', "print('Привет из архива')\n")
runpy.run_path('tool.pyz', run_name='__main__')
os.remove('tool.pyz')
Привет из архива
Никакой магии: архив — просто контейнер, а __main__.py — договорённость о точке входе. Команда python tool.pyz делает ровно то же: открывает zip, находит __main__.py, исполняет. Остальные модули архива при этом доступны для импорта: запуская архив, Python добавляет его в начало пути поиска модулей — как будто это папка.
zipapp.create_archive: официальная сборка
Руками собирать архив не нужно: модуль zipapp делает то же одной функцией create_archive. Ей передают папку с кодом и имя будущего архива. Требование одно — точка входа: файл __main__.py в корне папки должен существовать, иначе запускать архив будет нечего. Соберём мини-проект из двух файлов:
import os, zipapp, zipfile, runpy, shutil
os.mkdir('project')
with open('project/app.py', 'w', encoding='utf-8') as f:
f.write("TITLE = 'Инвентарь'\n")
with open('project/__main__.py', 'w', encoding='utf-8') as f:
f.write("import app\nprint(app.TITLE, 'собран в архив')\n")
zipapp.create_archive('project', 'project.pyz')
with zipfile.ZipFile('project.pyz') as z:
print('Внутри архива:', ', '.join(sorted(z.namelist())))
runpy.run_path('project.pyz', run_name='__main__')
shutil.rmtree('project')
os.remove('project.pyz')
Внутри архива: __main__.py, app.py Инвентарь собран в архив
Обратите внимание на запуск: __main__.py внутри архива делает import app — и импорт находит app.py в том же архиве. Для кода внутри .pyz собственный архив выглядит обычной папкой с модулями: работает встроенный механизм zipimport, который умеет импортировать прямо из zip-файлов. Можно и руками добавить архив в путь поиска — результат тот же:
import sys, zipfile, os
with zipfile.ZipFile('app.pyz', 'w') as z:
z.writestr('packhelper.py', "def hello():\n return 'Модуль найден внутри архива'\n")
sys.path.insert(0, 'app.pyz')
import packhelper
print(packhelper.hello())
sys.path.remove('app.pyz')
os.remove('app.pyz')
Модуль найден внутри архива
Параметр main: точка входа без лишнего файла
Писать __main__.py руками каждый раз необязательно: у create_archive есть параметр main. Ему передают строку вида «модуль:функция», и zipapp сам сгенерирует файл точки входа с вызовом вашей функции:
import os, zipapp, zipfile, runpy, shutil
os.mkdir('service')
with open('service/report.py', 'w', encoding='utf-8') as f:
f.write("def main():\n print('Отчёт: 3 позиции')\n")
zipapp.create_archive('service', 'service.pyz', main='report:main')
with zipfile.ZipFile('service.pyz') as z:
print('zipapp сам создал точку входа:', sorted(z.namelist()))
runpy.run_path('service.pyz', run_name='__main__')
shutil.rmtree('service')
os.remove('service.pyz')
zipapp сам создал точку входа: ['__main__.py', 'report.py'] Отчёт: 3 позиции
Внутри сгенерированного __main__.py — пара служебных строк: импортировать report и вызвать report.main(). А привычка заглядывать в архив через zipfile — полезная вообще: содержимое .pyz всегда можно посмотреть, не запуская его, тем же стандартным zipfile.
Shebang: архив как самостоятельная программа
На Unix-системах файл может запускаться сам, без слова python в команде, — если в его начале стоит shebang: строка #! с путём к интерпретатору. create_archive умеет дописывать её параметром interpreter:
import os, zipapp, shutil
os.mkdir('cli')
with open('cli/__main__.py', 'w', encoding='utf-8') as f:
f.write("print('Запуск как программа')\n")
zipapp.create_archive('cli', 'cli.pyz', interpreter='/usr/bin/env python3')
first_line = open('cli.pyz', 'rb').readline().decode('utf-8')
print('Первая строка архива:', first_line, end='')
shutil.rmtree('cli')
os.remove('cli.pyz')
Первая строка архива: #!/usr/bin/env python3
Первой строкой архива стала строка shebang. После chmod +x cli.pyz на Linux или macOS файл запускается командой ./cli.pyz — система прочтёт shebang и позовёт Python. В Windows shebang не работает: там архив запускают как python cli.pyz либо назначают ассоциацию файлов .pyz на установленный интерпретатор.
Сжатие: параметр compressed
По умолчанию zipapp складывает файлы без сжатия — так архив читается чуть быстрее. Параметр compressed=True включает стандартное zip-сжатие: файл меньше, старт — на доли секунды дольше. Режим каждой записи виден прямо в содержимом архива, по атрибуту compress_type:
import os, zipapp, zipfile, shutil
os.mkdir('data')
with open('data/__main__.py', 'w', encoding='utf-8') as f:
f.write("print('Готово')\n")
zipapp.create_archive('data', 'plain.pyz')
zipapp.create_archive('data', 'packed.pyz', compressed=True)
for name in ('plain.pyz', 'packed.pyz'):
with zipfile.ZipFile(name) as z:
stored = all(i.compress_type == zipfile.ZIP_STORED for i in z.infolist())
print(name, '- без сжатия:', stored)
shutil.rmtree('data')
os.remove('plain.pyz')
os.remove('packed.pyz')
plain.pyz - без сжатия: True packed.pyz - без сжатия: False
ZIP_STORED означает «сложено без сжатия», ZIP_DEFLATED — «сжато». Сам размер архива мы сознательно не печатаем: он зависит от версии Python и платформы, а вот режим сжатия — факт бинарный, он одинаковый везде.
Команды сборки и запуска .pyz
В своей системе всё то же самое делается без кода — командой python -m zipapp:
python -m zipapp src -o app.pyz -p "/usr/bin/env python3"
python app.pyz
python broken.pyz
Когда zipapp достаточен
- скрипт на чистой стандартной библиотеке — без внешних пакетов;
- внутренний инструмент для коллег, у которых Python уже установлен;
- быстрый способ отдать программу одним файлом без сборки exe.
Границы честные: Python на машине пользователя всё равно нужен. Зависимости из внешних каталогов zipapp упакует, но их сначала надо сложить в папку ключом -t у pip — архиватор кладёт в .pyz ровно то, что вы ему дали. И исходники внутри архива читаемы любым zip-редактором: упаковка — не защита кода.
Следующий шаг — то же самое, но с интерпретатором в комплекте: embeddable Python кладёт рядом с программой сам Python, и ничего устанавливать не нужно ни вам, ни пользователю.
zipapp решает проблему многих файлов: папка с кодом превращается в один .pyz с точкой входа, shebang и опциональным сжатием. Когда этого мало — нужен интерпретатор в комплекте или исполняемый файл — на сцену выходят инструменты следующих уроков: PyInstaller собирает exe с интерпретатором внутри, а embeddable-пакет кладёт Python рядом с программой без установки.
Сначала предскажи ответ в голове — это главный навык программиста.
import zipfile, runpy, os
with zipfile.ZipFile('app.pyz', 'w') as z:
z.writestr('__main__.py', "print('Задача выполнена')\n")
runpy.run_path('app.pyz', run_name='__main__')
os.remove('app.pyz')
import os, zipapp, zipfile, shutil
os.mkdir('mini')
with open('mini/worker.py', 'w', encoding='utf-8') as f:
f.write("def run():\n print('Работаю')\n")
zipapp.create_archive('mini', 'mini.pyz', main='worker:run')
with zipfile.ZipFile('mini.pyz') as z:
print(sorted(z.namelist()))
shutil.rmtree('mini')
os.remove('mini.pyz')
import os, zipfile, zipapp, shutil
os.mkdir('tiny')
with open('tiny/__main__.py', 'w', encoding='utf-8') as f:
f.write("print('ok')\n")
zipapp.create_archive('tiny', 'tiny.pyz', compressed=True)
with zipfile.ZipFile('tiny.pyz') as z:
stored = all(i.compress_type == zipfile.ZIP_STORED for i in z.infolist())
print('Без сжатия:', stored)
shutil.rmtree('tiny')
os.remove('tiny.pyz')
1. Что делает python app.pyz?
2. Что должно быть в папке, чтобы zipapp.create_archive дал запускаемый архив?
3. Что делает параметр interpreter у create_archive?
4. Где запуск ./app.pyz без слова python работает из коробки?
5. Что меняет compressed=True при сборке архива?
6. Внутри __main__.py из архива есть import app. Откуда импортируется app?
7. Защищает ли .pyz ваш код от чтения?
Собери архив pack.pyz из папки pack с модулем calc.py и точкой входа __main__.py, запусти архив через runpy и убери за собой. Ожидаемый вывод: Сумма: 42
Чем .pyz-архив отличается от exe?
pyz — это zip с вашими исходниками и точкой входа __main__.py: для запуска нужен установленный Python. exe, собранный PyInstaller, содержит ещё и интерпретатор с зависимостями — запускается двойным кликом без Python в системе.
Урок 10: PyInstaller — сборка exeКак запустить .pyz на машине без Python?
Никак: архиву нужен интерпретатор. Варианты два — положить рядом портативный Python из embeddable-пакета или собрать полноценный exe. Плюс .pyz в том, что он не требует сборки: достаточно zip-архива с точкой входа.
Урок 9: embeddable PythonЗачем параметр main, если можно написать __main__.py самому?
Это короче и надёжнее: zipapp.create_archive('src', 'app.pyz', main='worker:run') сам генерирует файл точки входа с вызовом worker.run, и в проекте не остаётся шаблонного файла, который можно случайно забыть обновить.
Можно ли спрятать код внутри .pyz?
Нет: архив — обычный zip, исходники читаются любым распаковщиком. Упаковка решает проблему многих файлов, а не защиты кода. Сокрытие — это компиляция: pyc вместо исходников или машинный код через Nuitka.
Урок 15: декомпиляция pycПонравился урок? Сошлитесь на него
«zipapp не компилирует код: внутри архива лежат те же исходники, собранные в один файл, и для запуска по-прежнему нужен установленный Python.»
Скопируйте готовую ссылку в формате HTML, Markdown или чистый адрес и вставьте в статью на Habr, VC, Telegram-канал или свой блог — так о проекте узнают новые читатели.
Что дальше
Осталось в разделе: 20 из 20. Прогресс хранится в вашем браузере.
Что читать дальше
Компиляция · Урок 9
embeddable Python: портативный интерпретатор рядом с программой
Интерпретатор в одной папке с программой: что внутри embeddable-пакета python.org, как читается ._pth-конфигурация и почему site-packages выключен по умолчанию.
Компиляция · Урок 10
PyInstaller: сборка exe из Python-скрипта
От скрипта к exe: PyInstaller анализирует импорты, кладёт интерпретатор, байткод и данные в один файл. Собираем бандл по шагам и проверяем его на чистой машине.
Компиляция · Урок 5
Версии Python и magic-число: почему pyc не совместим
Каждая минорная версия CPython получает новое magic-число, и pyc одной версии не грузится другой. Находим число в importlib, сверяем с заголовком pyc и проверяем версию кодом.
Похожие уроки по темам
Подобраны автоматически по пересечению тем и ключевых слов.
pytest · Урок 4
Несколько тестов в файле: независимость и порядок
Один файл — три теста — одно падение: упавший тест не отменяет соседей. Плюс порядок сверху вниз, помощники вместо копипасты и первый рефакторинг под защитой набора.
pytest несколько тестов файлpytest несколько тестов в одном файле
requests · Урок 5
Скачивание файлов в requests: картинка, архив и запись на диск
Скачивание в requests — это два шага: получить байты из .content и записать их в файл в бинарном режиме. Для больших файлов включаем stream=True.
requests скачать файлrequests сохранить файл
Компиляция · Урок 20
Проект: standalone-инструмент от исходника до pyz-архива
Финальный проект раздела: инструмент отчёта по продажам проходит путь от исходника до проверенного tasks.pyz. Проверка компиляции, zipapp с __main__.py, запуск архива, инвентаризация содержимого и чек-лист сборки.
zipapp python проект__main__.py zipapp