Python Заметки
2.31K subscribers
58 photos
2 videos
2 files
212 links
Интересные заметки и обучающие материалы по Python

Контакт: @paulwinex

⚠️ Рекламу на канале не делаю!⚠️

Хештеги для поиска:
#tricks
#libs
#pep
#basic
#regex
#qt
#django
#2to3
#source
#offtop
Download Telegram
3Dшников и всех кто в теме, повсеместно поздравляю! 🥳

#offtop
JSON — весьма удобный формат для передачи или хранения данных. Но у него есть одна особенность: по умолчанию он умеет сериализовать только стандартные типы данных Python, такие как int, float, list, dict и тд.
Как только появляется какой-либо класс мы терпим фиаско с ошибкой что-то вроде

TypeError: Object of type MyClass is not JSON serializable

Причём не поддерживаются даже стандартные классы типа datetime или re.Pattern 😢. Повезло лишь некоторым классам, которые предназначены для хранения данных, например namedtuple или defaultdict.
Как сделать так, чтобы любой объект смог сериализоваться в JSON?

Обычный подход это создать свой класс-сериализатор, где и будет прописан алгоритм превращения объектов в строку, словарь или что-то обычное для JSON
Допустим, у меня есть мой класс:

class MyClass:
def __init__(self):
self.x = 100

Создадим сериализатор который умеет понимать такой тип объекта. Его задача: сохранить имя типа и данные инстанса чтобы потом можно было восстановить объект обратно. Для этого переопределим метод default(), котоырй по умолчанию выбрасывает ошибку когда объект не поддерживается.

class MySerializer(json.JSONEncoder):
def default(self, obj):
if isinstance(obj, MyClass):
return {'type': MyClass.__name__,
'data': obj.__dict__}
return super().default(obj)

Пробуем закодировать объект в JSON используя мой сериализатор

>>> mc = MyClass()
>>> json.dumps(mc, cls=MySerializer)
'{"type": "MyClass", "data": {"x": 100}}'

Отлично, теперь json понимает мой тип! 😎

#libs
В прошлом посте мы научили JSON понимать новый тип данных. Но что если придётся записывать в JSON много разных неподдерживаемых типов?
Описывать для каждого отдельный if isinstance()?
А если нам не известно что именно придётся сериализовать и нужно поддерживать в принципе всё что угодно? Ну хотя бы как-то записать объект чтобы не было ошибки и чтобы объект записался в JSON более менее информативно.

В этом случае можно вызвать стандартные способы репрезентации объекта. Самое простое это функция repr(). И тут уж как повезёт с тем, как именно разработчик позаботился о таком поведении его класса.
Более описательный метод, это закинуть объект в словарь с именем класса и данными инстанса.
В общем, возвращаясь к примеру из прошлого поста, мы просто убираем проверку конкретного типа

class MySerializer(json.JSONEncoder):
def default(self, obj):
return {'type': obj.__class__.__name__,
'data': vars(obj)}

Функция vars() аналогична обращению к атрибуту __dict__

Теперь любой ранее неизвестный объект будет успешно сериализован... или нет?

>>> json.dumps(datetime.datetime.now(), cls=MySerializer)
AttributeError: 'datetime.datetime' object has no attribute '__dict__

Так уж вышло, что не любой объект имеет атрибут __dict__. В таком случае используем repr()

class MySerializer(json.JSONEncoder):
def default(self, obj):
return {'type': obj.__class__.__name__,
'data': getattr(obj, '__dict__', repr(obj))}

То есть мы пробуем забрать данные инстанса, и если не получается то вызываем стандартное строковое представление объекта в надежде что разработчики позаботились о нём.

>>> json.dumps(datetime.now(), cls=MySerializer)
'{"type": "datetime", "data": "datetime.datetime(...)"}'

⚠️ PS: Не могу сказать что решение идеально. Такую неявную сериализацию можно сравнить с замалчиванием ошибок.

try:
do_something()
except:
pass

