A descriptor that validates is worth little on its own; a family of them, sharing one hook, is a library feature. This lesson designs that family, and the design questions are more interesting than the code: when to reject, what to say, and what “an integer” means.
One hook, many fields
The base class owns the descriptor protocol. Subclasses override one method:
class Field:
def __set__(self, instance, value):
instance.__dict__[self.slot] = self.check(value)
def check(self, value):
"""Return the value to store, or raise ValueError."""
if value is None and not self.null:
raise ValueError(f"{self.name} may not be None")
return value
check returns the value rather than just approving it, which lets a field
normalise as well as validate — strip whitespace, coerce a date string, round
a decimal. Every subclass calls super().check(value) first, so the None
rule lives in one place.
Reject at assignment, not at save
post.views = -1 could raise now, or save() could collect all the problems
later. This library raises now, because the traceback then points at the line
that caused it. Deferred validation gives better error reports — every problem
at once, which a web form wants — at the cost of a stack trace pointing at
save(). Both are defensible; what isn’t is choosing by accident.
Two fields
class Field:
def __init__(self, *, null=False):
self.null = null
self.name = "<unnamed>"
def __set_name__(self, owner, name):
self.name = name
self.slot = f"_{name}"
def __get__(self, instance, owner=None):
if instance is None:
return self
try:
return instance.__dict__[self.slot]
except KeyError:
raise AttributeError(f"{type(instance).__name__}.{self.name} is not set") from None
def __set__(self, instance, value):
instance.__dict__[self.slot] = self.check(value)
def check(self, value):
if value is None and not self.null:
raise ValueError(f"{self.name} may not be None")
return value
class TextField(Field):
def __init__(self, *, max_length=None, **kwargs):
super().__init__(**kwargs)
self.max_length = max_length
def check(self, value):
value = super().check(value)
if value is None:
return None
if not isinstance(value, str):
raise ValueError(f"{self.name} must be a str, not {type(value).__name__}")
if self.max_length is not None and len(value) > self.max_length:
raise ValueError(f"{self.name} is longer than {self.max_length} characters")
return value
class Post:
title = TextField(max_length=10)
subtitle = TextField(null=True)
post = Post()
post.title = "Hello"
post.subtitle = None
print("stored:", post.title, "|", post.subtitle)
for bad in [42, "a much longer title than allowed"]:
try:
post.title = bad
except ValueError as error:
print("ValueError:", error)
try:
post.title = None
except ValueError as error:
print("ValueError:", error)
print("the good value survived:", post.title)The last line is the point of the whole design: three rejected assignments, and
the object still holds "Hello". A validator that mutates before it checks
leaves callers with objects that are half-updated after a caught exception.
An error message is API
Compare two messages for the same mistake:
ValueError: invalid value
ValueError: views must be an int, not str
The second names the field, the rule and what arrived. A library’s exception text is read far more often than its documentation, usually by someone who is already annoyed. Three habits pay for themselves:
- Name the attribute (
self.name, which is why__set_name__exists). - Say what was expected and what came, using
type(value).__name__rather than the value, which may be enormous or secret. - Keep it one line, lowercase-ish, no trailing full stop — matching the standard library, so it reads the same in a traceback.
bool is an int
Python’s numeric tower has a sharp edge for validators: True is an integer.
print("isinstance(True, int):", isinstance(True, int))
print("True == 1:", True == 1)
print("sum of bools:", sum([True, True, False]))
def check_naive(value):
if not isinstance(value, int):
raise ValueError("must be an int")
return value
def check_strict(value):
if isinstance(value, bool) or not isinstance(value, int):
raise ValueError(f"must be an int, not {type(value).__name__}")
return value
for check in (check_naive, check_strict):
try:
print(check.__name__, "accepted", check(True))
except ValueError as error:
print(check.__name__, "rejected True:", error)views = True would be stored as 1 and silently reported as one view. Whether
to refuse it is a judgement call — but make it deliberately, and write the test
either way, because the naive check gives you the permissive answer without
asking.
Don't reach for isinstance first
Type checks belong in a validator, where the whole job is to police types. In
ordinary code, calling a method and letting it fail is usually better than
isinstance, because it accepts anything that behaves correctly. Day 3 makes
that idea precise with protocols.
A ChoiceField
Add a field that accepts only one of a fixed set of values — the field a
status column wants. The tests define it; note the last one, which is about
the library’s own developer experience rather than its runtime behaviour.
import unittest
class Field:
def __init__(self, *, null=False):
self.null = null
self.name = "<unnamed>"
def __set_name__(self, owner, name):
self.name = name
self.slot = f"_{name}"
def __get__(self, instance, owner=None):
if instance is None:
return self
return instance.__dict__[self.slot]
def __set__(self, instance, value):
instance.__dict__[self.slot] = self.check(value)
def check(self, value):
if value is None and not self.null:
raise ValueError(f"{self.name} may not be None")
return value
class ChoiceField(Field):
"""TODO: accept only the given choices, and refuse an empty set of them."""
def __init__(self, *, choices=(), **kwargs):
super().__init__(**kwargs)
self.choices = tuple(choices)
class Post:
status = ChoiceField(choices=("draft", "published"))
class Choices(unittest.TestCase):
def test_accepts_a_choice(self):
post = Post()
post.status = "published"
self.assertEqual(post.status, "published")
def test_rejects_anything_else(self):
post = Post()
with self.assertRaises(ValueError):
post.status = "deleted"
def test_the_message_lists_the_choices(self):
post = Post()
with self.assertRaises(ValueError) as caught:
post.status = "deleted"
message = str(caught.exception)
self.assertIn("status", message)
self.assertIn("draft", message)
self.assertIn("published", message)
def test_none_is_still_refused(self):
post = Post()
with self.assertRaises(ValueError):
post.status = None
def test_empty_choices_is_refused_when_the_field_is_defined(self):
"""A field that can never accept anything is a mistake in the model,
and the library should say so at import time, not at first use."""
with self.assertRaises(ValueError):
ChoiceField(choices=())
unittest.TextTestRunner(verbosity=2).run(
unittest.defaultTestLoader.loadTestsFromTestCase(Choices)
)Hint 1: the idea
Two checks, at two different times. When the field is defined — in
__init__ — an empty set of choices is a mistake in the model, so refuse it
there. When a value is assigned, check runs: let the parent’s check handle
None first, then refuse anything not in self.choices, with a message naming
the field and listing the choices.
Hint 2: a first step
Override check and call super().check(value) first, so the None rule still
applies. Then compare against self.choices. The empty-choices test needs a
line in __init__, not in check.
Show one solution
class ChoiceField(Field):
def __init__(self, *, choices, **kwargs):
super().__init__(**kwargs)
if not choices:
raise ValueError("choices may not be empty")
self.choices = tuple(choices)
def check(self, value):
value = super().check(value)
if value is None:
return None
if value not in self.choices:
allowed = ", ".join(repr(choice) for choice in self.choices)
raise ValueError(f"{self.name} must be one of {allowed}, not {value!r}")
return valuechoices is keyword-only and required — ChoiceField() with no choices is a
TypeError from Python itself, and an empty sequence is the ValueError the
last test asks for. Both fire while the class body is being executed, so the
model’s own author hears about it at import time.
tuple(choices) copies, so a caller who passes a list and mutates it later
can’t change what the field accepts. That’s the same instinct as yesterday’s
list(filters), applied to configuration instead of data.
Here the value is included in the message, unlike the type errors above. It’s a short string from a small set, and knowing which invalid status arrived is the whole diagnostic.
What you learned
- A
checkhook that returns the value gives one place for validation and normalisation, withsuper().check()handling what’s common. - Validate before storing, so a rejected assignment changes nothing.
- Error messages are part of the API: name the field, the rule and the type.
boolpassesisinstance(x, int), and a numeric field should decide on purpose whether that’s acceptable.