Использование Annotated в Python

от автора

Всем привет. Ранее мы с вами разбирали универсальные типы в python. Продолжая тему подсказок типов, в данной статье, я расскажу о примерах использования Annotated из модуля typing. Если вы слышите о Annotated в первый раз, то для лучшего понимания, стоит ознакомится с PEP 593 – Flexible function and variable annotations.

Данный инструмент очень полезен, если вы разрабатываете различные фреймворки или библиотеки. И даже если вы занимаетесь написанием прикладного кода, то не будет лишним знать и понимать, что происходит «под капотом» фреймворков и библиотек использующих Annotated.

Теория

Прежде всего Annotated — это декоратор типа, позволяющий указать дополнительные метаданные зависящие от контекста. Метаданными могут являться любые объекты python.

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

from typing import Annotated  x: Annotated[int, 'Метаданные', 'Еще метаданные'] = 10

Для статической проверки типов переменная x является объектом типа int. А метаданные 'Метаданные' и 'Еще метаданные' не учитываются при статической проверке типов и доступны только в ходе выполнения программы.

from typing import Annotated, get_type_hints import sys  x: Annotated[int, 'Метаданные', 'Еще метаданные'] = 10  print(get_type_hints(sys.modules[__name__])) print(get_type_hints(sys.modules[__name__], include_extras=True)) print(get_type_hints(sys.modules[__name__], include_extras=True)['x'].__metadata__)
{'x': <class 'int'>} {'x': typing.Annotated[int, 'Метаданные', 'Еще метаднные']} ('Метаданные', 'Еще метаднные')

Для получения аннотаций с метаданными можно использовать функцию get_type_hints из модуля typing с обязательным указанием аргумента include_extras=True. Сами метаданные хранятся в атрибуте __metadata__.

Практика

Внедрение зависимостей

Для демонстративной реализации внедрения зависимостей нам потребуется две сущности:

  1. Объект, хранящий метаданные о зависимости, которую требуется внедрить.

  2. Декоратор, внедряющий зависимости.

Начнем с объекта, который будет указываться в качестве метаданных.

class Injectable:     def __init__(self, dependecy) -> None:         self.dependecy = dependecy

Далее реализуем декоратор, который позволит внедрять зависимости из объектов типа Injectable.

def inject(func: Callable) -> Callable:     @wraps(func)     def wrapper(*args, **kwargs):                  return func(*args, **kwargs)     return wrapper

Сначала нам нужно получить подсказки типов из аргументов функции переданной в декоратор. Обязательно используем параметр include_extras=True для получения метаданных из Annotated.

Далее пройдемся в цикле по каждому аргументу функции и проверим, является ли подсказка типа Annotated для аргумента в текущей итерации , а также убедимся, что метаданные являются объектом типа Injectable.

def inject(func: Callable) -> Callable:     @wraps(func)     def wrapper(*args, **kwargs):         type_hints = get_type_hints(func, include_extras=True)                  for arg, hint in type_hints.items():             if get_origin(hint) is Annotated and isinstance(hint.__metadata__[0], Injectable):                 ...                  return func(*args, **kwargs)     return wrapper

Обратите внимание, что для проверки подсказки типов на Annotated обязательно нужно использовать функцию get_origin из модуля typing. Также функция get_origin будет полезна при определении подсказок типов Callable, Tuple, Union, Literal, Final, ClassVar.

Осталось совсем немного, нужно пробросить аргументы, для которых не заданы значения и подходящие под условие, а также обернуть зависимости декоратором inject.

def inject(func: Callable) -> Callable:     @wraps(func)     def wrapper(*args, **kwargs):         type_hints = get_type_hints(func, include_extras=True)         injected_kwargs = {}         for arg, hint in type_hints.items():             if get_origin(hint) is Annotated and isinstance(hint.__metadata__[0], Injectable):                 sub_dependecy = inject(hint.__metadata__[0].dependecy)                 if arg not in kwargs:                     injected_kwargs.update({arg: sub_dependecy()})         kwargs.update(injected_kwargs)         return func(*args, **kwargs)     return wrapper

Рассмотрим пример использования приведенной демонстративной реализации внедрения зависимостей.

def get_db_config_file_path():     """ Функция возвращает путь до файла с конфигурацией БД """     return 'db.config'   def get_db_connect_string(         file_path: Annotated[str, Injectable(get_db_config_file_path)]     ) -> str:     """ Функция возвращает строку для соединения с БД """     with open(file_path, 'r') as file:         return file.read()   @inject def execute_query(         query: str,         db_connect_string: Annotated[str, Injectable(get_db_connect_string)]     ):     """ Функция возвращает результат запроса в БД """     with database.connect(db_connect_string) as connect:         return connect.execute(query)  query_result = execute_query('select * from ...')

