Как внедрить mypy в проекте на Python 2.7

Опубликовано 11 December 2017 в Python

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

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

Стоит сразу предупредить, что большая часть боли связана с особенностями синтаксиса аннотации типов в Python 2.7. В Python 3.x часть проблем отпадет. Еще одна проблема — примеры. Довольно легко найти подходящие куски кода из реального проекта, но они под NDA и довольно громоздки. Маленькие примеры не показывают всю боль.

Маленькие кусочки

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

Лучше разбивать проект на небольшие кусочки. В идеале на те, которые легко поправить за один подход. К тому же это сократит время ожидания: проверка типов не мгновенна. К примеру, для меня нормальный размер - 10–15 ошибок за один подход. Так что я проверяю максимум один средний модуль. Не будет ничего страшного если «нарезать» проверку слишком тонко.

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

Внедрите проверку типов в каждом PR

Проверить типы для пары файлов не составит труда. Для десятка чуть сложнее, но также не смертельно. Редко какие PR бывают больше. Сделать проверку типов для файлов, затронутых в каждом пуллреквесте, не займет много времени. Но сам процесс описания типов в проекте пойдет заметно быстрее.

Приятный бонус от такого внедрения — быстрый поиск проблемных мест. Не всегда понятно где нужно делать проверку типов в первую очередь, какой модуль привести в порядок первым. Ошибки в измененных файлах PR практически всегда располагаются в небольшом количестве модулей.

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

Сначала слабые проверки, потом сильные

Круто, конечно, сразу описать типы максимально подробно. Хочется сделать все сразу без всяких Any. Но вряд ли это реалистично. К тому же может потребоваться рефакторинг. Да, да. Иногда просто невозможно (ну хорошо, практически невозможно) удовлетворить анализатор тем кодом, что есть на текущий момент. Тогда требуется либо переписывать код, либо согласиться, что этот участок не проверяем.

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

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

Сначала CI, потом типы

Еще одна моя ошибка — это внедрение типов до настройки автоматических проверок в CI. mypy на проекте без аннотации типов выдает довольно большое количество ошибок. В основном это «Need type annotation for variable» или присвоение переменной класса значения типа отличного от того, что присвоено ей в базовом типе.

class Foo(object):
    boo = None


class Boo(Foo):
    boo = 'boo'
example01.py:6: error: Incompatible types in assignment
boo = []

foo = {}
example02.py:1: error: Need type annotation for variable
example02.py:3: error: Need type annotation for variable

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

Но вот если сначала описать типы, а потом внедрять CI, то ошибки будут более сложные.

Во-первых, интерфейсы функций «убегают» от описания типов.

В проектах на Python 2.7, где типы добавляются комментариями, такие ошибки возникают постоянно. С этим не так просто бороться, как кажется на первый взгляд. Понять из списка параметров какого типа они должны быть — сложно.

import datetime


def convert_to_timedelta(step):
    # делает что-то и возвращает timedelta


def foo(since, until, step):
    # type: (datetime, datetime) -> Generator[datetime, None, None]
    step = convert_to_timedelta(step)
    while since < until:
        yield since
        since += step
example03.py:8: error: Type signature has too few arguments

Какой тип в этом контексте будет у step? Может это быть timedelta? Возможно. Может это быть int? Вполне. Все зависит от реализации convert_to_timedelta. Типы у этой функции еще не описаны, а у нашей уже есть неполное описание. Восстанавливать такие типы сложно.

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

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

def foo(since, step, until):
    # type: (datetime, datetime) -> Generator[datetime, None, None]
    step = convert_to_timedelta(step)
    while since < until:
        yield since
        since += step

Не обсуждая, почему так произошло - это ведь только пример -, попробуем разобраться может step быть datetime или нет? В принципе, может. Тогда вроде бы (datetime, datetime, datetime) -> datetime - хороший тип для этой функции. После беглого взгляда на функцию возникает сомнение, правильно ли определены типы.

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

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

