Это история о том, как потерпеть фиаско, имея хорошо написанный и протестированный в боевых условиях работающий код и даже написанную документацию. Изначально я собирался делать анонс своей библиотеки, но что-то пошло не так. Поэтому начнём за здравие -- постановка и формализация задачи, описание возможностей и батареек. А закончим за упокой -- вопросами, как всё это теперь тестировать?

Логотип systempy

Неудавшийся анонс. SystemPY, в девичестве lifecycle

Начну с формализации проблемы. Современное asyncio приложение состоит из множества компонентов типа Redis, БД, брокера и прочих. Эти компоненты необходимо инициализовать и останавливать в правильном порядке. Также очень хочется из коробки работающий reload. Вишенкой же на торте будет точно так же инициализорованный REPL, а также простота написания одноразовых скриптов для разрешения косяков на проде. Читать статью я рекомендую, глядя одним глазом в документацию. Согласитесь, нет смысла её просто переводить, вместо этого её стоит расширить

Ещё раз и внимательно

Когда точка входа одна, добавление компонентов происходит постепенно и незаметно. Когда появляется вторая точка входа, будь то ещё один микросервис, REPL или скрипт -- вам приходится решать задачу запуска заново. Это особенно смешно в случае скрипта и остро напоминает проблему банана и гориллы -- вы хотели всего лишь банан, но для этого вам пришлось написать код для инициализации гориллы и всех джунглей, а после ещё для остановки

Я с ходу настаиваю на добавлении REPL -- ещё одной точки входа

Анализ проблемы и подхода

Я предлагаю смотреть на приложение как на конструктор компонентов. Идея в том, что каждый компонент может требовать для своего запуска и остановки исполнения некоторого кода. Этот некоторый код хорошо изолируется в собственные классы-миксины. Итоговое приложение является комбинацией этих миксинов. Создание новой точки входа сводится к импорту написанных ранее компонентов наследованию от них нового класса

Этапы жизненного цикла приложения

Я выделил 6 основных этапов жизненного цикла приложения:

  • on_init -- код исполняется единожды только в момент инициализации;

  • pre_startup -- код исполняется до запуска event loop в том числе во время reload;

  • on_startup -- код исполняется сразу после инициализации event loop;

  • on_shutdown -- код исполняется, когда приложение приняло решение остановиться, но event loop ещё работает;

  • post_shutdown -- код исполняется сразу после остановки / очистки event loop. В случае reload следующим этапом будет pre_startup;

  • on_exit -- код исполняется единожды только в момент остановки приложения.

Сценарии работы приложения

Я выделил 3 основных сценария:

  • Ведомое приложение, запускаемое каким-то фреймворком;

  • "Сам себе хозяин" -- демоны, скрипты и REPL;

  • Ведущее приложение или фреймворк, запускающий клиентское приложение.

Кастомные этапы жизненного цикла

Есть необходимость выполнить код прямо перед или прямо после какого-то существующего этапа жизненного цикла? Легко

что они там на самом деле все кастомные. Только тсс

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

@util.register_target
class MyCustomTarget(Target):
    @util.register_hook_before(Target.on_startup)
    @util.register_target_method("forward")
    async def before_on_startup(self) -> None: ...

    @util.register_hook_before(before_on_startup)
    @util.register_target_method("gather")
    async def before_2_on_startup(self) -> None: ...

После от этого интерфейса наследоваться. В результате методы будут выполнены в следующем порядке: before_2_on_startup -> before_on_startup -> on_startup

Естественно, вклинивать этапы можно пока не надоест

Хороший пример кастомных этапов жизненного цикла рассмотрен в документации

Боль, ненависть, REPL

Казалось бы, что может пойти не так, когда батарейки в комплекте? Пройдёмся по болевым точкам и откроем стандартный asyncio REPL:

python3 -m asyncio
asyncio REPL 3.9.2 (default, Feb 28 2021, 17:03:44) 
[GCC 10.2.1 20210110] on linux
Use "await" directly instead of "asyncio.run()".
Type "help", "copyright", "credits" or "license" for more information.
>>> import asyncio
>>> 

Номинально работает, но:

  • меня бесит некорректная обработка Ctrl+C

  • А что с автодополнениями по Tab? Почему не работают?

  • Окей, как теперь инициализировать окружение? Правильно, ручками

Так вот, требования к REPL

Ничего сверхестественного:

  • Корректная обработка Ctrl+C

  • Автодополнения должны работать, если есть readline

  • Окружение должно инициализироваться компонентно и так же останавливаться

  • Добавление собственных глобальных переменных (импортированных модулей) в неймспейс

Всё это тоже работает. Больше примеров в документации. В процессе пришлось через ctypes лезть в недра readline и заниматься знаменитым брутфорс-программированием. Питонячий readline -- это стыдоба

Ну и как я умудрился зайти в тупик?

Согласитесь, тестировать своим продом не совсем правильно. Вопрос необходимо формализовать. С учётом возможностей, простейшая с виду задача превращается в рак мозга. Есть непроверенный функционал -- метод reload, вызов которого должен запустить процесс остановки и последующего перезапуска. Очевидно, он должен вести себя одинаково во всех сценариях, а все нюансы -- половые трудности уже systempy. А различий есть у меня! В REPL вместо штатного reload необходимо выполнять reload_threadsafe -- и это совсем не одно и то же. У меня ломается мозг, как проверить работу метода reload в других сценариях работы

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

А ещё очень неприятно ломается REPL, и в случае ошибок забирает за собой и терминал -- спасает только закрытие и открытие нового

Если ли у хабра идеи? Или воспользоваться запасным сценарием?

Запасной сценарий для тестов
Можно и без тестов