То есть в какой-то момент вы будете получать совершенно бесполезные данные и придётся искать где это происходит. Если не определён метод __repr__ то вы получите что-то вроде такого

<__main__.MyClass object at 0x00000147CE48CBC8>

Что с этим делать? Непонятно! 🤔
Скорее всего найдутся люди, которые осудят такой подход.
Может выбрасывать ошибку в случае отсутствия метода __repr__ в классе?

#libs #tricks
Еще раз про JSON
Для создания кастомной сериализации объектов в JSON не обязательно создавать класс-сериализатор. Достаточно указать функцию default() которую в свою очередь подать в виде лямбды.
За читаемость примера не ручаюсь но выйдет что-то вроде такого:

json.dumps(my_data, default=lambda obj: {
'type': f'{obj.__class__.__module__}.{obj.__class__.__name__}',
'data': getattr(obj, '__dict__', repr(obj))})

В примере я добавил полный путь к классу включая имя модуля.

⚠️ Повторяю! В ситуации, когда данные неизвестны, такой подход может привести к непредсказуемому поведению! Лично я использовал его только для дебага, когда требовалось получить хоть что-то в виде JOSN а не ошибку.

Явное лучше чем неявное 😉
Кстати, обычная функция часто лучше чем лямбда 😬
Так что вам эта же функция в нормальном виде

def default_hook(obj):
return {
'type': f'{obj.__class__.__module__}.'
f'{obj.__class__.__name__}',
'data': getattr(obj, '__dict__', repr(obj))
}
json.dumps(my_data, default=default_hook)

Почему бы не организовать поддержку всех стандартных объектов Python в стандартном JSON-энкодере? Я думаю дело в неочевидности этого процесса. Как можно сериализовать float? Тут вполне очевидно. А как сериализовать datetime? Вот тут тысяча и один вариант как можно форматировать дату. Поэтому данный этап отадётся на откуп разработчику.

Меня устроило бы добавление в спецификацию класса метода __json__ по аналогии с __fspath__, который использовался бы стандартным энкодером. Метод возвращал бы поддерживаемый для JSON объект. Тогда не требуется что-то указывать в функции dump() и наш класс может использоваться в других модулях, где код уже записан и вставить что-то в default м не можем.
Но пока этого нет (и будет ли?) мы по-прежнему добавляем в класс метод toJson() и вызываем его, отправляя в json.dump().

#tricks #libs
Как разделить строку с shell-командой на отдельные аргументы в виде списка?
Если сделать просто сплит по пробелу то получим то что надо, кроме случаев со вставками текста с пробелами. Например так:

>>> '-arg "I Am Groot"'.split(' ')
['-arg', '"I', 'Am', 'Groot"']

Чтобы учитывать текст в кавычках как единый аргумент можно воспользоваться функцией shlex.split()
Кто читает мой канал давно, уже в курсе.

А что делать, если нужно обратное действие? Объединить аргументы из списка в строку и при этом добавить кавычки в аргумент с пробелами.
Конечно, если вы используете subprocess то он сам всё разрулит. Но если вам нужна именно команда одной строкой, то можно воспользоваться готовой функцией в том же subprocess.

>>> from subprocess import list2cmdline
>>> list2cmdline(['-arg', 'I Am Groot'])
'-arg "I Am Groot"'

Он также позаботится об экранировании уже имеющихся кавычек

>>> list2cmdline(['-arg', 'I Am "Groot"'])
'-arg "I Am \"Groot\""'

А вот так он может "схлопнуть" в команду JSON

>>> list2cmdline(['--json', json.dumps({'key': 'value'})])
'--json "{\"key\": \"value\"}"'

_______________
Возможно кто-то спросит, а зачем соединять аргументы в строку если subprocess сам это сделает а os.system не наш путь?
Мне как-то потребовалось отправлять команду на удалённое выполнение и в API поддерживалось указание команды только строкой. Так что всякое бывает)

#libs #basic
Для тех кто пропустил релиз Qt6!
PySide6 уже доступен!

Для старта можно взять эти странички:

