Python: проверка правильности ввода при изменении класса данных

В Python 3.7 есть эти новые контейнеры "dataclass", которые в основном похожи на изменяемые именованные кортежи. Предположим, я создаю класс данных, предназначенный для представления человека. Я могу добавить подтверждение ввода через __post_init__() функционировать так:

@dataclass
class Person:
    name: str
    age: float

    def __post_init__(self):
        if type(self.name) is not str:
            raise TypeError("Field 'name' must be of type 'str'.")
        self.age = float(self.age)
        if self.age < 0:
            raise ValueError("Field 'age' cannot be negative.")

Это даст хороший вклад через:

someone = Person(name="John Doe", age=30)
print(someone)

Person(name='John Doe', age=30.0)

В то время как все эти неверные данные приведут к ошибке:

someone = Person(name=["John Doe"], age=30)
someone = Person(name="John Doe", age="thirty")
someone = Person(name="John Doe", age=-30)

Однако, поскольку классы данных являются изменяемыми, я могу сделать это:

someone = Person(name="John Doe", age=30)
someone.age = -30
print(someone)

Person(name='John Doe', age=-30)

Таким образом, минуя входные проверки.

Итак, как лучше всего убедиться, что поля класса данных не видоизменены во что-то плохое после инициализации?

5 ответов

Решение

Классы данных - это механизм, обеспечивающий инициализацию по умолчанию для принятия атрибутов в качестве параметров, и хорошее представление, а также некоторые тонкости, такие как __post_init__ крюк.

К счастью, они не связываются с каким-либо другим механизмом доступа к атрибутам в Python - и вы все равно можете создавать свои атрибуты dataclassess как property дескрипторы или пользовательский класс дескриптора, если хотите. Таким образом, любой доступ к атрибутам будет проходить через функции получения и установки автоматически.

Единственный недостаток использования по умолчанию property встроенным является то, что вы должны использовать его "по-старому", а не с синтаксисом декоратора - это позволяет вам создавать аннотации для ваших атрибутов.

Итак, "дескрипторы" - это специальные объекты, назначенные атрибутам класса в Python таким образом, что любой доступ к этому атрибуту будет вызывать дескрипторы __get__, __set__ или же __del__ методы. property встроенный является условием для создания дескриптора, переданного от 1 до 3 функций, которые будут вызываться из этих методов.

Итак, без пользовательского дескриптора, вы можете сделать:

@dataclass
class MyClass:
   def setname(self, value):
       if not isinstance(value, str):
           raise TypeError(...)
       self.__dict__["name"] = value
   def getname(self):
       return self.__dict__.get(name)
   name: str = property(getname, setname)
   # optionally, you can delete the getter and setter from the class body:
   del setname, getname

Используя этот подход, вы должны будете написать доступ к каждому атрибуту как два метода / функции, но вам больше не нужно будет писать __post_init__: каждый атрибут будет проверен сам.

Также обратите внимание, что в этом примере использовался небольшой обычный подход для обычного хранения атрибутов в __dict__, В примерах в Интернете практика заключается в использовании обычного доступа к атрибутам, но с добавлением имени перед _, Это оставит эти атрибуты загрязняющими dir на вашем последнем экземпляре, и личные атрибуты не будут охраняться.

Другой подход - написать собственный класс дескриптора и позволить ему проверять экземпляр и другие свойства атрибутов, которые вы хотите защитить. Это может быть настолько изощренным, насколько вы хотите, достигая кульминации с вашей собственной структурой. Таким образом, для класса дескриптора, который будет проверять тип атрибута и принимать список валидаторов, вам потребуется:

def positive_validator(name, value):
    if value <= 0:
        raise ValueError(f"values for {name!r}  have to be positive")

