python

Бесполезный REPL. Доклад Яндекса

  • среда, 4 марта 2020 г. в 00:23:52
https://habr.com/ru/company/yandex/blog/490788/
  • Блог компании Яндекс
  • Python
  • GitHub
  • Тестирование веб-сервисов


REPL (read-eval-print loop) бесполезен в Python, даже если это волшебный IPython. Сегодня я предложу одно из возможных решений этой проблемы. В первую очередь доклад и мое расширение TheREPL будет полезны тем, кого интересует более быстрая и эффективная разработка, а также тем, кто пишет stateful-системы.

— Меня зовут Александр, я в Яндексе работаю программистом. Пишем мы в моей команде на Python, на Go пока не перешли. Но в свободное от работы время я, как ни странно, тоже программирую и делаю это на очень динамическом языке — Common Lisp. Он, пожалуй, даже более динамический, чем Python. Его особенность заключается в том, что сам процесс разработки устроен несколько иначе. Он более интерактивный и итеративный, потому что в REPL на Lisp вы можете делать всё: создавать новые и удалять старые модули, добавлять методы, классы и удалять их, переопределять классы и т. д.



В Python с этим все более сложно. В нем есть IPython. Конечно, IPython некоторым образом улучшает REPL, добавляет автодополнение, позволяет использовать разные расширения. Но для итеративной разработки он подходит не очень хорошо. В нем можно загрузить код, немножечко его потестить и всё. А иногда от него хочется большего интерактива, чтобы можно было реально использовать этот REPL в разработке, переключаться между модулями, менять функции и классы внутри них.

У меня такое бывает — запускаешь, например, IPython REPL в продакшен-среде и начинаешь там какие-нибудь команды запускать, что-то исследовать, а потом обнаруживается, что в модуле ошибка, и хочется ее по-быстрому поправить. Но так сделать не получается, потому что нужно собрать новый Docker-образ, выкатить его в продакшен, снова зайти в этот REPL, снова добиться там нужного состояния, снова запустить то, на чем все упало. А в идеале я должен был бы поправить функцию, тут же ее запустить и мгновенно получить результат.

Что можно с этим сделать? Каким образом в IPython можно перезагрузить код? Я пробовал использовать autoreload, и мне не понравилось по нескольким причинам. В первую очередь, он при перезагрузке модуля теряет состояние, которое внутри этого модуля было в глобальных переменных. А там может быть закешированное значение с результатами каких-то функций. Или я мог, например, подгрузить там данные по сети, чтобы потом с ними быстрее работать. То есть autoreload теряет состояние.

Поэтому в качестве эксперимента я сделал свое простое расширение для IPython и назвал его TheREPL.

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

Что же такое TheREPL? Это расширение, которое вы загружаете, после этого в IPython появляется такое понятие как namespace, и вы можете взять и переключиться в любой питоновский модуль, посмотреть, какие там есть переменные, функции и так далее. И что более важно — можно прямо написать def, название функции, переопределить функцию или класс, и он поменяется во всех модулях, куда был импортирован. Но при этом перезагрузки самого модуля не происходит, поэтому состояние сохраняется. Кроме того, TheREPL позволяет избежать еще некоторых артефактов, которые есть в autoreload и на которые мы сейчас посмотрим.



Итак, в autoreload апгрейд кода происходит только при сохранении файла. Но при этом нужно ввести что-то в сам REPL, и только тогда autoreload подхватит эти изменения. Это проблема №1. То есть, если у вас какой-то фоновый процесс в отдельном потоке (например, сервер работает), вы не можете просто так взять и поправить код. Autoreload не применит эти изменения до тех пор, пока вы в IPython REPL что-нибудь не введете.

В случае моего расширения вы прямо в редакторе нажимаете шоткат, и функция, которая находится под курсором, тут же применяется и начинает работать. То есть с помощью TheREPL можно более гранулярно менять код. Кроме того, можно в IPython тоже написать def.



Переключение между модулями, как я сказал, autoreload никаким образом не поддерживает. Вы можете только найти файлик в файловой системе, поменять его и надеяться, что autoreload там все разрулит.



