Как и зачем у нас появился статический анализатор типов для Python - Академия Selectel

Как и зачем у нас появился статический анализатор типов для Python

Владимир Туров
Владимир Туров Разработчик
7 июня 2023

Рассказываем, как у нас появился статический анализатор типов для Python и как мы решали возникающие в процессе проблемы.

Изображение записи

Удобство процесса разработки напрямую влияет на скорость работы и на количество ошибок при написании кода. Что делать, если среда разработки почему-то отказывается использовать автодополнение во всю силу? Правильно, искать обходные пути и изобретать велосипеды.

Язык программирования Python в силу своей динамичности может легко создать ситуацию, когда статический анализатор не может вывести типы и, как следствие, часть проверок отключается. В статье я подробно расскажу о проблеме в разработке системы управления выделенными серверами, которую мы сами себе создали, а затем героически решили. В примерах используется интерпретатор Python 3.10, а средой разработки выступает PyCharm 2022.2.4.

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

С этой темой я выступил на Python MeetUp в Selectel. Переходите по ссылке, если хотите послушать мой доклад (предупреждаю, что текст подробнее), и ознакомиться с другими. 

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


Начнем с простого и более популярного решения, которое вызывает проблемы.

Ленивые вычисления

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

from lazy import lazy
class A:
   a: int = 0
   b: str = ""
class B:
   @lazy
   def lazy(self) -> A:
       return A()
   def normal(self) -> A:
       return A()

Везде есть аннотации типов, так что кажется, что каждый метод класса B возвращает объект класса А. Статическому анализатору на это намекает не только подсказка типов (type hinting), но и конструкция return A(), из которой однозначно выводится тип.

Но PyCharm считает иначе.

Статический анализатор «запинается» об декоратор и выдает подсказки для декоратора, а не для возвращаемого значения. В lazy 1.5 сделали подсказки типов, но PyCharm почему-то не изменил своего мнения. Проблема небольшая, особенно если вы используете Python 3.8 или выше.

class B:
   @cached_property
   def cached(self) -> A:
       return A()

В Python 3.8 появился декоратор cached_property, который аналогичен декоратору lazy, но корректно обрабатывается автодополнением среды разработки.

Это было просто, переходим к более сложным случаям.

Динамические свойства объекта

Здесь требуется небольшое введение, которое объяснит всю глубину проблемы. Наша система использует сценарии — классы, которые описывают решение какой-либо маленькой задачи. Сценарии имеют универсальный интерфейс, могут вызывать друг друга и находятся под контролем Книги Сценариев. Книга Сценариев — это комплексный объект со сложной логикой, поэтому в рамках статьи я приведу более простую аналогию под названием заклинания. Начнем с интерфейса.

class Spell:
    spell_name: str
    spell_description: str
    def cast(self, *args, **kwargs): 
        pass

Интерфейс Spell определяет два поля мета-информации и метод cast, который выполняет некоторую бизнес-логику. Взглянем на реализацию одного заклинания.

class Confundus(Spell):
    spell_name = "confundus"
    spell_description = "Сбивает с толку"
    def cast(self, exitcode=0, **kwargs):
        exit(exitcode + 42)

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

class SpellBook:
    _spells: Dict[str, Type[Spell]]
    def __init__(self, spells: Dict):
        self._spells = spells.copy()
    def __getattr__(self, name):
        if name in self._spells:
            return self._spells[name].cast
        raise NotImplementedError()
# Инициализация книги заклинаний
spells = SpellBook({
    "confundus": Confundus()
})

Книга заклинаний — это обертка, которая хранит ассоциативный массив имя-объект. Основная магия, о которой хочется рассказать, заключается в магическом методе getattr. Этот метод возвращает фунцию cast из соответствующего класса заклинания. Таким образом у книги заклинаний появляются методы по имени ключей в словаре.

>>> spells.confundus()
Process finished with exit code 42

В этот момент статический анализатор среды разработки скажет «у меня лапки», ведь возможные имена задаются во времени исполнения (runtime). Отказ статического анализатора приводит к возможным опечаткам в имени заклинания или в порядке аргументов. В общем, придется искать оригинальную реализацию заклинания и постоянно с ней сверяться.