class MyAttr:
     def __init__(self, type, validators=()):
          self.type = type
          self.validators = validators

     def __set_name__(self, owner, name):
          self.name = name

     def __get__(self, instance, owner):
          if not instance: return self
          return instance.__dict__[self.name]

     def __delete__(self, instance):
          del instance.__dict__[self.name]

     def __set__(self, instance, value):
          if not isinstance(value, self.type):
                raise TypeError(f"{self.name!r} values must be of type {self.type!r}")
          for validator in self.validators:
               validator(self.name, value)
          instance.__dict__[self.name] = value

#And now

@dataclass
class Person:
    name: str = MyAttr(str)
    age: float = MyAttr((int, float), [positive_validator,])

Вот и все - создание собственного дескрипторного класса требует немного больше знаний о Python, но приведенный выше код должен быть полезен даже в работе - вы можете его использовать.

Обратите внимание, что вы можете легко добавить множество других проверок и преобразований для каждого из ваших атрибутов - и код в __set_name__ Сам может быть изменен, чтобы самоанализ __annotations__ в owner класс, чтобы автоматически принять к сведению типы - так, чтобы параметр типа не был необходим для MyAttr сам класс. Но, как я уже говорил, вы можете сделать это настолько изощренным, насколько захотите.

Простым и гибким решением может быть переопределение__setattr__ метод:

@dataclass
class Person:
    name: str
    age: float

    def __setattr__(self, name, value):
        if name == 'age':
            assert value > 0, f"value of {name} can't be negative: {value}"
        self.__dict__[name] = value

Возможно, заблокируйте атрибут, используя методы получения и установки, вместо того, чтобы изменять атрибут напрямую. Если вы затем извлечете свою логику проверки в отдельный метод, вы можете проверить одинаково как с вашего установщика, так и с __post_init__ функция.

Ответ , предоставленный @jsbueno , user108205 , но он не допускает аргументов по умолчанию. Я расширил его, чтобы разрешить значения по умолчанию:

      def positive_validator(name, value):
    if value <= 0:
        raise ValueError(f"values for {name!r}  have to be positive")

class MyAttr:
    def __init__(self, typ, validators=(), default=None):
        if not isinstance(typ, type):
            if isinstance(typ, tuple) and all([isinstance(t,type) for t in typ]):
                pass
            else:
                raise TypeError(f"'typ' must be a {type(type)!r} or {type(tuple())!r}` of {type(type)!r}")
        else:
            typ=(typ,)
        self.type = typ
        self.name = f"MyAttr_{self.type!r}"
        self.validators = validators
        self.default=default
        if self.default is not None or type(None) in typ:
            self.__validate__(self.default)
        
    def __set_name__(self, owner, name):
        self.name = name
    
    def __get__(self, instance, owner):
        if not instance: return self
        return instance.__dict__[self.name]

    def __delete__(self, instance):
        del instance.__dict__[self.name]
        
    def __validate__(self, value):
        for validator in self.validators:
            validator(self.name, value)
            
    def __set__(self, instance, value):
        if value == self:
            value = self.default
        if not isinstance(value, self.type):
            raise TypeError(f"{self.name!r} values must be of type {self.type!r}")

        instance.__dict__[self.name] = value
        


#And now

@dataclass
class Person:
    name: str = MyAttr(str,[]) # required attribute, must be a str, cannot be none
    age: float = MyAttr((int, float), [positive_validator,],2) # optional attribute, must be an int >0, defaults to 2
    posessions: Union[list, type(None)] = MyAttr((list, type(None)),[]) # optional attribute in which None is default

Это мое улучшение для дескрипторов, вы можете определить наши собственные функции

      from dataclasses import dataclass
import re
from enum import Enum


