- PVSM.RU - https://www.pvsm.ru -
Удобство процесса разработки напрямую влияет на скорость работы и на количество ошибок при написании кода. Что делать, если среда разработки почему-то отказывается использовать автодополнение во всю силу? Правильно, искать обходные пути и изобретать велосипеды.
Язык программирования Python в силу своей динамичности может легко создать ситуацию, когда статический анализатор не может вывести типы и, как следствие, часть проверок отключается. В статье я подробно расскажу о проблеме в разработке системы управления выделенными серверами [1], которую мы сами себе создали, а затем героически решили. В примерах используется интерпретатор Python 3.10, а средой разработки выступает PyCharm 2022.2.4.
Текст будет интересен тем, кто хочет узнать новые стороны Python, и кому любопытно, с какими проблемами можно столкнуться, если использовать все модные фичи языка.
С этой темой я выступил на последнем Python MeetUp [2] в Selectel. Переходите по ссылке, если хотите послушать мой доклад (предупреждаю, что текст подробнее), и ознакомиться с другими. А о новых выступлениях и другом житье-бытье разработчика, я рассказываю в Telegram-канале [3].
Сперва отмечу, что проект, о котором идет речь, развивается в течение нескольких лет и уже набрал тяжелую кодовую базу. Это система управления выделенными серверами [1] 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": Confudus()
})
Книга заклинаний — это обертка, которая хранит ассоциативный массив имя-объект. Основная магия, о которой хочется рассказать, заключается в магическом методе __getattr__. Этот метод возвращает фунцию cast из соответствующего класса заклинания. Таким образом у книги заклинаний появляются методы по имени ключей в словаре.
>>> spells.confundus()
Process finished with exit code 42
В этот момент статический анализатор среды разработки скажет «у меня лапки», ведь возможные имена задаются во времени исполнения (runtime). Отказ статического анализатора приводит к возможным опечаткам в имени заклинания или в порядке аргументов. В общем, придется искать оригинальную реализацию заклинания и постоянно с ней сверяться.
Можно ли как-то научить IDE разбираться в таком специфическом и проекто-зависимом случае? Наверняка.
Первое решение было на поверхности: плагин для среды разработки. PyCharm внутри себя имеет представление практически о всем проекте, так что внедриться в механизм автодополнения и активироваться при некоторых условиях казалось простой идеей. Тем более, что существуют проекты, решающие аналогичные проблемы — например, плагин [4] для библиотеки Pydantic [5].
Решение хорошее, но вот проблемы, которые оно приносит.
Итого имеем, что для одного проекта нужен разработчик, который не только знает Python, но также умеет писать на Java и понимает специфику среды разработки. При этом на выходе получится решение, уникальное для PyCharm, а пользователи vim/emacs останутся без внимания.
Нужно более универсальное решение.
Я поделился с коллегами своими сомнениями и размышлениями. Мне посоветовали посмотреть на функциональность решения под названием mypy. Я не питал особых надежд, ведь, как говорилось ранее, статический анализатор не может разобрать то, что определяется во времени исполнения.
Внезапным открытием для меня стал генератор pyi-файлов, которые можно называть заглушками [7] (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.
В нашей системе есть интерактивный консольный интерфейс, в котором загружены основные компоненты системы. Это значит, что уже написанный код решит множество задач по поиску заклинаний и инициализации книги заклинаний. Вместе с тем это значит, что входные данные — это ссылки на класс или объект класса.
Обозначим список задач, которые нужно решить, чтобы интерфейсные файлы приносили пользу:
Начнем последовательно решать задачу шаг за шагом.
Извлечь импорты из файла просто, но у нас только ссылка на класс или на объект. Поэтому здесь поможет пакет 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():
# Имя поля + тип
Обратите внимание на следующие моменты, которые могут быть критичными.
Отдельного внимания заслуживает обработка методов и функций, потому что это сложнее, чем кажется.
Работа с методами сложна тем, что у нас есть ссылка на метод, а нам хочется сигнатуру, как написал программист. К счастью, в пакете 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
Сложные типы при преобразовании сигнатуры в строку могут превратиться в синтаксически некорректную строку, как произошло со значением по умолчанию. Некоторые типы при этом могут обрасти префиксами пакетов, которые не импортировались в исходном коде, а значит, не попали в импорты файла-заглушки. Закономерный вопрос: что делать?
Решения следующие:
В моем случае я наспех собрал систему разрешения типов, которая разбивала имя типа по точкам и проверяла, есть ли такое в импортах. Не очень качественное решение, но зато быстрое.
Осталось совместить полученные знания и дополнить книгу заклинаний методами непосредственно из реализаций заклинаний. Здесь алгоритм остается тем же:
Остается лишь одна мелочь, которая не дает покоя. Методов заклинаний не существует на самом деле, поэтому нельзя перейти к исходной реализации. Может, можно сделать через docstring? Вот так, например.
class SpellBook:
def confundus(self, exitcode=0):
"""
Сбивает с толку
<ссылка на оригинальный метод в оригинальном классе
какой-нибудь разметкой>
"""
К сожалению, вот уже пять лет как открыт запрос [8] на эту функциональность. Но, как говорится, воз и ныне там.
В результате этого приключения на двадцать минут получился инструмент, который собран из палок и термоклея, но даже в таком виде упрощает жизнь мне и моим коллегам. Более того, я чуть больше познакомился со специфичным в интерпретаторе Python, что, определенно, расширяет кругозор.
Исходного кода, увы, не будет. Получившийся инструмент сильно завязан на особенности нашего проекта, код которого раскрыть я не могу.
Возможно, эти тексты тоже вас заинтересуют:
→ Как подключить платежную систему с Payments к Telegram [9]
→ Как общаться с ChatGPT с помощью голосовых сообщений в Telegram [10]
→ Простая процедурная генерация мира, или Шумы Перлина на Python [11]
Автор: Владимир
Источник [12]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/python/384763
Ссылки в тексте:
[1] выделенными серверами: https://selectel.ru/services/dedicated/?utm_source=habr.com&utm_medium=referral&utm_campaign=dedicated_article_typing_160523_content
[2] Python MeetUp: https://www.youtube.com/live/nk70F5KBMdc?feature=share
[3] Telegram-канале: https://t.me/+VzpLr5pam-MxODEy
[4] плагин: https://plugins.jetbrains.com/plugin/12861-pydantic
[5] Pydantic: https://docs.pydantic.dev/latest/
[6] могут быть включены: https://www.jetbrains.com/help/idea/managing-plugins.html%23required-plugins
[7] заглушками: https://mypy.readthedocs.io/en/stable/stubs.html
[8] запрос: https://youtrack.jetbrains.com/issue/PY-27635
[9] Как подключить платежную систему с Payments к Telegram: https://habr.com/ru/companies/selectel/articles/729856/
[10] Как общаться с ChatGPT с помощью голосовых сообщений в Telegram: https://habr.com/ru/companies/selectel/articles/731692/
[11] Простая процедурная генерация мира, или Шумы Перлина на Python: https://habr.com/ru/companies/selectel/articles/731506/
[12] Источник: https://habr.com/ru/companies/selectel/articles/735178/?utm_source=habrahabr&utm_medium=rss&utm_campaign=735178
Нажмите здесь для печати.