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

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

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

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 сам сгенерирует файл точки входа с вызовом вашей функции:

zipapp сам создаёт __main__.py
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:

shebang первой строкой архива
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
Это команды терминала, поэтому блок не запускается в браузерной песочнице. Флаг -o задаёт имя архива, -p дописывает shebang, --compress включает сжатие. В песочнице выше те же шаги сделаны функцией create_archive.
архив без точки входа
python broken.pyz
Тоже терминальная команда, и провальная: в broken.pyz нет __main__.py. Python откажется запускать архив и напечатает сообщение о том, что точка входа не найдена; формулировка сообщения зависит от версии интерпретатора, поэтому вывод не приводим.

Когда 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')
Проверь себя
0 / 7

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 ваш код от чтения?

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

Собери архив pack.pyz из папки pack с модулем calc.py и точкой входа __main__.py, запусти архив через runpy и убери за собой. Ожидаемый вывод: Сумма: 42

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

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

TelegramVK

Что дальше

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

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

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