Можно ли как-то научить IDE разбираться в таком специфическом и проекто-зависимом случае? Наверняка.

Плагин для среды разработки

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

Решение хорошее, но вот проблемы, которые оно приносит.

  • Плагины для IDE не зависят от проекта, хотя могут быть включены только для некоторых проектов.
  • PyCharm построена на базе IntelliJ IDEA, которая, в свою очередь, написана на языке программирования Java. Плагины, конечно, тоже разрабатываются на Java.
  • Система автодополнения в среде разработки — это сложный механизм, в который нельзя модифицировать за пару часов.

Итого имеем, что для одного проекта нужен разработчик, который не только знает Python, но также умеет писать на Java и понимает специфику среды разработки. При этом на выходе получится решение, уникальное для PyCharm, а пользователи vim/emacs останутся без внимания.

Нужно более универсальное решение.

mypy

Я поделился с коллегами своими сомнениями и размышлениями. Мне посоветовали посмотреть на функциональность решения под названием mypy. Я не питал особых надежд, ведь, как говорилось ранее, статический анализатор не может разобрать то, что определяется во времени исполнения.

Внезапным открытием для меня стал генератор pyi-файлов, которые можно называть заглушками (stub). Файлы pyi — это файлы интерфейсов, которые содержат только сигнатуры без реализации. Для автодополнения файлы заглушек имеют более высокий приоритет, чем файлы с исходным кодом. Это значит, что можно типизировать внешние библиотеки без изменения их исходного кода. Если, конечно, в этом есть необходимость.

class A:
    def __init__(self):
        self.a = True 
    @lazy
    def foo(self) -> int:
        self.b = True
        return 2
    @property
    def bar(self) -> str:
        return "2"
    @cached_property
    def baz(self) -> None:
        return None

Напишем простой класс и отдадим его на вход генератору заглушек в mypy — stubgen. Есть мысли, что может пойти не так?

Данный тест проводится на версии 0.991, так что не спешите расстраиваться. Может быть, уже поправили.

Выходной интерфейсный файл.

class A:
    a: bool
    def __init__(self) -> None: ...
    b: bool
    def foo(self) -> int: ...
    @property
    def bar(self) -> str: ...
    def baz(self) -> None: …

Как видно, stubgen умеет находить переменные, которые инициализированы в конструкторе, но вместе с тем совершенно не разбирается в декораторах.

Пройденный исследовательский путь явно говорит, что спасение утопающих — дело рук самих утопающих.

Работаем с трудностями

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

  • решение доступно только в одном проекте и распространяется вместе с проектом,
  • не зависит от среды разработки,
  • требуется знание Python.

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

Обозначим список задач, которые нужно решить, чтобы интерфейсные файлы приносили пользу:

  1. Научиться собирать все импорты из оригинального файла. Если в интерфейсном файле нет импортов сложных типов, то автодополнение не будет работать.
  2. Создать генератор заглушек для классов, так как в заглушке должны быть все методы, которые есть в исходном коде.
  3. Конвертировать все заклинания в методы для книги заклинаний, не забывая про актуальные импорты.

Начнем последовательно решать задачу шаг за шагом.

Получение импортов

Извлечь импорты из файла просто, но у нас только ссылка на класс или на объект. Поэтому здесь поможет пакет inspect и функция getfile, благодаря которой можно найти файл, в котором описан класс.

import inspect
filename = inspect.getfile(cls)

Теперь можем пройтись по файлу и забрать все конструкции для импорта через модуль ast (Abstract Syntax Tree).

import ast
with open(filename, "r") as f:
   root = ast.parse(f.read(), filename)
for node in ast.iter_child_nodes(root):
   if not hasattr(node, "names"):
       continue
   for n in node.names:
       if isinstance(node, ast.Import):
           import_str = f"import {n.name}"
       elif isinstance(node, ast.ImportFrom):
           import_str = f"from {node.module} import {n.name}"
       else:
           continue
       if n.asname:
           import_str += f" as {n.asname}"

Представленная реализация весьма наивна, так как учитывает только импорты в глобальной области видимости. Импорты за условием if typing.TYPE_CHECKING и внутри функций учитываться не будут. Однако для нашего проекта это приемлемо.

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

Импорты есть, теперь обрабатываем классы.