https://wiki.qt.io/Qt_for_Python
https://doc.qt.io/qtforpython/
https://doc.qt.io/qtforpython/tutorials/index.html

Там же есть ссылка на репозиторий примеров
https://code.qt.io/cgit/pyside/pyside-setup.git/tree/examples

#qt
Как в простом скрипте выполнить отложенное действие или даже несколько?
Например, нам требуется удалить файл через 10 секунд после его создания, но при этом основной поток не должен просто остановиться на 10 секунд с помощью time.sleep().
Допустим, у нас также нет никаких асинхронных очередей задач типа rq или celery.

Самый простой способ это класс threading.Timer.
Это удобный способ выполнить отложенное действие, которое не блокирует основной поток. А так же это пример как писать многопоточный код на базе класса threading.Thread. Всё что делает этот класс, это создаёт отдельный поток, в котором и будет запущена функция ожидания.

from threading import Timer
import os

filename = '/home/user/data.txt'
t = Timer(10, lambda: os.remove(filename))
t.start()

Можно проверить выполнилось ли действие с помощью неблокирующго метода ивента is_set()

if t.finished.is_set():
# do something

или отменить выполнение

t.cancel()

А что будет если процесс интерпретатора завершится ДО завершения таймаута?

Тут у нас два варианта:
🔸 Если поток запущен как Daemon то он завершится вместе с программой, тем самым отменив выполнение колбека.
🔸 В противном случае программа не завершится пока поток не завершится.

По умолчанию режим Daemon отключен, то есть программа в любом случае дождётся исполнения колбека и только потом завершится.

Переключение режима следует делать ДО запуска потока!

t = Timer(...)
t.setDaemon(True)
t.start()

#tricks
Всех с Новым 2021🎉

Желаю всем нам чтобы год 2021 был сильно лучше чем 2020❗️

Между тем, ровно год назад был создан этот канал. Так что у нас тут немножко день рождения))) 🎂

Кажется пора подумать о новом контенте для канала 😉

#offtop
Помните пост про абсолютный импорт? Он мне пригодился на днях, когда я объяснял особенности импортов в Python3.
В процессе объяснения собрался небольшой конспект с заметками. Давайте рассмотрим их в следующих постах.
Подразумеваемые неймспейсы или неявные пакеты.

Этот функционал добавлен в Python 3.3
Что он означает?

Ранее, до 3.3 пакетами считались лишь директории, в которых есть файл __init__.py.
Этот файл одновременно являлся свидетельством того, что директория это Python-пакет, и служил "телом" этого пакета. То есть местом, где можно написать код, как это делается внутри модуля. Этот код исполняется в момент импорта пакета, так что его принято называть "код инициализации пакета".

Начиная с версии 3.3 Любая директория считается пакетом и Python будет пытаться использовать любую директорию для импорта.

Конечно, не любую в файловой системе, а только те что находятся в sys.path.

Это значит, что теперь __init__.py нужно делать только если:

🔸 вам требуется создать код инициализации пакета
🔸 нужна совместимость со старыми версиями Python

На мой взгляд это немного упрощает разработку, делает её чище, но с другой стороны убивает некоторую однозначность происходящего.
Например, я создал репозиторий со своей библиотекой и рядом положил код примеров или тестов.

repo_name/
my_library/
__init__.py
main.py
examples/
exam1.py
exam2.py

В этом репозитории пакетом является только my_library, остальные директории это не пакеты, это просто дополнительный код в файлах. Директория examples не добавлена в sys.path, в ней нет рабочих модулей. Но если она лежит рядом с my_library, то Python вполне сможет импортнуть из неё модули, так как посчитает что examples это валидный пакет.

Конечно, пример несколько надуманный. Никто не будет добавлять корень репозитория в sys.path. Но, я думаю, суть ясна. Иногда директория это просто директория а не пакет!

#basic #pep
Первая директория в sys.path

🔸 Когда вы запускаете Python-интерпретатор в интерактивном режиме, в системные пути (sys.path) в самое начало добавляется текущая рабочая директория

