105

Having a simple Python class like this:

class Spam(object):
    __init__(self, description, value):
        self.description = description
        self.value = value

I would like to check the following constraints:

  • "description cannot be empty"
  • "value must be greater than zero"

Should I:
1. validate data before creating spam object ?
2. check data on __init__ method ?
3. create an is_valid method on Spam class and call it with spam.isValid() ?
4. create an is_valid static method on Spam class and call it with Spam.isValid(description, value) ?
5. check data on setters declaration ?
6. etc.

Could you recommend a well designed/Pythonic/not verbose (on class with many attributes)/elegant approach?

Luuklag
  • 3,897
  • 11
  • 38
  • 57
systempuntoout
  • 71,966
  • 47
  • 171
  • 241

6 Answers6

129

You can use Python properties to cleanly apply rules to each field separately, and enforce them even when client code tries to change the field:

class Spam(object):
    def __init__(self, description, value):
        self.description = description
        self.value = value

    @property
    def description(self):
        return self._description

    @description.setter
    def description(self, d):
        if not d: raise Exception("description cannot be empty")
        self._description = d

    @property
    def value(self):
        return self._value

    @value.setter
    def value(self, v):
        if not (v > 0): raise Exception("value must be greater than zero")
        self._value = v

An exception will be thrown on any attempt to violate the rules, even in the __init__ function, in which case object construction will fail.

UPDATE: Sometime between 2010 and now, I learned about operator.attrgetter:

import operator

class Spam(object):
    def __init__(self, description, value):
        self.description = description
        self.value = value

    description = property(operator.attrgetter('_description'))

    @description.setter
    def description(self, d):
        if not d: raise Exception("description cannot be empty")
        self._description = d

    value = property(operator.attrgetter('_value'))

    @value.setter
    def value(self, v):
        if not (v > 0): raise Exception("value must be greater than zero")
        self._value = v