class Descriptor:
    def __init__(self, type, validators=(), **kwargs):
        self.type = type
        self.default = kwargs.get("default")
        self.validators = validators
        self.kwargs = kwargs

    def __set_name__(self, owner, name):
        self.name = name

    def __get__(self, instance, owner):
        if not instance:
            return self
        # return instance.__dict__[self.name]
        return instance.__dict__.get(self.name, self.default)

    def __delete__(self, instance):
        del instance.__dict__[self.name]

    def __set__(self, instance, value):
        if isinstance(value, Descriptor):
            value = self.default
        else:
            if not isinstance(value, self.type):
                raise TypeError(
                    f"{self.name!r} VALUES MUST BE TYPE {self.type!r} NOT {type(value)}!"
                )
            switch = {
                "POSITIVE": self.val_positive,
                "BETWEEN": self.val_between_values,
                "MAXMINZISE": self.val_between_sizes,
                "SIZE": self.val_size,
                "EMAIL": self.val_email,
                "NUMBER": self.val_number,
                "ONEOF": self.val_one_of,
            }
            for validator in self.validators:
                if validator in switch:
                    switch[validator](value)
        instance.__dict__[self.name] = value

    def val_positive(self, value: int | float):
        if value <= 0:
            raise ValueError(f"VALUE IS NOT VALID FOR {self.name!r} MUST BE POSITIVE")

    def val_between_values(self, value: int | float):
        maxval = self.kwargs.get("maxval")
        if maxval is not None:
            if not isinstance(maxval, int) or isinstance(maxval, float):
                raise TypeError(
                    f"MAX VALUE MUST BE TYPE INTEGER OR FLOAT NOT {type(value)}!"
                )
            if value > maxval:
                raise ValueError(
                    f"VALUE IS NOT VALID FOR {self.name!r} MUST BE GREATER THAN {maxval}"
                )
        minval = self.kwargs.get("minval")
        if minval is not None:
            if not isinstance(minval, int) or isinstance(maxval, float):
                raise TypeError(
                    f"MIN VALUE MUST BE TYPE INTEGER OR FLOAT NOT {type(value)}!"
                )
            if value < minval:
                raise ValueError(
                    f"VALUE IS NOT VALID FOR {self.name!r} MUST BE LESS THAN {minval}"
                )

    def val_between_sizes(self, value: int):
        maxsize = self.kwargs.get("maxsize")
        if maxsize is not None:
            if not isinstance(maxsize, int):
                raise TypeError(f"MAX VALUE MUST BE TYPE INTEGER NOT {type(value)}!")
            if len(value) > maxsize:
                raise ValueError(
                    f"VALUE IS NOT VALID FOR {self.name!r} MUST BE GREATER THAN {maxsize}"
                )
        minsize = self.kwargs.get("minsize")
        if minsize is not None:
            if not isinstance(minsize, int):
                raise TypeError(f"MIN VALUE MUST BE TYPE INTEGER NOT {type(value)}!")
            if len(value) < minsize:
                raise ValueError(
                    f"VALUE IS NOT VALID FOR {self.name!r} MUST BE LESS THAN {minsize}"
                )

    def val_size(self, value: str):
        length = self.kwargs.get("size")
        if length is None:
            raise ValueError("LEN NOT DEFINED")
        if len(value) > length:
            raise ValueError(
                f"VALUE IS NOT VALID FOR {self.name!r} THE LIMIT OF CHACRTERS IS {length}"
            )

    def val_email(self, value: str):
        regex = r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b"
        if not re.fullmatch(regex, value):
            raise ValueError("MAIL NOT VALID")

    def val_number(self, value: str):
        regex = "[0-9]+"
        if not re.match(regex, value):
            raise ValueError("STRING NUMBER NOT VALID")

    def val_one_of(self, value: str):
        posible_values = self.kwargs.get("posible_values")
        if posible_values is None:
            raise ValueError("POSIBLE VALUES NOT DEFINED")
        if not issubclass(posible_values, Enum):
            raise TypeError(f"POSIBLE VALUES MUST BE TYPE ENUM NOT {type(value)}!")
        values = [e.value for e in posible_values]
        if value not in values:
            raise ValueError(
                f"VALUE IS NOT VALID FOR {self.name!r} IS NOT PRESENT IN ARRAY {posible_values}"
            )
@dataclass
class Person():
   personid: str = Descriptor((str), ["SIZE"], size=8)
Другие вопросы по тегам