>>> for path in sys.path:
... print(f'"{path}"')
""
"/usr/lib/python37.zip"
"/usr/lib/python3.7"
...

Первая строка пустая, что и означает текущую рабочую директорию.

🔸 Если вы запускаете интерпретатор передавая скрипт как аргумент, то история получается иная. На первом месте будет директория в которой располагается скрипт. А текущая рабочая директория игнорируется.

Пишем скрипт с таким содержанием:

# script.py
import sys
for path in sys.path:
print(f'"{path}"')

Запускаем

python3 /home/user/dev/script.py

Получаем

"/home/user/dev"
"/usr/lib/python37.zip"
"/usr/lib/python3.7"
...

🔸 Если вы запускаете скрипт по имени модуля то на первом месте будет домашняя директория текущего юзера

python3 -m script

"/home/user"
"/usr/lib/python37.zip"
"/usr/lib/python3.7"
...

Скрипт должен быть доступен для импорта


На что это влияет?
На видимость модулей для импорта. Если вы ждёте, что, запустив скрипт по пути, сможете импортировать модули из текущей рабочей директории, то вы ошибаетесь. Придётся добавлять путь os.getcwd() в sys.path самостоятельно или заранее объявлять переменную PYTHONPATH.

#basic
Многие из тех кто активно работал с Python2 несколько удивлены, почему в Python3 удобная функция reload() переехала из builtin в imp а потом и в importlib?
Ну было же удобно! А теперь лишний импорт😖

Дело в том, что начиная с Python3.3 функция reload() переписана на Python вместо .
Что это нам даёт?

🔸 Такой код проще поддерживать и развивать

🔸 Python код легче читать, изучать и понимать.
Сравните это ➡️ и это ➡️.

🔸 Как результат пункта 2, проще писать свои расширения импорта. Например, пользовательский импортёр с какой-либо хитрой логикой по аналогии с импортом из zip архивов.

А есть ли у этого решения недостатки? Да, они всегда есть.

🔹 Так как это не builtin функция, её следует импортнуть перед использованием

🔹 Скорость замедлилась примерно на 5%. Очевидно, что это совершенно не критично. К тому же от версии к версии логика импорта будет оптимизироваться и ускоряться.
В самом начале файла importlib/__init__.py мы видим такой импорт:

import _imp  # Just the builtin component, NOT the full Python module

То есть часть функционала по прежнему написана на Си, но достаточно низкоуровневая.

#basic
Вопросы про переменную PYTHONPATH

🔸 Как она определяет пути поиска модулей при импорте?

Пути поиска модулей находятся в списке sys.path. Как формируется этот список?
Исходя из документации мы может выделить 3 основных этапа.

▫️ Путь к запускаемому скрипту или рабочая директория
▫️ Переменная PYTHONPATH
▫️ Стандартные пути к библиотекам

Это значит, что все три этапа выполняются в момент инициализации интерпретатора. Результат заполняет список sys.path. В том числе и пути, указанные в переменной PYTHONPATH.

🔸 Можно ли добавлять новые пути в эту переменную в Python-коде?

Можно, но учитывая, что используется она только во время старта интерпретатора, никакого эффекта это иметь не будет.
Для изменения путей поиска модулей в коде нужно изменять непосредственно список sys.path.

🔸 Можно ли указать много путей для поиска?

Да, с помощью переменной PYTHONPATH можно указать несколько директорий, разделённых символом разделения пути. Для Linux это символ ":", для Windows это ";".
Например:

export PYTHONPATH=/mnt/libs:~/mylibs

#basic #tricks
Мы уже знаем, что на текущую сессию интерпретатора изменение PYTHONPATH никак не повлияет. Но если вы запустите дочерний процесс, то он унаследует окружение текущего процесса, а значит и изменения в любых переменных будут на него влиять.
Вот небольшой пример:

Объявляем переменную

user@host:~$ export PYTHONPATH=/path1

Запускаем интерпретатор

user@host:~$ python3

Проверим что в sys.path

>>> import sys
>>> print(sys.path)
['', '/path1', '/usr/lib/...', ...]