Основной цикл парсера

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

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

class SomeClass:
   # В магическом словаре __annotations__
   annotated: str
   # Член класса
   initialized = 1
   # Член класса + в магическом словаре
   both: bool = True
   # Метод класса
   def method(self) -> int:
       # Такое игнорируем
       self.ignored = "=("
       return 42

Методы можно поймать с помощью условия callable(method), известные декораторы — явной проверкой на тип. Сложнее всего с переменными. Переменные могут быть иметь или аннотацию, или значение по умолчанию, а могут и то, и другое. Аннотированные переменные не являются полем класса, поэтому их нужно искать в магическом словаре annotations, который существует только если есть хотя бы одна переменная с аннотацией.

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

# Обработка полей
for member_name in dir(cls):
   member = getattr(cls, member_name)
   if isinstance(member, property):
       # Обработка декораторов, в данном случае
       # @property
   elif callable(member):
       # Обработка методов
   elif not callable(member) and member_name not in cls.__annotations__:
       # Обработка инициализированных переменных
       # без аннотаций. Автовыведение типа через
       # type(member).__name__
# Обработка аннотаций
if hasattr(cls, "__annotations__"):
   for name, t in cls.__annotations__.items():
       # Имя поля + тип

Обратите внимание на следующие моменты, которые могут быть критичными.

  • Функция dir() перебирает все члены класса, в том числе магические методы. Возможно, вам захочется отдельно прописать черный список методов, которые вы не хотите видеть в заглушках.
  • В примере есть проверка на декоратор property, но исключительно в виде демонстрации. Чтобы узнать тип, спрятанный за декоратором, необходимо исследовать исходный код декоратора.
  • Все, для чего нет отдельной ветки, трактуется как переменная, и тип выводится через функцию type(). Это может стать проблемой, когда в проекте появится новый декоратор.

Отдельного внимания заслуживает обработка методов и функций, потому что это сложнее, чем кажется.

Как работать с методами

Работа с методами сложна тем, что у нас есть ссылка на метод, а нам хочется сигнатуру, как написал программист. К счастью, в пакете inspect найдется функция и на этот случай: signature(). Функция возвращает объект класса Signature, который очень красиво преобразуется к строке.

>>> from typing import List
>>> def foo(a, b: str = None) -> List: 
...     pass
>>> signature = inspect.signature(foo)
>>> str(signature)
(a, b: str = None) -> List

Красиво. На радостях от простого решения забыли протестировать на более сложных типах? Фатальная ошибка.

>>> import enum
>>> class SomeEnum(enum.Enum):
...   default = "default"
...   magic = "magic"
...
>>> from typing import List
>>> def foo(a, b: SomeEnum = SomeEnum.magic) -> List: 
...     pass
...
>>> signature = inspect.signature(foo)
>>> str(signature)
(a, b: __main__.SomeEnum = <SomeEnum.magic: 'magic'>) -> List
>>> signature.return_annotation
typing.List

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

Решения следующие:

  • «Прибить» в заглушки импорты корневых пакетов вашего приложения. Например, для кода выше сделать что-то вроде import __main__. Однако это не поможет против перечислений.
  • Написать функцию разрешения типов, которая по доступным импортам и строковому представлению будет гадать, какое именно описание типа правильное.

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

Осталось совместить полученные знания и дополнить книгу заклинаний методами непосредственно из реализаций заклинаний. Здесь алгоритм остается тем же:

  1. Получаем файл, в котором реализован класс заклинания.
  2. Забираем импорты.
  3. Получаем сигнатуру функции cast.
  4. Дописываем в класс SpellBook функцию с именем из spell_name и сигнатурой из пункта 3, а вместо pass добавляем комментарий из spell_description.

Остается лишь одна мелочь, которая не дает покоя. Методов заклинаний не существует на самом деле, поэтому нельзя перейти к исходной реализации. Может, можно сделать через docstring? Вот так, например.

class SpellBook:
    def confundus(self, exitcode=0):
        """ 
        Сбивает с толку
        <ссылка на оригинальный метод в оригинальном классе
        какой-нибудь разметкой>
        """

К сожалению, вот уже пять лет как открыт запрос на эту функциональность. Но, как говорится, воз и ныне там.

Заключение

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

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