Дальше. Autoreload теряет глобальные переменные, TheREPL сохраняет и позволяет вам продолжать исследовать работу своего приложения, менять его внутренний код и таким образом разрабатывать его быстро.



У autoreload есть еще такая особенность. Он очень хитро применяет изменения в том модуле, который перезагружает. В частности, он делает там очень интересный трюк. Если функция в этом модуле обновилась, то чтобы ее поменять везде, где она была проимпортирована, он с помощью сборщика мусора находит ее и все эти экземпляры функций и внутри них меняет код. Дальше мы посмотрим на примерах, как это происходит. Благодаря этому код функции меняется, даже если она попала в замыкание.

Вы знаете, что такое замыкание? Это очень полезная штука. JavaScript-разработчики постоянно этим пользуются. Вы, скорее всего, тоже, просто никогда не обращали внимания. Но поскольку autoreload делает то, что я описал выше, вы можете оказаться в ситуации, когда старый код использует новый код, который может работать по-другому. Например, функция может возвращать не одно значение, а два, tuple вместо string и т. д. Старый код на этом сломается.

TheREPL такой хитрой штуки не делает специально для того, чтобы всё было более консистентно. То есть он меняет функцию или класс в модуле, в котором она определена. Находит этот класс, во всех остальных модулях, и там тоже меняет. После этого все работает уже по-новому.



Как работает замена функции, которую делает autoreload? У нас есть две функции, one и two. В каждой функции есть набор атрибутов: документация, код, аргументы и т.д. Здесь на слайде показан пример замены атрибутов, в которых хранится байт-код.

После того, как autoreload его поменяет, вызванная функция начинает работать уже по-другому. Но это синтетический пример, который я просто воспроизвел руками, чтобы вы понимали, что происходит. Функция называется одним образом, но код там на самом деле другой. И если дизассемблировать, там тоже видно, что она возвращает двойку. К чему это приводит?



Вот пример замыкания. На второй строчке мы создаем замыкание, в которое захватываем функцию foo. Само замыкание ожидает, что эта функция, которую мы передали, возвращает строчку, она ее кодирует в utf-8 и все работает.



Но предположим, вы поменяете модуль, в котором foo определена, и autoreload подхватит изменение. А поменяете вы ее так, чтобы она возвращала не строку, а число. Тогда замыкание уже будет работать неправильно, потому что функция в нем поменялась внутри, а замыкание этого не ожидает, оно же не изменилось. И такие проблемы с autoreload могут «выстреливать» в неожиданных местах.



Как autoreload обновляет классы? Очень просто. Он все методы класса обновляет точно так же, как функции, и еще он у всех инстансов обновляет атрибут __class__, чтобы резолюция методов (определение того, какой метод нужно вызвать), начала работать по-новому.

В TheREPL все чуточку сложнее, потому что когда вы обновляете _class_, может оказаться, что у него есть какие-то потомки, дочерние классы, которые тоже нужно обновить, поскольку в списке базовых классов что-то поменялось.

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



Вот хороший пример. Есть два модуля — a и b. В модуле a определен родительский класс, в модуле b дочерний, и мы создаем инстанс дочернего класса. И на строчке 10 видно, что да, это инстанс класса Foo, родительского.



Дальше мы просто берем и меняем модуль a. Например, добавляем в класс Foo документацию. Затем autoreload подхватывает эти изменения. Как вы думаете, что он в этом случае вернет из Bar?



А он возвращает false, потому что autoreload поменял класс Foo, и теперь это совсем другой класс, не тот, от которого унаследован класс Bar.



И сюрприз! В двух модулях a и b класс Foo — это разный класс, и Bar наследуется от какого-то одного из них. Из-за таких косяков очень сложно предсказать, как будет работать ваш код после того, как autoreload в нем что-то поправит.



Примерно так он обновляет классы. Я прокомментирую картинку. Изначально класс Foo импортируется в модуль b и так там и остается. При замене autoreload релодит этот модуль a, и там появляется уже новый класс, а в модуле b он не обновляется.