Добавляем что-то в переменную

>>> import os
>>> os.emviron['PYTHONPATH'] = '/path1:/path2'
>>> print(sys.path)
['', '/path1', '/usr/lib/...', ...]

Изменений нет. Но давайте запустим дочерний процесс и посмотрим там

>>> os.system('python3')
# теперь мы находимся в другом процессе
>>> import sys
>>> print(sys.path)
['', '/path1', '/path2', '/usr/lib/...', ...]

Тоже самое будет и с subprocess, так как по умолчанию текущее окружение тоже наследуется.

>>> import subprocess
>>> subprocess.call(['python3', '-c', 'import sys;print(sys.path)'])
['', '/path1', '/path2', '/usr/lib/...', ...]

______________________
Лучшей практикой является передача энвайронмента явно через аргумент env!

import subprocess
subprocess.call(cmd, env={'PYTHONPATH': '...'})

Это поможет точно понимать какое окружение будет у запускаемого процесса и при этом не изменять окружение текущего процесса.

#basic
Релятивный импорт в исполняемом файле

Часто встречается ситуация, когда исполняемый скрипт находится внутри Python-пакета. Например, представим такую структуру библиотеки:

my_lib/
cmd/
start.py
stop.py
core.py
services.py

Для запуска каких-то процессов мне надо исполнить скрипт start.py и вот как я делаю его вызов:

python3 /mnt/libs/my_lib/cmd/start.py

Пока выглядит всё красиво.
Но что, если я внутри этого файла хочу импортировать модуль services.py? При этом я хочу использовать релятивный импорт

# start.py
if __name__ == "__main__":
from .. import services

Я получу такую ошибку:

ValueError: attempted relative import beyond top-level package

Эта ошибка возникает потому, что интерпретатор просто не знает что мы находимся внутри пакета и не может понять куда это мы собрались выйти на уровень выше)

Есть три способа как избежать этой ошибки. Все они требуют чтобы библиотека my_lib находилась в доступном для импорта месте, то есть в моëм случае чтобы путь /mnt/libs был в sys.path.

🔸Просто пишем полный путь импорта

if __name__ == "__main__":
from my_lib import services

Это сработает. Но, очевидно, что это не то, что мы ищем. Нам нужен релятивный импорт.

🔸 Если интерпретатору подсказать имя пакета в котором мы находимся, то всё заведётся. И есть два способа это сделать. Первый способ — это запускать не через имя файла а по имени модуля

python -m my_lib.cmd.start

Уже самой командой мы обозначили все необходимые неймспейсы.

🔸 Если предыдущий способ недоступен (то есть запускаем именно по пути к файлу .../start.py), то объявляем имя пакета прямо внутри кода. Для этого используем переменную модуля __package__

if __name__ == "__main__":
if __package__ is None:
__package__ = 'my_lib.cmd'
from .. import services

Кстати, мы также можем при необходимости:
🔹 динамически определить имя пакета в котором находимся
🔹 добавить необходимые пути к основной библиотеке в sys.path перед импортом
🔹 переместить обновление __package__ в начале скрипта вместе со всеми импортами но обязательно с проверкой is None!

#tricks
Бывает начинающие в процессе обучения создают файлы с именем модуля который они изучают. В результате на тестовых запусках ничего не работает😱

Всё потому, что появилась коллизия имён. Например, изучаете вы модуль datetime, и создаёте с таким именем файл (ну логично же 😄) прямо в рабочей директории.
Потом, при попытке импортировать datetime модуль, из-за приоритета импорта будет импортирован файл из рабочий директории а не оригинальная библиотека. Ведь имя файла это суть имя модуля!

А знаете ли вы, что не все стандартные модули можно так перезаписать? Коллизии имён не подвержены builtin модули. Они всегда стоят на первом месте в приоритете импорта, поэтому их нельзя заменить.

Полный список таких модулей можно посмотреть в списке sys.builtin_module_names.

То есть, вы сломаете весь Python если назовёте свой модуль os или site, но если назовёте time или gc то ничего страшного не случится)))

