Всем привет. Ранее мы с вами разбирали универсальные типы в 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__.
Практика
Внедрение зависимостей
Для демонстративной реализации внедрения зависимостей нам потребуется две сущности:
Объект, хранящий метаданные о зависимости, которую требуется внедрить.
Декоратор, внедряющий зависимости.
Начнем с объекта, который будет указываться в качестве метаданных.
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 потребуется реализовать следующие сущности:
Декоратор класса, задача которого заключается в вызове валидаторов при вызове метода
__init__декорируемого класса.Классы, содержащие логику валидации.
Начнем с простого - реализуем интерфейс валидатора.
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 будет выполнять три проверки:
Номер телефона должен быть строкой.
Номер телефона должен соответствовать регулярному выражению
^[\+]?[(]?[0-9]{3}[)]?[-\s\.]?[0-9]{3}[-\s\.]?[0-9]{4,6}$Если при инициализации валидатора указан определенный региональный код для номера телефона, то проверяем соответствует ли номер телефона этому коду.
Теперь реализуем самую интересную часть - декоратор класса выполняющий валидацию.
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 очень мощный инструмент позволяющий в удобной форме добавлять полезную "магию" в фреймворки и библиотеки. Он остается совместим со статической проверкой типов, но при этом не нужно злоупотреблять данным инструментом, чтобы наш код оставался лаконичным, выразительным и удобным для чтения. Помните, что с большой силой приходит большая ответственность.