Благодаря внедрению зависимостей можно вызвать функцию execute_query передав лишь один аргумент query, а аргумент db_connect_string автоматически получит значения из зависимости Injectable(get_db_connect_string). Более того, дополнительная зависимость Injectable(get_db_config_file_path), которая требуется для внедрения зависимости Injectable(get_db_connect_string) тоже внедрится автоматически, даже без указания декоратора @inject для функции get_db_connect_string. Цепочку зависимостей можно выстраивать бесконечно.

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

Валидация данных

Для демонстративной реализации валидации данных с использованием Annotated потребуется реализовать следующие сущности:

  1. Декоратор класса, задача которого заключается в вызове валидаторов при вызове метода __init__ декорируемого класса.

  2. Классы, содержащие логику валидации.

Начнем с простого — реализуем интерфейс валидатора.

from abc import ABC, abstractmethod  class Validatator(ABC):     @abstractmethod     def validate(self, value):         raise NotImplementedError()

В данном случае можно использовать либо ABC, либо Protocol с обязательным декоратором @runtime_checkable, так как внутри декоратора нужно как-то различать какой тип имеют метаданные во время выполнения кода. Если же указать Prtotocol без @runtime_checkable, то во время выполнения мы не сможем узнать тип метаданных с помощью функций isinstance или issubclass.

Сразу реализуем простенький валидатор для валидации телефонных номеров в соответствии с интерфейсом.

class PhoneNumberValidator(Validatator):     def __init__(self, country_code: str | None = None) -> None:         self.country_code = country_code          def validate(self, value):         if not isinstance(value, str):             raise Exception('Phone number must be str')         if not re.match(r'^[\+]?[(]?[0-9]{3}[)]?[-\s\.]?[0-9]{3}[-\s\.]?[0-9]{4,6}$', value):             raise Exception('Wrong phone number format')         if self.country_code and not value.startswith(self.country_code):             raise Exception(f'Only {self.country_code} country code avalible')

PhoneNumberValidator будет выполнять три проверки:

  1. Номер телефона должен быть строкой.

  2. Номер телефона должен соответствовать регулярному выражению ^[\+]?[(]?[0-9]{3}[)]?[-\s\.]?[0-9]{3}[-\s\.]?[0-9]{4,6}$

  3. Если при инициализации валидатора указан определенный региональный код для номера телефона, то проверяем соответствует ли номер телефона этому коду.

Теперь реализуем самую интересную часть — декоратор класса выполняющий валидацию.

def modelclass(cls: type[T]) -> type[T]:          type_hints = get_type_hints(cls, include_extras=True)          return cls

Прежде всего получим подсказки типов при помощи уже известной функции get_type_hints. Далее пройдемся по каждому аргументу в поисках метаданных типа Validatator, если метаданные найдены, вызовем метод validate передав в него значение аргумента.

def modelclass(cls: type[T]) -> type[T]:     type_hints = get_type_hints(cls, include_extras=True)      for arg, hint in type_hints.items(): if get_origin(hint) is Annotated: validators: list[Validatator] = [] for meta in hint.__metadata__: if isinstance(meta, Validatator): validators.append(meta) for validator in validators: validator.validate(kwargs[arg])                          return cls

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

def modelclass(cls: type[T]) -> type[T]:     type_hints = get_type_hints(cls, include_extras=True)     def __init__(self, **kwargs):         for arg, hint in type_hints.items():             if get_origin(hint) is Annotated:                 validators: list[Validatator] = []                 for meta in hint.__metadata__:                     if isinstance(meta, Validatator):                         validators.append(meta)                 for validator in validators:                     validator.validate(kwargs[arg])         for key, arg in kwargs.items():             setattr(self, key, arg)     cls.__init__ = __init__     return cls

Настало время протестировать реализацию. Создадим простую модель, которая имеет один атрибут phone_number и проинициализируем модель различными значениями.

@modelclass class Model:     phone_number: Annotated[str, PhoneNumberValidator('+7')]  model1 = Model(phone_number=112) model2 = Model(phone_number='123') model3 = Model(phone_number='+919367788755') model4 = Model(phone_number='+719367788755') print(model4.phone_number)
Exception: Phone number must be str Exception: Wrong phone number format Exception: Only +7 country code avalible +719367788755

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

Заключение

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


ссылка на оригинал статьи https://habr.com/ru/articles/822827/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *