Проверка типов и проверка значений обрабатываются в 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, они допускают недействительное обновление после инициализации, что может быть опасно в некоторых случаях.

Надеюсь, вам понравилась эта статья.

Комментарии (8)