Всем привет. Ранее мы с вами разбирали универсальные типы в 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
очень мощный инструмент позволяющий в удобной форме добавлять полезную «магию» в фреймворки и библиотеки. Он остается совместим со статической проверкой типов, но при этом не нужно злоупотреблять данным инструментом, чтобы наш код оставался лаконичным, выразительным и удобным для чтения. Помните, что с большой силой приходит большая ответственность.
ссылка на оригинал статьи https://habr.com/ru/articles/822827/
Добавить комментарий