Не обсуждая, почему так произошло — это ведь только пример — попробуем разобраться может step быть datetime или нет? В принципе, может. Тогда вроде бы (datetime, datetime, datetime) -> datetime - хороший тип для этой функции. После беглого взгляда на функцию возникает сомнение, правильно ли определены типы.

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

def foo(since, until, step):
    # type: (datetime, datetime, int) -> Generator[datetime, None, None]
    step = convert_to_timedelta(step)
    while since < until:
        yield since
        since += step

Потом решили, что int — не самый подходящий вариант для step, лучше подойдет timedelta. Во всех вызовах функции foo мы аккуратно поправили int на timedelta. Строчку с преобразованием из int в timedelta выкинули. Но как обычно это случается, забыли поправить тип у соответствующего параметра:

def foo(since, until, step):
    # type: (datetime, datetime, int) -> Generator[datetime, None, None]
    while since < until:
        yield since
        since += step
example05.py:9: error: Unsupported operand types for + ("datetime" and "int")
example05.py:9: error: Incompatible types in assignment (expression has type "int", variable has type "datetime")

Даже в этом микроскопическом примере намеки mypy на datetime неверны. Правильный вариант будет такой:

def foo(since, until, step):
    # type: (datetime, datetime, timedelta) -> Generator[datetime, None, None]
    while since < until:
        yield since
        since += step

Так что делаем практически по TTD: сначала делаем проверку, добиваемся зеленого цвета, потом добавляем аннотации типов.

Быть готовым отключать проверку типов для кусков кода

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

from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText


def compose_email():
    # type: () -> MIMEMultipart
    msg = MIMEMultipart()
    msg['Subject'] = 'Subject'
    msg['From'] = 'test@domain.com'
    msg['To'] = 'test@domain.com'
    msg.attach(MIMEText('Body'))
    return msg

При проверке в mypy с ключом --py2 выдает следующий набор ошибок:

example06.py:8: error: Unsupported target for indexed assignment
example06.py:9: error: Unsupported target for indexed assignment
example06.py:10: error: Unsupported target for indexed assignment
example06.py:11: error: "MIMEMultipart" has no attribute "attach"

И дело не в неправильном использовании библиотеки. Просто описание типов для этой библиотеки для Python 2 просто не доделана.

При возникновении подобных ошибок выхода два:

  • Засучить рукава и сделать описание типов самостоятельно.
  • Отключить проверку типов для некоторых кусков кода.

Конечно, было бы неплохо внести свою лепту в сообщество и прописать типы. Но для этого может просто не быть времени — надо же кому-то и фичи делать. В этом случае в библиотеке typing есть декораторы @typing.no_type_check и @typing.no_type_check_decorator, которыми можно выключить из проверки функцию, класс или декоратор. Для отельной строчки есть возможность использовать комментарий: # type: ignore.

То, что возвращает bool

Давно вы приводили к bool возвращаемое значение функции, даже если используется она именно в таком контексте? Обычно подобные функции выглядят так:

from typing import Dict, Any


def predicate(elem):
    # type: (Dict[str, Any]) -> bool
    return elem and 'something' in elem

В этом случае, тип ожидаемого возвращаемого значения bool для такой функции вполне подходящий. Он в полной мере определяет то, что мы от этой функции ждем. При этом фактический тип возвращаемого значения другой: Union[Dict[str, Any], bool]. Если внедряются типы, то придется переписывать функцию так:

from typing import Dict, Any


def predicate(elem):
    # type: (Dict[str, Any]) -> bool
    return bool(elem and 'something' in elem)

Хорошо это или плохо — не знаю. Выглядит такой код немного странно. При этом использование функции ограничивается, ее уже не получится использовать и как предикат, и как геттер.

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

---
Возник вопрос? Мне всегда можно написать в Twitter: avkorablev