Тем не менее, никогда не называйте модули уже занятыми именами!!! ⚠️

Я всегда рекомендую всем своим файлам делать именной префикс из 2-3 символов. Например я называю свои проекты так:

pw_project_name
pw_ui_tools.py
pw_something/main.py

Либо под ситуацию

tst_scriptname.py 
(не "test" чтобы не подхватывал pytest)
dbg_script.py
maya_ui.py
hou_menu_tools.py

И искать проще, и коллизий нет.

#tricks #basic
Как получить минимальную информацию о модули не импортируя сам модуль?
Стандартная библиотека pyclbr позволяет это сделать. Она не импортит модуль, а только парсит код и возвращает список имеющихся в модуле классов и функций в виде специальных объектов. Например, вы сможете узнать какие в модуле есть классы, от чего они наследованы и какие у них методы.

Возьмём для примера такой простой модуль

# mymodule.py
class Cls1:
def __init__(self):
pass

def execute(self):
pass

class Cls2(Cls1):
pass

def start():
pass

Запускаем анализ

>>> import pyclbr
>>> mdata = pyclbr.readmodule_ex('mymodule')
# список всего что нашлось
>>> print(mdata)
{'Cls1': <pyclbr.Class object at 0x000001B62F9D4288>,
'Cls2': <pyclbr.Class object at 0x000001B62F9DD908>,
'start': <pyclbr.Function object at 0x000001B62F8A8288>}

# список методов класса (имя метода и строка объявления)
>>> mdata['Cls1'].methods
{'__init__': 3, 'execute': 6}

# получения наследуемых классов
>>> mdata['Cls2'].super
[<pyclbr.Class object at 0x000001B62F9D4288>]
>>> mdata['Cls2'].super[0].name
'Cls1'

#libs #tricks
Как работает функция reload()?

Эта функция нужна для того, чтобы перезагрузить изменившийся код из py-файла без рестарта интерпретатора.
Дело в том, что любой импортированный модуль при повторном импорте не будет перечитывать файл. Функция импорта вернёт уже загруженный в память объект модуля. Чтобы обновить код, нужно либо перезапустить всю программу, либо использовать функцию reload()

from importlib import reload
reload(my_module)

🔸 Функция reload() принимает в качестве аргумента только объект модуля или пакета. Она не может перезагрузить класс или функцию. Только весь файл целиком!

🔸 Перезагрузка пакета перезагрузит только его файл __init__.py, если он есть. Но не вложенные модули.

🔸Она не может перезагрузить ранее не импортированный модуль.

🔸При вызове функция reload() перечитывает и перекомпилирует код в файле, создавая новые объекты. После создания новых объектов перезаписывается ранее созданный неймспейс этого модуля.
Это значит, что если где-то этот модуль импортирован через import и обращение к атрибутам происходит через неймспейс (имя) модуля, то такие атрибуты обновятся.
Если какие-либо объекты из этого модуля импортированы через from то они будут ссылаться на старые объекты.

Напишем простой модуль

# mymodule.py
x = 1

Теперь импортируем модуль и отдельно переменную х из модуля

>>> import mymodule
>>> from mymodule import x
>>> print(mymodule.x)
1
>>> print(x)
1

Не перезапуская интерпретатор вносим изменения в модуль

# mymodule.py
x = 2

Делаем перезагрузку модуля и проверяем х ещё раз

>>> reload(mymodule)
>>> print(mymodule.x)
2
>>> print(x)
1

То же самое будет если присвоить любой объект переменной (даже словарь или список)

Повторный импорт обновляет значение

>>> from mymodule import x
>>> print(x)
2

🔸Созданные инстансы классов не обновятся после перезагрузки модуля. Их придётся пересоздать.

#tricks #basic
В PYTHONPATH или в sys.path можно указать путь к ZIP архиву с Python-модулями и пакетами.
export PYTHONPATH=~/my_py_archive.zip

Всё будет выглядеть так, как если бы архив был директорией.

