Проверка(валидация) типов и значений атрибутов выполняются в Python гибким и неявным образом. В Python начиная с Python 3 появился модуль 1typing, который обеспечивает поддержку подсказок типов во время 2выполнения. Но для проверки значений не существует единого способа проверки.
1 Начиная с версии Python 3.9, больше нет необходимости импортировать абстрактные коллекции для описания типов. Теперь вместо, например, typing.Dict[x, y] можно использовать dict[x,y]
2 Этот модуль обеспечивает поддержку подсказок типа во время выполнения, но для этого необходимо разработать\использовать отдельный модуль, например, с использованием декораторов или метаклассов.
Из документации
Этот модуль обеспечивает поддержку подсказок типа во время выполнения. Наиболее фундаментальная поддержка состоит из типов Any, Union, Callable, TypeVar и Generic. Полную спецификацию см. в PEP 484.
Но, PEP 484 – Type Hints Хотя предлагаемый модуль типизации будет содержать некоторые возможности для проверки типов во время выполнения - в частности, функцию get_type_hints() - для функциональности проверки типов во время выполнения необходимо будет разработать\использовать отдельный модуль, например, с использованием декораторов или метаклассов. Следует также подчеркнуть, что Python останется динамически типизированным языком, и у авторов нет желания когда-либо делать подсказки типов обязательными, даже по соглашению.
Один из сценариев, в котором нам нужна проверка значений - это инициализация экземпляра класса. На первом этапе мы хотим убедиться в правильности вводимых атрибутов, например, адрес электронной почты должен иметь правильный формат xxx@xx.com, возраст не должен быть отрицательным, фамилия не должна превышать 20 символов и т.д.
В этой статье я хочу продемонстрировать 7[+2 добавил я, прим.пер] вариантов проверки атрибутов экземпляра класса с помощью встроенных модулей Python или сторонних библиотек. Интересно, какой вариант вы предпочитаете? Если вы знаете другие варианты, пишите в комментариях. Поехали.
Содержание:
Вариант 1: Создание функции валидатора
Мы начнем с самого простого решения: создадим функцию проверки для каждого аргумента. Здесь у нас есть 3 метода для проверки имени, электронной почты и возраста по отдельности. Атрибуты проверяются последовательно, при неудачной проверке сразу возникает исключение ValueError и программа останавливается.
Вариант 1
import re class Citizen: def __init__(self, id, name, email, age): self.id = id self.name = self._is_valid_name(name) self.email = self._is_valid_email(email) self.age = self._is_valid_age(age) def _is_valid_name(self, name): if len(name) > 20: raise ValueError("Name cannot exceed 20 characters.") return name def _is_valid_email(self, email): regex = "^[a-z0-9]+[\._]?[a-z0-9]+[@]\w+[.]\w{2,3}$" if not re.match(regex, email): raise ValueError("It's not an email address.") return email def _is_valid_age(self, age): if age < 0: raise ValueError("Age cannot be negative.") return age xiaoxu = Citizen("id1", "xiaoxu gao", "xiaoxugao@gmail.com", 27) xiaoxu = Citizen("id1", "xiaoxu1234567890123456789", "xiaoxugao@gmail.com", 27) # ValueError: Name cannot exceed 20 characters. xiaoxu = Citizen("id1", "xiaoxu gao", "xiaoxugao@gmail.c", 27) # ValueError: It's not an email address. xiaoxu = Citizen("id1", "xiaoxu gao", "xiaoxugao@gmail.com", -27) # ValueError: Age cannot be negative.
Этот вариант прост, но, с другой стороны, это, вероятно, не самое "Pythonic" решение, которое вы когда-либо видели, а многие люди предпочитают иметь чистый __init__, насколько это возможно. Другая проблема заключается в том, что после инициализации атрибуту может быть присвоено недопустимое значение без возникновения исключения. Например, может произойти следующее:
xiaoxu = Citizen("id1", "xiaoxu gao", "highsmallxu@gmail.com", 27) xiaoxu.email = "xiaoxugao@gmail.c" # This email is not valid, but still accepted by the code
Вариант 1.5: __setattr__
Добавил от себя ещё один вариант. Проверка происходит в magiс методе __setattr__, что решает проблему изменения атрибутов после создания экземпляра класса:
Вариант 1/2
import re class Citizen: def __init__(self, id, name, email, age): self.id = id self.name = name self.email = email self.age = age def _is_valid_name(self, name): return len(name) < 20 def _is_valid_email(self, email): regex = "^[a-z0-9]+[\._]?[a-z0-9]+[@]\w+[.]\w{2,3}$" return re.match(regex, email) def _is_valid_age(self, age): return age > 0 def __setattr__(self, key, value): if key == 'name' and not self._is_valid_name(value): raise ValueError("Name cannot exceed 20 characters.") if key == 'email' and not self._is_valid_email(value): raise ValueError("It's not an email address.") if key == 'age' and not self._is_valid_age(value): raise ValueError("Age cannot be negative.") super().__setattr__(key, value) xiaoxu = Citizen("id1", "xiaoxu gao", "xiaoxugao@gmail.com", 27) xiaoxu = Citizen("id1", "xiaoxu1234567890123456789", "xiaoxugao@gmail.com", 27) # ValueError: Name cannot exceed 20 characters. xiaoxu = Citizen("id1", "xiaoxu gao", "xiaoxugao@gmail.c", 27) # ValueError: It's not an email address. xiaoxu = Citizen("id1", "xiaoxu gao", "xiaoxugao@gmail.com", -27) # ValueError: Age cannot be negative.
Вариант 2: Использование @property
Во втором варианте используется встроенная функция: @property. Она работает как декоратор, который добавляется к атрибуту. Согласно документации Python:
Объект свойства имеет методы getter, setter и deleter, используемые в качестве декораторов, которые создают копию свойства с соответствующей функцией доступа, установленной на декорируемую функцию.
На первый взгляд, он создает больше кода, чем первый вариант, но с другой стороны, снимает ответственность с __init__. Каждый атрибут имеет 2 метода (кроме id), один с @property, другой с setter. При получении атрибута, например citizen.name, вызывается метод с @property. Когда значение атрибута устанавливается во время инициализации или обновления, например citizen.name="xiaoxu", вызывается метод с setter.
Вариант 2
import re class Citizen: def __init__(self, id, name, email, age): self._id = id self.name = name self.email = email self.age = age @property def id(self): return self._id @property def name(self): return self._name @name.setter def name(self, value): if len(value) > 20: raise ValueError("Name cannot exceed 20 characters.") self._name = value @property def email(self): return self._email @email.setter def email(self, value): regex = "^[a-z0-9]+[\._]?[a-z0-9]+[@]\w+[.]\w{2,3}$" if not re.match(regex, value): raise ValueError("It's not an email address.") self._email = value @property def age(self): return self._age @age.setter def age(self, value): if value < 0: raise ValueError("Age cannot be negative.") self._age = value xiaoxu = Citizen("id1", "xiaoxu gao", "xiaoxu.gao@ing.com", 27) xiaoxu = Citizen("id1", "xiaoxu1234567890123456789", "highsmallxu@gmail.com", 27) # ValueError: Name cannot exceed 20 characters. xiaoxu = Citizen("id1", "xiaoxu gao", "highsmallxu@gmail.c", 27) # ValueError: It's not an email address. xiaoxu = Citizen("id1", "xiaoxu gao", "highsmallxu@gmail.com", -27) # ValueError: Age cannot be negative.
Этот вариант переносит логику валидации в метод setter каждого атрибута и, таким образом, сохраняет init чистым. Кроме того, валидация также применяется к каждому обновлению каждого атрибута после инициализации. Таким образом, этот больше не принимается:
xiaoxu = Citizen("id1", "xiaoxu gao", "highsmallxu@gmail.com", 27) xiaoxu.email = "xiaoxugao@gmail.c" # ValueError: It's not an email address.
Атрибут id является исключением, потому что у него нет метода setter. Это связано с тем, что я хочу сообщить клиенту, что этот атрибут не должен обновляться после инициализации. Если вы попытаетесь это сделать, вы получите исключение AttributeError.
Вариант 3: Дескрипторы
Третий вариант использует дескрипторы Python, которые являются мощной, но часто упускаемой из виду возможностью. Возможно, сообщество осознало эту проблему: начиная с версии Python 3.9 в документацию были добавлены примеры использования дескрипторов для проверки атрибутов.
Дескриптор - это объект, определяющий методы __get__(), __set__() или __delete__(). Он изменяет поведение по умолчанию при получении, установке или удалении атрибутов.
Вот пример кода, использующий дескрипторы. Каждый атрибут становится дескриптором, который представляет собой класс с методами __get__ и __set__. Когда значение атрибута устанавливается, например self.name=name, то вызывается __set__. Когда атрибут извлекается, например print(self.name), вызывается __get__.
Вариант 3.1
import re class Name: def __get__(self, obj, objtype=None): return self.value def __set__(self, obj, value): if len(value) > 20: raise ValueError("Name cannot exceed 20 characters.") self.value = value class Email: def __get__(self, obj, objtype=None): return self.value def __set__(self, obj, value): regex = "^[a-z0-9]+[\._]?[a-z0-9]+[@]\w+[.]\w{2,3}$" if not re.match(regex, value): raise ValueError("It's not an email address.") self.value = value class Age: def __get__(self, obj, objtype=None): return self.value def __set__(self, obj, value): if value < 0: raise ValueError("Age cannot be negative.") self.value = value class Citizen: name = Name() email = Email() age = Age() def __init__(self, id, name, email, age): self.id = id self.name = name self.email = email self.age = age xiaoxu = Citizen("id1", "xiaoxu gao", "xiaoxugao@gmail.com", 27) xiaoxu = Citizen("id1", "xiaoxu1234567890123456789", "highsmallxu@gmail.com", 27) # ValueError: Name cannot exceed 20 characters. xiaoxu = Citizen("id1", "xiaoxu gao", "highsmallxu@gmail.c", 27) # ValueError: It's not an email address. xiaoxu = Citizen("id1", "xiaoxu gao", "highsmallxu@gmail.com", -27) # ValueError: Age cannot be negative.
Это решение сравнимо с @property. Оно лучше работает, когда дескрипторы могут быть повторно использованы в нескольких классах. Например, в классе Employee мы можем просто повторно использовать предыдущие дескрипторы без создания большого количества кода:
Вариант 3.2
class Salary: def __get__(self, obj): self.value def __set__(self, obj, value): if value < 1000: raise ValueError("Salary cannot be lower than 1000.") self.value = value class Employee: name = Name() email = Email() age = Age() salary = Salary() def __init__(self, id, name, email, age, salary): self.id = id self.name = name self.email = email self.age = age self.salary = salary xiaoxu = Employee("id1", "xiaoxu gao", "xiaoxugao@gmail.com", 27, 1000) xiaoxu = Employee("id1", "xiaoxu gao", "xiaoxugao@gmail.com", 27, 999) # ValueError: Salary cannot be lower than 1000.
Вариант 4: Сочетание декораторов и дескрипторов
Развитие варианта 3 - объединить декораторы и дескрипторы. Конечный результат выглядит следующим образом, где дескрипторы с необходимыми условиями для валидации инкапсулированы в декораторах:
Вариант 4
# Дескрипторы из Варианта 3 class Name: def __get__(self, obj, objtype=None): return self.value def __set__(self, obj, value): if len(value) > 20: raise ValueError("Name cannot exceed 20 characters.") self.value = value class Email: def __get__(self, obj, objtype=None): return self.value def __set__(self, obj, value): regex = "^[a-z0-9]+[\._]?[a-z0-9]+[@]\w+[.]\w{2,3}$" if not re.match(regex, value): raise ValueError("It's not an email address.") self.value = value class Age: def __get__(self, obj, objtype=None): return self.value def __set__(self, obj, value): if value < 0: raise ValueError("Age cannot be negative.") self.value = value # Декораторы-дескрипторы def email(attr): def decorator(cls): setattr(cls, attr, Email()) return cls return decorator def age(attr): def decorator(cls): setattr(cls, attr, Age()) return cls return decorator def name(attr): def decorator(cls): setattr(cls, attr, Name()) return cls return decorator @email("email") @age("age") @name("name") class Citizen: def __init__(self, id, name, email, age): self.id = id self.name = name self.email = email self.age = age
Эти декораторы могут быть легко расширены. Например, вы можете иметь более общие правила с применением нескольких атрибутов, например @positive_number(attr1,attr2)
Вариант 5: Использование __post_init__ в @dataclass
Другим способом создания класса в Python является использование @dataclass. Dataclass предоставляет декоратор для автоматической генерации метода__init__().
Кроме того, @dataclass также вводит специальный метод __post_init__(), который вызывается из скрытого __init__(). __post_init__ - это место для инициализации поля на основе других полей или включения правил валидации.
Вариант 5
from dataclasses import dataclass import re @dataclass class Citizen: id: str name: str email: str age: int def __post_init__(self): if self.age < 0: raise ValueError("Age cannot be negative.") regex = "^[a-z0-9]+[\._]?[a-z0-9]+[@]\w+[.]\w{2,3}$" if not re.match(regex, self.email): raise ValueError("It's not an email address.") if len(self.name) > 20: raise ValueError("Name cannot exceed 20 characters.") xiaoxu = Citizen("id1", "xiaoxu gao", "xiaoxu.gao@ing.com", 27) xiaoxu = Citizen("id1", "xiaoxu1234567890123456789", "highsmallxu@gmail.com", 27) # ValueError: Name cannot exceed 20 characters. xiaoxu = Citizen("id1", "xiaoxu gao", "highsmallxu@gmail.c", 27) # ValueError: It's not an email address. xiaoxu = Citizen("id1", "xiaoxu gao", "highsmallxu@gmail.com", -27) # ValueError: Age cannot be negative.
Этот вариант имеет тот же эффект, что и вариант 1, но использует стиль @dataclass.
До сих пор мы рассмотрели 5 вариантов, используя только встроенные функции. На мой взгляд, встроенные функции Python уже достаточно мощные, чтобы покрыть то, что нам часто требуется для валидации данных. Но давайте также оглянемся вокруг и посмотрим на некоторые сторонние библиотеки.
Вариант 6: Конвейер функций
[пер.] Добавил ещё один вариант. В данном примере мы создаём класс Pipe с перегрузкой метода __or__. Данный вариант может быть полезен, когда нам нужно применить несколько функций для валидации и/или преобразования(верхний регистр, добавление символов и т.п.). Функции для валидации выделены в отдельный класс Validator.
Метод __or__. был добавлен для поддержки синтаксиса X | Y, как замена typing.Union и также используется для указания, что переменная или функция могут принимать несколько различных типов значений.
import typing int | str == typing.Union[int, str] # True
Класс Pipe:
class Pipe: def __init__(self, value=None): self.value = value def __or__(self, other): if callable(other): return Pipe(other(self.value)) else: raise ValueError("Right operand must be callable")
Метод __or__ вызывается, когда вы используете оператор | между объектом Pipe и каким-либо другим объектом. Этот метод прове��яет, является ли правый операнд (тот, который стоит справа от |) вызываемым объектом (функцией).
Если other (правый операнд) — это функция (то есть callable), то применяет эту функцию к значению, хранящемуся в Pipe (в self.value), и возвращает новый объект Pipe, в котором будет храниться результат вызова функции.
Если other не является функцией, то возникает ошибка ValueError.
Вариант 6: конвейер функций
import re class Validator: @staticmethod def is_valid_name(name: str): if len(name) > 20: raise ValueError("Name cannot exceed 20 characters.") return name @staticmethod def is_valid_email(email: str): regex = "^[a-z0-9]+[\._]?[a-z0-9]+[@]\w+[.]\w{2,3}$" if not re.match(regex, email): raise ValueError("It's not an email address.") return email @staticmethod def is_valid_age(age: int): if age < 0: raise ValueError("Age cannot be negative.") return age @staticmethod def is_restricted_name(name: str): blacklist = ['user', 'admin', 'administrator', 'root'] # это упрощённый пример if name.lower() in blacklist: raise ValueError("This name is restricted") return name class Pipe: def __init__(self, value): self.value = value def __or__(self, other): if callable(other): return Pipe(other(self.value)) else: raise ValueError("Right operand must be callable") class Citizen: def __init__(self, id, name, email, age): self.id = id self.name = Pipe(name) | Validator.is_valid_name | Validator.is_restricted_name self.email = Pipe(email) | Validator.is_valid_email self.age = Pipe(age) | Validator.is_valid_age xiaoxu = Citizen("id1", "xiaoxu gao", "xiaoxugao@gmail.com", 27) # no errors xiaoxu2 = Citizen("id1", "xiaoxu1234567890123456789", "xiaoxugao@gmail.com", 27) # ValueError: Name cannot exceed 20 characters. xiaoxu3 = Citizen("id1", "xiaoxu gao", "xiaoxugao@gmail.c", 27) # ValueError: It's not an email address. xiaoxu4 = Citizen("id1", "xiaoxu gao", "xiaoxugao@gmail.com", -27) # ValueError: Age cannot be negative. root = Citizen("id1", "root", "xiaoxugao@gmail.com", 27) # ValueError: This name is restricted
Вариант 7: marshmallow
Marshmallow - это библиотека Python для сериализации объектов, которая преобразует сложные типы данных в родные типы данных Python и обратно. Чтобы понять, как сериализовать и валидировать объект, пользователю необходимо построить схему, которая определяет правила валидации для каждого атрибута. Несколько вещей, на мой взгляд, делают эту библиотеку мощной:
Предоставляет множество готовых функций валидации, таких, как Length, Date, Range, Email и т.д., что экономит разработчикам много времени на самостоятельное создание. Конечно, вы можете создать и свой собственный валидатор.
Поддерживает вложенную схему.
ValidationError из Marshmallow содержит все неудачные валидации, в то время как предыдущие подходы выбрасывали исключение сразу после обнаружения первой ошибки. Эта функция помогает пользователю исправить все ошибки за один раз.
Для демонстрации добавлен дополнительный атрибут birthday и вложенная схема HomeAddressSchema, чтобы показать вам различные возможности.
Вариант 7
from marshmallow import Schema, fields, validate, ValidationError class HomeAddressSchema(Schema): postcode = fields.Str(validate=validate.Regexp("^\d{4}\s?\w{2}$")) city = fields.Str() country = fields.Str() class CitizenSchema(Schema): id = fields.Str() name = fields.Str(validate=validate.Length(max=20)) birthday = fields.Date() email = fields.Email() age = fields.Integer(validate=validate.Range(min=1)) address = fields.Nested(HomeAddressSchema())
Поверх схемы нам нужно создать настоящий класс Citizen. Я использую @dataclass, чтобы пропустить некоторые коды __init__. Marshmallow требует объект JSON в качестве входных данных, поэтому для решения этой проблемы добавлена функция asdict().
Вариант 7 (продолжение)
from dataclasses import dataclass, asdict @dataclass class Citizen: id: str name: str birthday: str email: str age: int address: object citizen = Citizen( id="1234", name="xiaoxugao", birthday="1990-01-01", email="xiaoxugao@gmail.com", age=1, address={"postcode": "1095AB", "city": "Amsterdam", "country": "NL"}, ) CitizenSchema().load(asdict(citizen)) citizen.name = "xiaoxugao1231234567890-1234567890" citizen.email = "xiaoxugao@gmail.c" CitizenSchema().load(asdict(citizen)) # marshmallow.exceptions.ValidationError: {'email': ['Not a valid email address.'], 'name': ['Longer than maximum length 20.']}
Однако эта библиотека "позволяет" обновлять атрибуты с недопустимым значением после инициализации. Например, в строках 23 и 24 возможно обновить объект citizen с недопустимыми именем и email.
Для получения дополнительной информации обратитесь к документации Marshmallow.
Вариант 8: Pydantic
Pydantic - это библиотека, похожая на Marshmallow. Она также следует идее создания схемы или модели для объекта и при этом предоставляет множество готовых классов валидации, таких, как PositiveInt, EmailStr и т.д. По сравнению с Marshmallow, Pydantic интегрирует правила валидации в класс объекта, а не создает отдельный класс схемы.
Вот как мы можем достичь той же цели с помощью Pydantic. ValidationError хранит все 3 ошибки, найденные в объекте.
Вариант 8
from pydantic import BaseModel, ValidationError, validator, PositiveInt, EmailStr class HomeAddress(BaseModel): postcode: str city: str country: str class Config: anystr_strip_whitespace = True @validator('postcode') def dutch_postcode(cls, v): if not re.match("^\d{4}\s?\w{2}$", v): raise ValueError("must follow regex ^\d{4}\s?\w{2}$") return v class Citizen(BaseModel): id: str name: str birthday: str email: EmailStr age: PositiveInt address: HomeAddress @validator('birthday') def valid_date(cls, v): try: datetime.strptime(v, "%Y-%m-%d") return v except ValueError: raise ValueError("date must be in YYYY-MM-DD format.") try: citizen = Citizen( id="1234", name="xiaoxugao1234567889901234567890", birthday="1990-01-32", email="xiaoxugao@gmail.", age=0, address=HomeAddress( postcode="1095AB", city=" Amsterdam", country="NL" ), ) print(citizen) except ValidationError as e: print(e) # 3 validation errors for Citizen # birthday # date must be in YYYY-MM-DD format. (type=value_error) # email # value is not a valid email address (type=value_error.email) # age # ensure this value is greater than 0 (type=value_error.number.not_gt; limit_value=0)
Я лично предпочитаю иметь только один класс со всеми правилами валидации из-за его ясности.
На самом деле, Pydantic может делать гораздо больше, чем это. Он также может экспортировать файл схемы через метод schema_json.
print(Citizen.schema_json(indent=2))
json
{ "title": "Citizen", "type": "object", "properties": { "id": { "title": "Id", "type": "string" }, "name": { "title": "Name", "type": "string" }, "birthday": { "title": "Birthday", "type": "string" }, "email": { "title": "Email", "type": "string", "format": "email" }, "age": { "title": "Age", "exclusiveMinimum": 0, "type": "integer" }, "address": { "$ref": "#/definitions/HomeAddress" } }, "required": [ "id", "name", "birthday", "email", "age", "address" ], "definitions": { "HomeAddress": { "title": "HomeAddress", "type": "object", "properties": { "postcode": { "title": "Postcode", "type": "string" }, "city": { "title": "City", "type": "string" }, "country": { "title": "Country", "type": "string" } }, "required": [ "postcode", "city", "country" ] } } }
Схема совместима с JSON Schema Core, JSON Schema Validation и OpenAPI. Но, как и в Marshmallow, эта библиотека также "разрешает" обновление атрибутов с недопустимым значением после инициализации.
Заключение
В этой статье я рассказала[а я добавил, автор статьи - девушка] о 7[+2 прим.пер.] подходах к проверке(валидации) атрибутов.
С помощью встроенных функций разработчики могут полностью контролировать каждую деталь валидации. Но это может потребовать больше времени на разработку и поддержку.
С помощью сторонних библиотек разработчики могут избавить себя от разработки общих правил и написать меньше кода. Но в то же время они должны знать, действительно ли эти готовые функции соответствуют их ожиданиям. Например, могут существовать различные мнения о том, как проверять формат электронной почты. Кроме того, как для Marshmallow, так и для Pydantic, они допускают недействительное обновление после инициализации, что может быть опасно в некоторых случаях.
Надеюсь, вам понравилась эта статья. Пишите свои варианты и замечания!
Если вы нашли ошибку, пожалуйста, используйте Ctrl+Enter или напишите в лс.
