Типизируй все
- четверг, 19 декабря 2019 г. в 00:34:30
Всем привет!
У нас уже есть одна статья про развитие типизации в Ostrovok.ru. В ней объясняется, зачем мы переходим с pyContracts на typeguard, почему переходим именно на typeguard и что в итоге получаем. А сегодня я расскажу подробнее о том, каким образом происходит этот переход.
Объявление функции с pyContracts в общем случае выглядит так:
from contracts import new_contract
import datetime
@new_contract
def User (x):
from models import User
return isinstance(x, User)
@new_contract
def dt_datetime (x):
return isinstance(x, datetime.datetime)
@contract
def func(user_list, amount, dt=None):
"""
:type user_list: list(User)
:type amount: int|float
:type dt: dt_datetime|None
:rtype: bool
"""
…
Это абстрактный пример, потому что я не нашла в нашем проекте определения функции, короткого и содержательного по количеству случаев для проверки типа. Обычно определения для pyContracts хранятся в файлах, не содержащих никакой другой логики. Обратите внимание, что здесь User – это определенный пользовательский класс, и он не импортируется напрямую.
А это желаемый результат с typeguard:
from typechecked import typechecked
from typing import List, Optional, Union
from models import User
import datetime
@typechecked
def func (user_list: List[User], amount: Union[int, float], dt: Optional[datetime.datetime]=None) -> bool:
...
Вообще функций и методов с проверкой типа в проекте так много, что если сложить их в стопку, то можно дотянуться до Луны. Так что переводить их с pyContracts на typeguard вручную просто невозможно (я пробовала!). Поэтому я решила написать скрипт.
Скрипт разделяется на две части: одна кэширует импорты новых контрактов, а вторая занимается рефакторингом кода.
Хочу отметить, что ни тот, ни другой скрипт не претендует на универсальность. Мы не ставили целью написание инструмента для решения всех требуемых случаев. Поэтому я часто опускала автоматическую обработку каких-то частных случаев, если они редко встречаются в проекте — быстрее поправить руками. К примеру, скрипт генерации маппинга контрактов и импортов собрал 90% значений, оставшиеся 10% — это крафтовые маппинги ручной работы.
Логика работы скрипта для генерации маппинга:
Шаг 1. Пройтись по всем файлам проекта, прочитать их. Для каждого файла:
Шаг 2. Вручную обработать ошибки
Теперь, когда у нас есть имена всех типов, которыми пользуется pyContracts (это они и были определены с декоратором new_contract), и есть все необходимые импорты, можно писать код для рефакторинга. Пока я переводила с pyContracts на typeguard вручную, я поняла, что мне нужно от скрипта:
Решать вопрос "а не импортируется ли уже данное имя в данном файле?" я изначально не собиралась: с этой проблемой мы справимся дополнительным прогоном библиотеки isort.
Зато после прогона первой версии скрипта возникли вопросы, которые решать все же пришлось. Оказалось, что 1) ast не всемогущ, 2) astunparse более всемогущ, чем хотелось бы. Это проявлялось в следующем:
Код, пропущенный через ast и astunparse, остается рабочим, но его читаемость снижается.
Самый серьезный недостаток из перечисленных — это исчезающие однострочные комментарии (в других случаях мы ничего не теряем, а только приобретаем — скобочки, например). С этим обещает разобраться библиотека horast, основанная на ast, astunparse и tokenize. Обещает и делает.
Теперь пустые строки. Вариантов решения было два:
Про тройные кавычки у комментариев скажу немного ниже, а с лишними скобками было довольно легко смириться, тем более, что часть из них убирается автоформатированием.
В horast’е мы пользуемся двумя функциями: parse и unparse, но обе неидеальны — parse содержит странные внутренние ошибки, в редких случаях не может распарсить исходный код, а unparse не может записать что-то, что имеет тип type (такой тип, который получится, если сделать type(any_other_type)).
С parse я решила не разбираться, потому что логика работы довольно запутанная, а исключения случаются редко — здесь работает принцип неуниверсальности.
А вот unparse работает предельно ясно и довольно изящно. Функция unparse создает экземпляр класса Unparser, который в init обрабатывает дерево, а потом записывает его в файл. Horast.Unparser последовательно наследуется от многих других Unparser’ов, где самый базовый класс — это astunparse.Unparser. Все классы-наследники просто расширяют функционал базового класса, но логика работы остается такой же, так что рассмотрим astunparse.Unparser. В нем есть пять важных методов:
Все остальные методы — это методы вида _T, например, _Module или _Str. В каждом таком методе может: 1) рекурсивно вызываться dispatch для узлов поддерева или 2) использоваться write для записи содержимого узла или добавления символов и ключевых слов, чтобы результат был валидным выражением на python.
Например, нам попался узел типа arg, в котором ast хранит имя аргумента и узел аннотации. Тогда dispatch вызовет метод _arg, который сначала запишет имя аргумента, потом запишет двоеточие и запустит dispatch для узла аннотации, где будет разбираться поддерево аннотации, и для этого поддерева все так же будут вызываться dispatch и write.
Вернемся к нашей проблеме невозможности обработки типа type. Теперь, когда понятно, как работает unparse, создать свой тип несложно. Создадим некий тип:
class NewType(object):
def __init__ (self, t):
self.s = t.s
Он хранит в себе строку, и не просто так: нам же нужно типизировать аргументы функций, а типы аргументов мы как раз получаем в виде строк из докстринга. Поэтому давайте подменим аннотации аргументов не теми типами, которые нам требуются, а объектом NewType, хранящем внутри только имя нужного типа.
Для этого расширим horast.Unparser – напишем свой UnparserWithType, отнаследовавшись от horast.Unparser, и добавим обработку нашего нового типа.
class UnparserWithType(horast.Unparser):
def _NewType (self, t):
self.write(t.s)
Это сочетается с духом библиотеки. Названия переменных выполнены в стилистике ast, и именно поэтому они состоят из одной буквы, а не потому что я не умею придумывать названия. Думаю, что t – это сокращение от tree, а s – от string. Кстати, NewType – это не строка. Если бы мы хотели, чтобы он интерпретировался как тип строки, то нам надо было бы до и после вызова write записать кавычки.
А теперь магия monkey patch: заменим horast.Unparser нашим UnparserWithType.
Как это теперь работает: у нас есть синтаксическое дерево, в нем есть какая-то функция, в функции — аргументы, у аргументов — аннотации типов, в аннотации типов спрятана игла, а в ней — смерть Кощеева. Раньше узлов аннотаций вообще не было, это мы их создали, причем любой такой узел есть экземпляр NewType. Мы вызываем функцию unparse для нашего дерева, и она для каждого узла вызывает dispatch, которая классифицирует этот узел и вызывает соответствующую ему функцию. Как только функция dispatch получает узел аргумента, она записывает имя аргумента, потом смотрит, есть ли аннотация (раньше это был None, но мы положили туда NewType), если есть, то пишет двоеточие и вызывает dispatch для аннотации, который вызывает наш _NewType, который просто записывает строку, которую хранит — это имя типа. В итоге получаем записанным аргумент: тип.
Вообще-то, это не совсем легально. С точки зрения компилятора, мы записали аннотации аргументов какими-то словами, которые нигде не определены, так что когда unparse завершает свою работу, мы получаем неправильный код: нам нужны импорты. Я просто формирую строку правильного формата и добавляю ее в начало файла, а потом дописываю результат unparse, хотя могла бы добавить импорты и как узлы в синтаксическое дерево, так как ast поддерживает узлы Import и ImportFrom.
Решение проблемы тройных кавычек не сложнее добавления нового типа. Мы создадим класс StrType и метод _StrType. Метод ничем не отличается от использовавшегося ранее для аннотации типов метода _NewType, а вот определение класса изменилось: мы будем хранить не только саму строку, но и уровень табуляции, на котором ее следует записывать. Число отступов определим так: если эта строка встретилась в функции, то один, если в методе, то два, а случаев, когда функция определена в теле другой функции и при этом декорирована, в нашем проекте нет.
class StrType(object):
def __init__ (self, s, indent):
self.s = s
self.indent = indent
def __repr__ (self):
return '"""\n' + self.s + '\n' + ' ' * 4 * self.indent + '"""\n'
В repr определим, как должна выглядеть наша строка. Я думаю, это далеко не единственное решение, но оно работает. Можно было бы поэкспериментировать с astunparse.fill и astunparse.Unparser.indent, тогда это было бы более универсально, но эта идея пришла мне в голову уже во время написания этой статьи.
На этом решенные трудности заканчиваются. После работы моего скрипта иногда возникает проблема циклических импортов, но это вопрос архитектуры. Я не нашла готового стороннего решения, а обрабатывать такие случаи в рамках моего скрипта кажется серьезным усложнением поставленной задачи. Наверное, с помощью ast возможно обнаруживать и разрешать циклические импорты, но эту идею нужно обдумать отдельно. В общем, ничтожно малое количество таких казусов в нашем проекте вполне позволяло мне не обрабатывать их автоматически.
Еще одна сложность, которая мне встретилась — это отсутствие в ast обработки выражений from A import B as C. Внимательный читатель уже знает, что monkey patch — лекарство от всех болезней. Пусть это будет для него домашним заданием, а я решила сделать так: просто добавить такие импорты в файл маппинга, потому что обычно эта конструкция используется для обхода конфликта имен, а у нас их мало.
Несмотря на найденные несовершенства, скрипт делает то, ради чего задумывался. Что в итоге:
Спасибо!
Полезные ссылки: