Search code examples
pythonvalidationpython-dataclasses

Validate dataclass field with custom defined method?


While working with dataclasses, type hints are good but what I'm looking also for is a Validation of passed values (such as string of max length 50, int with upper limit of 100 etc)

Is there anyway to validate passed value ? For example, Pydantic has these Validators. I'm looking for something native without adding external libraries. My only solution is:

from dataclasses import dataclass

def validate_str(max_length):
    def _validate(f):
        def wrapper(self, value):
            if type(value) is not str:
                raise TypeError(f"Expected str, got: {type(value)}")
            elif len(value) > max_length:
                raise ValueError(
                    f"Expected string of max length {max_length}, got string of length {len(value)} : {value}"  # noqa
                )
            else:
                return f(self, value)

        return wrapper

    return _validate

@dataclass
class Example:
    """Class for keeping track of an item in inventory."""

    @property
    def name(self):
        return self._name

    @name.setter
    @validate_str(max_length=50)
    def name(self, value):
         self._name = value

where validate_str is just a custom decorator method to check length of provided value, but then I repeat myself. I would like to pass validator somehow in same row of dataclass attribute as:

@dataclass
class InventoryItem:
    """Class for keeping track of an item in inventory."""
    name: str = validate_somehow()
    unit_price: float = validate_somehow()
    quantity_on_hand: int = 0

Solution

  • The ideal approach would be to use a modified version of the Validator example from the Python how-to guide on descriptors.

    For example:

    from abc import ABC, abstractmethod
    from dataclasses import dataclass, MISSING
    
    
    class Validator(ABC):
    
        def __set_name__(self, owner, name):
            self.private_name = '_' + name
    
        def __get__(self, obj, obj_type=None):
            return getattr(obj, self.private_name)
    
        def __set__(self, obj, value):
            self.validate(value)
            setattr(obj, self.private_name, value)
    
        @abstractmethod
        def validate(self, value):
            """Note: subclasses must implement this method"""
    
    
    class String(Validator):
    
        # You may or may not want a default value
        def __init__(self, default: str = MISSING, minsize=None, maxsize=None, predicate=None):
            self.default = default
            self.minsize = minsize
            self.maxsize = maxsize
            self.predicate = predicate
    
        # override __get__() to return a default value if one is not passed in to __init__()
        def __get__(self, obj, obj_type=None):
            return getattr(obj, self.private_name, self.default)
    
        def validate(self, value):
    
            if not isinstance(value, str):
                raise TypeError(f'Expected {value!r} to be an str')
    
            if self.minsize is not None and len(value) < self.minsize:
                raise ValueError(
                    f'Expected {value!r} to be no smaller than {self.minsize!r}'
                )
    
            if self.maxsize is not None and len(value) > self.maxsize:
                raise ValueError(
                    f'Expected {value!r} to be no bigger than {self.maxsize!r}'
                )
    
            if self.predicate is not None and not self.predicate(value):
                raise ValueError(
                    f'Expected {self.predicate} to be true for {value!r}'
                )
    
    
    @dataclass
    class A:
        y: str = String(default='DEFAULT', minsize=5, maxsize=10, predicate=str.isupper)  # Descriptor instance
        x: int = 5
    
    
    a = A()
    print(a)
    
    a = A('TESTING!!')
    print(a)
    
    try:
        a.y = 'testing!!'
    except Exception as e:
        print('Error:', e)
    
    try:
        a = A('HEY')
    except Exception as e:
        print('Error:', e)
    
    try:
        a = A('HELLO WORLD!')
    except Exception as e:
        print('Error:', e)
    
    try:
        a.y = 7
    except Exception as e:
        print('Error:', e)
    

    Output:

    A(y='DEFAULT', x=5)
    A(y='TESTING!!', x=5)
    Error: Expected <method 'isupper' of 'str' objects> to be true for 'testing!!'
    Error: Expected 'HEY' to be no smaller than 5
    Error: Expected 'HELLO WORLD!' to be no bigger than 10
    Error: Expected 7 to be an str