Лучшие практики использования аннотаций

автор:

Ларри Гастингс

Доступ к дикте аннотаций объекта в Python 3.10 и новее

В Python 3.10 в стандартную библиотеку добавлена новая функция: inspect.get_annotations(). В Python версии 3.10 и новее вызов этой функции является лучшей практикой для доступа к дикту аннотаций любого объекта, поддерживающего аннотации. Эта функция также может «разгруппировать» строковые аннотации.

Если по какой-то причине inspect.get_annotations() не подходит для вашего случая использования, вы можете получить доступ к члену данных __annotations__ вручную. В Python 3.10 лучшая практика для этого также изменилась: начиная с Python 3.10, o.__annotations__ гарантированно всегда работает с функциями, классами и модулями Python. Если вы уверены, что исследуемый объект является одним из этих трех специфических объектов, вы можете просто использовать o.__annotations__, чтобы получить дикту аннотаций объекта.

Однако у других типов вызываемых объектов - например, созданных с помощью functools.partial() - атрибут __annotations__ может быть не определен. При обращении к __annotations__ возможно неизвестного объекта лучшей практикой в Python версий 3.10 и новее является вызов getattr() с тремя аргументами, например getattr(o, '__annotations__', None).

До Python 3.10 обращение к __annotations__ в классе, который не содержит аннотаций, но имеет родительский класс с аннотациями, возвращало родительский __annotations__. В Python 3.10 и более новых версиях вместо аннотаций дочернего класса будет использоваться пустой dict.

Доступ к диктофону аннотаций объекта в Python 3.9 и старше

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

Лучшая практика для доступа к дикту аннотаций других объектов - функций, других вызываемых объектов и модулей - такая же, как и лучшая практика для 3.10, если вы не вызываете inspect.get_annotations(): вы должны использовать трехаргументный getattr() для доступа к атрибуту __annotations__ объекта.

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

class Base:
    a: int = 3
    b: str = 'abc'

class Derived(Base):
    pass

print(Derived.__annotations__)

Это позволит вывести дикту аннотаций, начиная с Base, а не с Derived.

Если исследуемый объект является классом (isinstance(o, type)), ваш код должен иметь отдельный путь. В этом случае наилучшая практика опирается на деталь реализации Python 3.9 и ранее: если у класса определены аннотации, они хранятся в словаре __dict__ класса. Поскольку класс может иметь или не иметь определенные аннотации, лучше всего вызвать метод get на дикте класса.

Чтобы свести все это воедино, вот пример кода, который обеспечивает безопасный доступ к атрибуту __annotations__ произвольного объекта в Python 3.9 и ранее:

if isinstance(o, type):
    ann = o.__dict__.get('__annotations__', None)
else:
    ann = getattr(o, '__annotations__', None)

После выполнения этого кода ann должен быть либо словарем, либо None. Перед дальнейшим изучением рекомендуется перепроверить тип ann с помощью isinstance().

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

Ручное удаление строчных аннотаций

В ситуациях, когда некоторые аннотации могут быть «построчными», и вы хотите оценить эти строки, чтобы получить значения Python, которые они представляют, действительно лучше вызвать inspect.get_annotations(), чтобы он сделал эту работу за вас.

Если вы используете Python 3.9 или более раннюю версию, или по каким-то причинам не можете использовать inspect.get_annotations(), вам придется продублировать его логику. Советуем вам изучить реализацию inspect.get_annotations() в текущей версии Python и следовать аналогичному подходу.

В двух словах, если вы хотите оценить строковую аннотацию на произвольном объекте o:

  • Если o является модулем, используйте o.__dict__ в качестве globals при вызове eval().

  • Если o - это класс, используйте sys.modules[o.__module__].__dict__ как globals, а dict(vars(o)) как locals, когда вызываете eval().

  • Если o является обернутой вызываемой функцией, использующей functools.update_wrapper(), functools.wraps() или functools.partial(), итеративно разверните ее, обращаясь к o.__wrapped__ или o.func в зависимости от ситуации, пока не найдете корневую обернутую функцию.

  • Если o является вызываемым объектом (но не классом), используйте o.__globals__ в качестве глобального объекта при вызове eval().

Однако не все строковые значения, используемые в качестве аннотаций, могут быть успешно превращены в значения Python с помощью eval(). Теоретически строковые значения могут содержать любую допустимую строку, и на практике существуют случаи использования подсказок типов, когда требуется аннотировать строковые значения, которые конкретно не могут быть оценены. Например:

  • Типы объединения PEP 604, использующие |, до того, как поддержка этого была добавлена в Python 3.10.

  • Определения, которые не нужны во время выполнения, импортируются только при значении typing.TYPE_CHECKING.

Если eval() попытается оценить такие значения, он потерпит неудачу и вызовет исключение. Поэтому при разработке API библиотеки, работающей с аннотациями, рекомендуется пытаться оценивать строковые значения только по явному запросу вызывающей стороны.

Лучшие практики для __annotations__ В любой версии Python

  • Не следует присваивать член __annotations__ объектам напрямую. Пусть Python сам управляет назначением __annotations__.

  • Если вы присваиваете член __annotations__ объекту напрямую, всегда устанавливайте его на dict.

  • Если вы напрямую обращаетесь к члену __annotations__ объекта, вам следует убедиться, что это словарь, прежде чем пытаться изучить его содержимое.

  • Вам следует избегать модификаций __annotations__ dicts.

  • Не следует удалять атрибут __annotations__ объекта.

__annotations__ Причуды

Во всех версиях Python 3 объекты функций лениво создают дикт с аннотациями, если для этого объекта не определены аннотации. Вы можете удалить атрибут __annotations__ с помощью del fn.__annotations__, но если вы затем обратитесь к fn.__annotations__, объект создаст новый пустой dict, который он будет хранить и возвращать в качестве своих аннотаций. Удаление аннотаций функции до того, как она лениво создаст свою дикту аннотаций, вызовет ошибку AttributeError; использование del fn.__annotations__ дважды подряд гарантированно всегда вызывает ошибку AttributeError.

Все, что написано выше, относится и к объектам классов и модулей в Python 3.10 и более новых версиях.

Во всех версиях Python 3 вы можете установить __annotations__ для объекта функции в значение None. Однако последующий доступ к аннотациям этого объекта с помощью fn.__annotations__ приведет к ленивому созданию пустого словаря, как указано в первом абзаце этого раздела. Это не относится к модулям и классам в любой версии Python; эти объекты позволяют установить __annotations__ в любое значение Python и сохранят любое установленное значение.

Если Python строчит аннотации (используя from __future__ import annotations), а вы указываете строку в качестве аннотации, то она сама будет заключена в кавычки. В результате аннотация будет взята в кавычки дважды. Например:

from __future__ import annotations
def foo(a: "str"): pass

print(foo.__annotations__)

Это выводит {'a': "'str'"}. Это не следует считать «причудой»; она упомянута здесь просто потому, что может вызвать удивление.