Можно также указать вложенную директорию внутри архива.

export PYTHONPATH=~/my_py_archive.zip/lib

Следует только помнить, что в таком случае пути к модулям будут не актуальны. То есть переменная __file__ будет вести к файлу внутри архива, но с ним не стоит работать как с обычным файлом.

>>> import main
>>> print(main.__file__)
'/home/user/my_py_archive.zip/main.py'

Если вы в архив поместили какие-то ресурсы, то следует сначала их извлечь во внешние файлы или загрузить в память прямо из архива.

import pkgutil
text = pkgutil.get_data(my_pkg.__name__, 'README.md')

____________________
WHL файлы тоже являются ZIP-архивами. Так что с ними это сработает тоже. Но у них иная задача и лучше так не делать.

#tricks
Вы уже знаете, что можно добавить ZIP архив в PYTHONPATH и использовать его как библиотеку.
У Python есть еще одна интересная стандартная библиотека zipapp. Она поможет создать стендалон (или почти стендалон) приложение с помощью ZIP архива.
На самом деле мы получим всё тот же архив но с некоторыми дополнениями

🔸 У него будет точка входа, то есть функция, которая запускается при старте приложения
🔸 У архива будет расширение PYZ, хотя, это тот же ZIP
🔸 Архив можно сделать исполняемым как простое приложение (только для Linux)

Давайте создадим простое приложение. Можно указать директорию или файл. Удобней всего делать приложение из директории. При этом все файлы внутри указанного пути попадут в архив.
Вот такая структура приложения у нас есть:

myapp/
app.py

А это код приложения

# app.py
def main():
print('START APP')

Создаём ZIP-приложение:

python3 -m zipapp myapp

Скорее всего вы получите ошибку, так как не указана точка входа.

ZipAppError: Archive has no entry point

Чтобы это исправить следует в директории рядом с app.py создать файл __main__.py. Именно он будет выполняться при старте приложения. Либо просто указать флагом --main функцию внутри архива которую надо выполнить, и zipapp сам создаст этот файл с нужными импортами.

python3 -m zipapp myapp --main=app:main

Мы получим файл myapp.pyz который можно запустить с помощью Python

$ python3 ./myapp.pyz
START APP

Чтобы запускать это приложение просто по даблклику без дописывания python, следует сделать файл исполняемым и в начало дописать так называемый shebang lineолько для Linux).
Вы скорее всего видели их в Bash-скриптах. Там написано с помощью какого интерпретатора запускать данный скрипт. В нашем случае надо дописать в начало ZIP-файла такую строку:

#!/usr/bin/env python3

Это можно сделать с помощью флага --python

python3 -m zipapp myapp --main=app:main --python '/usr/bin/env python3'

Теперь в начало архива добавится нужная строка а сам файл станет исполняемым.

А вот так можно добавить эту строку ручками после создания архива

echo '#!/usr/bin/env python3' > myapp2.pyz
cat ./myapp.pyz >> myapp2.pyz
chmod +x myapp2.pyz

Остаётся добавить флаг --compress чтобы сжать архив.

python3 -m zipapp myapp --main=app:main --python '/usr/
bin/env python3' --compress

Можно сказать, что наше элементарное приложение готово😎

Что ещë можно сделать?

▫️Как видите, это не полноценный стендалон. Для запуска приложения всё ещë требуется Python в системе. Чтобы ваше приложение завелось на чистой системе можно добавить в директорию myapp все зависимости приложения, то есть любые нестандартные внешние библиотеки. Для работы приложения потребуется только сам Python.

▫️Можно добавлять любые файлы ресурсов. Для их использования потребуется извлечение этих файлов из архива. Например, если закинуть картинку в корень myapp

myapp/
app.py
image.jpg

то достать её можно так:

import pkgutil
# достаём данные
img_data = pkgutil.get_data('__main__', 'image.jpg')
# сохраняем в файл
open(filename, 'wb').write(img_data)

▫️На самом деле исполняемый файл можно сделать и для Windows, но там всё несколько сложней 😖

#tricks