TheREPL делает немного по-другому. Он инжектит измененный класс внутрь каждого модуля, куда он был проимпортирован. Поэтому там все работает корректно. Более того, если в классе были объекты, они сохранятся.



А вот так TheREPL решает проблему с дочерними классами. То есть когда поменялся родительский класс, он определяет список базовых классов через магический атрибут mro (method resolution order). Этот атрибут содержит список классов в том порядке, в котором нужно искать в них методы или атрибуты. И каждый раз, когда вы вызываете у своего объекта, например, метод get_name, Python пойдет сначала проверит его в классе Bar, потом в классе Foo, потом в классе object, если не найдет. Он действует согласно процедуре method resolution order.

TheREPL использует эту фишку. Он берет список базовых классов, меняет там тот класс, который вы только что поменяли на новый. Создает новый дочерний тип, это второй шаг. С помощью функции type можно, на самом деле, создавать классы. Если вы никогда не пользовались — попробуйте, это весело.

Вы просто говорите имя класса, говорите, какой у него базовый класс. В простейшем случае, например, object. И — словарик с методами и атрибутами класса. Все, у вас появляется новый класс, который можно инстанциировать, как обычно. TheREPL пользуется этой фишкой. Он генерит дочерний класс и меняет на него указатели во всех объектах старого класса Bar.

У меня еще заготовлена демка, давайте взглянем, как это работает. Для начала посмотрим на такой простой штуке.

Первое демо
Я говорил, что можно менять код внутри модуля. Предположим, у нас есть сервер. Я его сейчас запущу. В какой-то момент мы обнаруживаем, что он почему-то создает временные директории. Или начал создавать, а до этого не создавал. Тогда мы можем подключиться к этому серверу и, догадываясь, что он, наверное, создает эти директории с помощью функции mkdtemp из модуля файл, можно перейти прямо в этот питоновский модуль.

Смотрите — в углу поменялось название текущего модуля. Теперь тут написано tempfile. И я могу посмотреть, какие там есть функции. Мы их видим, и мы их можем, что важно, переопределить. У меня заготовлена специальная оберточка, которая позволяет задекорировать любую функцию так, чтобы при всех ее вызовах был виден трейс, откуда она вызывается. Сейчас мы их проимпортируем и применим.

То есть я оборачиваю стандартную питоновскую функцию, не имея даже доступа к исходникам этого модуля. Я могу взять и и обернуть ее. И при следующем выводе мы увидим Traceback и обнаружим, откуда она вызывается.

Точно так же можно эти изменения откатить обратно, чтобы она нам не спамила. То есть мы видим, что это сервер внутри worker на восьмой строчке вызывает mkdtemp и продолжает плодить нам временные директории, захламляя файловую систему. Это одно применение.

Давайте посмотрим на другой пример того, почему вообще autoreload иногда не очень хорошо работает. У меня заготовлен телеграм-бот:

Второе демо
Сейчас мы активируем autoreload и посмотрим, как он нам поможет. Все, теперь можно запустить бот и с ним пообщаться. Чтобы вам было получше видно, мы начнем с ним диалог. Познакомимся с ботом. Так. Тут какая-то ошибка. Задумывалась совершенно другая ошибка, и я решил в последний момент внести изменения. Но неважно. Сейчас мы ее поправим, autoreload нам в этом поможет.

Мы переключаемся на ботика. И вот это я пока временно закомментирую, раз так. Сохраняю файл. autoreload, по идее, должен был эти изменения подхватить. Стартанем бота снова. Бот меня узнал. Давайте с ним пообщаемся.

Еще одна ошибка. Она уже так и задумана. Идем ее исправлять. Бота я оставлю, он будет работать в фоне, я переключусь на редактор, и в редакторе мы эту ошибку найдем. Тут просто опечатка, и я забыл, что у меня переменная называется user_name. Я сохранил файл. autoreload должен был ее подхватить, и сейчас мы это увидим.

