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