Всем привет. Ранее мы с вами разбирали универсальные типы в 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 очень мощный инструмент позволяющий в удобной форме добавлять полезную "магию" в фреймворки и библиотеки. Он остается совместим со статической проверкой типов, но при этом не нужно злоупотреблять данным инструментом, чтобы наш код оставался лаконичным, выразительным и удобным для чтения. Помните, что с большой силой приходит большая ответственность.

Комментарии (5)


  1. avkritsky
    20.06.2024 03:27

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


    1. amigo2208 Автор
      20.06.2024 03:27

      В тесте достаточно явно передать зависимость. Например, если мы тестируем функцию execute_query из примера, можно передать ей аргумент db_connect_string напрямую:

      execute_query(
        'select * from ...', 
        db_connect_string='postgresql://user:pass@localhost'
      )


      1. avkritsky
        20.06.2024 03:27

        Точно, спасибо! Пробовал передать зависимость вручную, но передавал не именованным аргументом - поэтому не получилось.

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


        1. amigo2208 Автор
          20.06.2024 03:27

          В данное реализации - да, но это не сложно доработать. Для демонстрации постарался написать как можно компактнее, чтобы передавался основной смысл.


  1. Dirlandets
    20.06.2024 03:27
    +1

    Спасибо за статью! На редкость хороший и не перегруженный материал!