Проверка типов и проверка значений обрабатываются в 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 вариантов проверки атрибутов класса с помощью встроенных модулей 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__
Добавил от себя ещё один вариант. Почти тоже самое, что и Вариани 1, но проверка происходит в magiс методе __setattr__, что решает проблему изменения атрибутов после создания экземпляра класса:
Вариант 1.5
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
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.
Это решение сравнимо c @property
. Оно лучше работает, когда дескрипторы могут быть повторно использованы в нескольких классах. Например, в классе Employee мы можем просто повторно использовать предыдущие дескрипторы без создания большого количества кода:
Вариант 3
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: marshmallow
Marshmallow - это библиотека Python для сериализации объектов, которая преобразует сложные типы данных в родные типы данных Python и обратно. Чтобы понять, как сериализовать и валидировать объект, пользователю необходимо построить схему, которая определяет правила валидации для каждого атрибута. Несколько вещей, на мой взгляд, делают эту библиотеку мощной:
Предоставляет множество готовых функций валидации, таких как Length, Date, Range, Email и т.д., что экономит разработчикам много времени на самостоятельное создание. Конечно, вы можете создать и свой собственный валидатор.
Поддерживает вложенную схему.
ValidationError из Marshmallow содержит все неудачные валидации, в то время как предыдущие подходы выбрасывали исключение сразу после обнаружения первой ошибки. Эта функция помогает пользователю исправить все ошибки за один раз.
Для демонстрации добавлен дополнительный атрибут birthday и вложенная схема HomeAddressSchema, чтобы показать вам различные возможности.
Вариант 6
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().
Вариант 6 (продолжение)
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.
Вариант 7: Pydantic
Pydantic - это библиотека, похожая на Marshmallow. Она также следует идее создания схемы или модели для объекта и при этом предоставляет множество готовых классов валидации, таких, как PositiveInt, EmailStr и т.д. По сравнению с Marshmallow, Pydantic интегрирует правила валидации в класс объекта, а не создает отдельный класс схемы.
Вот как мы можем достичь той же цели с помощью Pydantic. ValidationError хранит все 3 ошибки, найденные в объекте.
Вариант 7
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 подходах к проверке атрибутов класса. 5 из них основаны на встроенных функциях Python, а 2 - на сторонних библиотеках.
С помощью встроенных функций разработчики могут полностью контролировать каждую деталь валидации. Но это может потребовать больше времени на разработку и поддержку.
С помощью сторонних библиотек разработчики могут избавить себя от разработки общих правил и написать меньше кода. Но в то же время они должны знать, действительно ли эти готовые функции соответствуют их ожиданиям. Например, могут существовать различные мнения о том, как проверять формат электронной почты. Кроме того, как для Marshmallow, так и для Pydantic, они допускают недействительное обновление после инициализации, что может быть опасно в некоторых случаях.
Надеюсь, вам понравилась эта статья.