Marcelo Cantos
  • 181,030
  • 38
  • 327
  • 365
  • 1
    +1 elegant solution thanks, dont' you think is a little bit verbose for a small class like that? – systempuntoout May 13 '10 at 09:55
  • 2
    Agreed, it isn't the prettiest solution. Python prefers free-range classes (think chickens), and the idea of properties controlling access was a bit of an afterthought. Having said that, this wouldn't be much more concise in any other language I can think of. – Marcelo Cantos May 13 '10 at 10:33
  • 2
    @MarceloCantos I realize this is an old question, but based on the [documentation](http://docs.python.org/3/library/functions.html#property) (albeit for Python 3), should `self.description = description` use an underscore, i.e. `self._description = description`, or does that not matter? Is this necessary or merely something similar to Python's version of "private" variables? – John Bensin Mar 24 '13 at 23:31
  • 15
    @JohnBensin: Yes and no. `self.description = …` assigns through property, whereas `self._description = …` assigns directly to the underlying field. Which one to use during construction is a design choice, but it's usually safer to always assign through the property. For example, the above code will raise an exception if you call `Spam('', 1)`, as it should. – Marcelo Cantos Mar 24 '13 at 23:37
  • @MarceloCantos Thanks for pointing that out. I was completely unaware of the difference in assignment. Based on a quick test, this difference also holds true for accessing a member variable, which I didn't know either. Very neat. – John Bensin Mar 25 '13 at 00:10
  • 1
    I do not think it is too verbose. The alternative is for it to be possible to set the value to invalid values. – Tony Ennis Mar 02 '16 at 16:11
  • Is this supposed to work in Python 2? Because I'm trying the first variant, and the setter just doesn't get run. – Christofer Ohlsson Jul 12 '17 at 16:03
  • @ChristoferOhlsson yes it should work in Python 2. Have you added logging to confirm that the code never runs? – Marcelo Cantos Jul 12 '17 at 19:40
12

If you only want to validate the values when the object is created AND passing in invalid values is considered a programming error then I would use assertions:

class Spam(object):
    def __init__(self, description:str, value:int):
        assert description != ""
        assert value > 0
        self.description = description
        self.value = value

This is about as concise as you are going to get, and clearly documents that these are preconditions for creating the object.

Kermit
  • 4,922
  • 4
  • 42
  • 74
Dave Kirby
  • 25,806
  • 5
  • 67
  • 84
  • thanks Dave, using assert, how do i specify to the client of that class what went wrong (description or value)? Don't you think that assertion should be used just to test conditions that should never happen? – systempuntoout May 13 '10 at 12:42
  • 5
    You can add a message to the assert statement e.g `assert value > 0, "value attribute to Spam must be greater than zero"`. Assertions are really messages to the developer and should not be caught by client code, since they indicate a programming error. If you want the client to catch and handle the error then explicitly raise an exception such as ValueError, as shown in the other answers. – Dave Kirby May 13 '10 at 15:46
  • 1
    To answer your second question, yes asserts should be used to test conditions that should never happen - that is why I said "if passing in invalid values is considered a programming error...". If that is not the case then don't use asserts. – Dave Kirby May 13 '10 at 15:49
  • `def `should be inserted prior to `__init__` – datapug Jan 13 '19 at 19:56
  • 1
    Thanks @datapug, fixed the typo. – Dave Kirby Jan 14 '19 at 10:31
  • 1
    Instead of using assert you should consider raising errors since it's possible for the user to disable assert statements with [`python -O`](https://docs.python.org/3/reference/simple_stmts.html#the-assert-statement) – Luis Meraz Oct 28 '20 at 21:40
7

Unless you're hellbent on rolling your own, you can simply use formencode. It really shines with many attributes and schemas (just subclass schemas) and has a lot of useful validators builtin. As you can see this is the "validate data before creating spam object" approach.

from formencode import Schema, validators

class SpamSchema(Schema):
    description = validators.String(not_empty=True)
    value = validators.Int(min=0)

class Spam(object):
    def __init__(self, description, value):
        self.description = description
        self.value = value

## how you actually validate depends on your application
def validate_input( cls, schema, **input):
    data = schema.to_python(input) # validate `input` dict with the schema
    return cls(**data) # it validated here, else there was an exception

# returns a Spam object
validate_input( Spam, SpamSchema, description='this works', value=5) 

# raises an exception with all the invalid fields
validate_input( Spam, SpamSchema, description='', value=-1) 

You could do the checks during __init__ too (and make them completely transparent with descriptors|decorators|metaclass), but I'm not a big fan of that. I like a clean barrier between user input and internal objects.

Jochen Ritzel
  • 104,512
  • 31
  • 200
  • 194
6

if you want to only validate those values passed to the constructor, you could do:

class Spam(object):
    def __init__(self, description, value):
        if not description or value <=0:
            raise ValueError
        self.description = description
        self.value = value

This will of course will not prevent anyone from doing something like this:

>>> s = Spam('s', 5)
>>> s.value = 0
>>> s.value
0

So, correct approach depends on what you're trying to accomplish.

SilentGhost
  • 307,395
  • 66
  • 306
  • 293
  • this is my actual approach; but i don't like it when attributes number raise and\or constraints checks are more sophisticated.It seems to clutter the init method too much. – systempuntoout May 13 '10 at 10:07
  • 2
    @system: you can separate validity check into its own method: there are not hard and fast rules about this situation. – SilentGhost May 13 '10 at 10:18
2

You can try pyfields:

from pyfields import field

class Spam(object):
    description = field(validators={"description can not be empty": lambda s: len(s) > 0})
    value = field(validators={"value must be greater than zero": lambda x: x > 0})

s = Spam()
s.description = "hello"
s.description = ""  # <-- raises error, see below

It yields

ValidationError[ValueError]: Error validating [<...>.Spam.description=''].
  InvalidValue: description can not be empty. 
  Function [<lambda>] returned [False] for value ''.

It is compliant with python 2 and 3.5 (as opposed to pydantic), and validation happens everytime the value is changed (not only the first time, as opposed to attrs). It can create the constructor for you, but does not do it by default as shown above.

Note that you may wish to optionally use mini-lambda instead of plain old lambda functions if you wish the error messages to be even more straightforward (they will display the failing expression).

See pyfields documentation for details (I'm the author by the way ;) )

smarie
  • 4,568
  • 24
  • 39
0

I'm working on yet another validation library - convtools models (docs / github).

The vision of this library is:

  • validation first
  • no implicit type casting
  • no implicit data losses during type casting - e.g. casting 10.0 to int is fine, 10.1 is not
  • if there’s a model instance, it is valid.
from collections import namedtuple
from typing import Union

from convtools.contrib.models import ObjectModel, build, validate, validators

# input data to test
SpamTest = namedtuple("SpamTest", ["description", "value"])


class Spam(ObjectModel):
    description: str = validate(validators.Length(min_length=1))
    value: Union[int, float] = validate(validators.Gt(0))


spam, errors = build(Spam, SpamTest("", 0))
"""
>>> In [34]: errors
>>> Out[34]:
>>> {'description': {'__ERRORS': {'min_length': 'length is 0, but should be >= 1'}},
>>>  'value': {'__ERRORS': {'gt': 'should be > 0'}}
"""


spam, errors = build(Spam, SpamTest("foo", 1))
"""
>>> In [42]: spam
>>> Out[42]: Spam(description='foo', value=1)
>>> In [43]: spam.to_dict()
>>> Out[43]: {'description': 'foo', 'value': 1}
>>> In [44]: spam.description
>>> Out[44]: 'foo'
"""
westandskif
  • 972
  • 6
  • 9