Но autoreload, как я уже упоминал, ничего не знает о том, что файл поменялся, до тех пор, пока вы в него что-то не введете. С таким долгим процессом… Его нужно прервать, запустить заново. Готово. Переходим обратно к нашему боту, пишем ему. Вот, видите, бот забыл, что меня зовут Саша. Почему? autoreload его пересоздал заново, потому что он перезагружает весь модуль полностью. И мне нужно снова писать боту, восстанавливать его состояние.

А если вы отлаживаете какую-то ошибку, которая возникает при определенном состоянии, то состояние терять нельзя, потому что иначе вы опять потратите много времени, чтобы этого состояния добиться. TheREPL как раз в таких случаях выручает.

Давайте посмотрим, как бот будет обновляться в случае использования TheREPL. Lля чистоты эксперимента я перезапущу IPython, и мы повторим все сначала.

А теперь я загружаю TheREPL. Он сразу начинает слушать на определенном порту так, чтобы можно было внутрь него отправлять код. Кстати, это можно делать, даже если у вас IPython работает где-нибудь на сервере, а редактор запущен локально, что тоже может в некоторых случаях вас выручить.

Импортируем бота, стартуем его, снова пишем. Тут понятно — мы Python перестартовывали, поэтому он не помнит, кто я. Проверим, что есть ошибка внутри. Да, ошибка есть. Что ж, давайте ее справим.

Я переключаюсь обратно на редактор, исправляю ошибку. Можем даже не сохранять файл, я жму Ctrl-C, Ctrl-C, это шоткат, по которому Emacs берет текущее описание функции, которая у меня сейчас прямо под курсором, и отправляет его в Python-процесс, к которому подключен. Все, теперь мы можем пройти и проверить, как там наш бот отвечает на мои сообщения. Вот, он помнит, что я Саша, и честно отвечает, что он ничего не умеет.

Попробуем добавить туда прямо новый функционал. Для этого вернемся в редактор. Например, добавим команду help. Пока пускай он отвечает, что ничего не знает про help. Опять жму Ctrl-C, Ctrl-C, код применился. Идем к ботику. Смотрим, понимает ли он эту команду. Да, команда применилась.

Кстати, у него еще есть такая штука, сейчас мы на ней посмотрим, как класс будет меняться. У него есть команда state, специальная отладочная команда, чтобы посмотреть состояние бота. Вот, какой-то Олег приконнектился. Интересно.

Когда бот эту команду выполняет, он вызывает reply, чтобы посмотреть репрезентацию бота. Мы можем пойти и поправить, например, этот reply на что-нибудь еще. Например, сделать так, чтобы там просто имена вводились. Можно сделать так. Идем обратно в наш мессенджер, снова выполняем state. И все. Теперь reply работает по-новому, а объект все тот же, у него сохранилось состояние, так как он помнит про нас всех — про Олега, Сашу, kek и «DROP TABLE Users, Алекс»!

Таким образом, можно писать и отлаживать код прямо на лету, не переключаясь в этот цикл, когда вам нужно собрать пакет, выкатить его куда-то. Можно что-то быстро потестить, поменять все необходимое, а уже потом все эти изменения упаковать как следует и задеплоить.

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

Обязательно нужно сделать плагин для PyCharm. Если найдется доброволец, который мне поможет с Kotlin и плагином для PyCharm, то буду рад пообщаться. Напишите мне на почту или в телеграм.

* * *

Подключайтесь к разработке TheREPL. Там много еще фишек можно придумать. Например, можно придумать способ, как обновлять инстансы классов при их апгрейде, добавлять туда новые атрибуты или апгрейдить их состояние еще как-то. Точно так же мы апгрейдим базы данных. Сейчас этого нет.

Можно придумать hot-reload-код для продакшена, чтобы, когда к вам приезжают новые изменения, не приходилось перезапускать сервера. Можно много еще чего придумать. Это просто идея, и я хочу, чтобы вы ее отсюда унесли. Надо все подстраивать под себя и делать удобным. У меня на этом все.