7 и 1/2 подходов к проверке атрибутов классов в Python

от автора

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

Надеюсь, вам понравилась эта статья. Пишите свои варианты и замечания!


ссылка на оригинал статьи https://habr.com/ru/articles/732388/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *