Warm-up: from Day 2
From memory first.
1. Predict all three lines. Loud has a __get__ and no __set__.
class Loud:
def __set_name__(self, owner, name):
self.name = name
def __get__(self, instance, owner=None):
if instance is None:
return self
return f"descriptor {self.name}"
class Post:
title = Loud()
post = Post()
print(post.title)
post.__dict__["title"] = "instance"
print(post.title)
print(type(Post.title).__name__)Check your prediction
descriptor title
instance
LoudWith no __set__, Loud is a non-data descriptor, so an entry in the
instance’s dict wins over it. Give it a __set__ and the descriptor would win
instead. From Descriptors: Attributes With Behaviour.
2. Fix. A rejected assignment should leave the old value in place: this
should print 5.
class Positive:
def __set_name__(self, owner, name):
self.slot = "_" + name
def __get__(self, instance, owner=None):
return instance.__dict__[self.slot]
def __set__(self, instance, value):
instance.__dict__[self.slot] = value
if value < 0:
raise ValueError("may not be negative")
class Counter:
views = Positive()
c = Counter()
c.views = 5
try:
c.views = -1
except ValueError:
pass
print(c.views)Check your fix
class Positive:
def __set_name__(self, owner, name):
self.slot = "_" + name
def __get__(self, instance, owner=None):
return instance.__dict__[self.slot]
def __set__(self, instance, value):
if value < 0:
raise ValueError("may not be negative")
instance.__dict__[self.slot] = value
class Counter:
views = Positive()
c = Counter()
c.views = 5
try:
c.views = -1
except ValueError:
pass
print(c.views)5Check first, store second. Stored first, the bad value is already in place when the error is raised — and a caller who catches the error holds a broken object.
3. Explain. No code: what does __init_subclass__ let the library catch that
a check in __init__ couldn’t?
Check your explanation
Mistakes in a model’s definition — no fields, two models claiming one table —
caught when the class statement runs, which is at import time. A check in
__init__ would wait until someone first creates an instance, possibly in
production, possibly never. From init_subclass and What Happens at class.
Today
Your fields validate at run time. A type checker knows none of it: to mypy,
post.views is whatever Field.__get__ says it returns, and so far that’s
nothing useful.
Today’s job is to make the annotations carry the same information the descriptors already have — and, by the end of the day, to be precise about what they still can’t.
This day needs Python on your own machine
A type checker is a separate program, and it can’t run in the browser. The
playgrounds here run the runtime behaviour; for the checking part, download
the day’s files and run mypy --strict --python-version 3.14 . locally.
pip install "mypy==2.3.*" is the only setup, and the files need Python 3.13+
for warnings.deprecated. Every mypy output quoted in these lessons is from
mypy 2.3 targeting 3.14 on the day’s reference files; a different checker or
target may word the diagnostics differently.
A field that knows what it holds
Make Field generic in the type of its value, and each concrete field fills
the parameter in:
class Field[T]:
def __set__(self, instance: object, value: T) -> None: ...
class TextField(Field[str]): ...
class IntegerField(Field[int]): ...
class Field[T] is the syntax added in Python 3.12; the older
T = TypeVar("T") and Generic[T] mean the same thing. A subclass that stays
generic re-declares the parameter — class Nullable[T](Field[T]) — which is
easy to forget, and the error (NameError: name 'T' is not defined) arrives at
import time.
Two ways to read a field, two overloads
Post.views gives the field object; post.views gives an integer. One method,
two answers, and @overload is how you say so:
@overload
def __get__(self, instance: None, owner: type | None = None) -> Self: ...
@overload
def __get__(self, instance: object, owner: type | None = None) -> T: ...
def __get__(self, instance: object | None, owner: type | None = None) -> Self | T:
...
The two stubs are what callers see; the third — the implementation — is
invisible to them and must be compatible with both. Self rather than Field
matters for subclasses: Post.views is an IntegerField, not a Field, so
Post.views.minimum type-checks.
from typing import Self, overload
class Field[T]:
@overload
def __get__(self, instance: None, owner: type | None = None) -> Self: ...
@overload
def __get__(self, instance: object, owner: type | None = None) -> T: ...
def __get__(self, instance: object | None, owner: type | None = None) -> Self | T:
if instance is None:
return self
return instance.__dict__["_" + self.name]
def __set_name__(self, owner: type, name: str) -> None:
self.name = name
def __set__(self, instance: object, value: T) -> None:
instance.__dict__["_" + self.name] = value
class IntegerField(Field[int]):
pass
class Post:
views = IntegerField()
post = Post()
post.views = 3
print("value: ", post.views)
print("through class:", Post.views)
print("overloads at run time:", Field.__get__.__name__, "— just one function")At run time there is exactly one __get__: the final definition is what runs,
and the overload stubs are never called. (They aren’t quite gone — Python
records them, and typing.get_overloads(f) hands them back for tools to
inspect — but they contribute no behaviour.) Everything the overloads say is for
the checker, which is the deal typing makes everywhere: no behaviour, only
claims that can be checked.
Self for anything that returns the receiver
def save(self, connection: Connection) -> Self: ...
@classmethod
def get(cls, connection: Connection, id: int) -> Self: ...
Annotating these -> "Model" would be wrong in a way that only shows up
downstream: Post.get(...) would be a Model, and .title an error. Self
means “whatever class this was called on”, which is what the code actually does
via cls(...).
Verified on the day’s files, these two lines check clean:
saved: Post = post.save(connection)
loaded: Post = Post.get(connection, 1)
Where cast is honest
Field.check is declared -> T, and the base implementation returns the value
it was handed, which the checker only knows as Any:
def check(self, value: Any) -> T:
if value is None and not self.null:
raise ValueError(f"{self.name} may not be None")
return cast(T, value)
cast generates no code and checks nothing: it’s a note to the checker saying
trust me here. That makes it a small, deliberate hole, and the reason this one
is defensible is that the base class genuinely can’t prove the claim — only a
subclass knows what T is, and each subclass’s check does the isinstance
test that makes it true.
The alternative to a cast is usually a # type: ignore[...] comment, and
between the two prefer the cast: it says what you’re claiming, and it can’t
accidentally silence a second, unrelated error on the same line.
Keep ignores narrow
# type: ignore silences everything on that line; # type: ignore[no-any-return]
silences one error code. Libraries should always use the narrow form — the broad
one hides the errors you introduce later.
Type a cached property
Once computes a value the first time it’s read and remembers it. Add the
annotations that make the commented lines below type-check the way the comments
say — the tests here only check the run-time behaviour, so the real exercise is
running mypy --strict on it yourself.
import unittest
from typing import Any, Callable, Self, overload
class Once:
"""A non-data descriptor: computed once, then shadowed by the instance dict.
TODO: make this generic in the value the function returns, so that
`report.total` is an `int` to a checker, and `Report.total` is the
descriptor itself.
"""
def __init__(self, function):
self.function = function
self.name = function.__name__
def __set_name__(self, owner, name):
self.name = name
def __get__(self, instance, owner=None):
if instance is None:
return self
value = self.function(instance)
instance.__dict__[self.name] = value # no __set__, so this wins next time
return value
class Report:
calls = 0
@Once
def total(self) -> int:
Report.calls += 1
return 40 + 2
class Cached(unittest.TestCase):
def test_computes_once_and_remembers(self):
report = Report()
Report.calls = 0
self.assertEqual(report.total, 42)
self.assertEqual(report.total, 42)
self.assertEqual(Report.calls, 1)
def test_each_instance_computes_its_own(self):
Report.calls = 0
first, second = Report(), Report()
first.total, second.total
self.assertEqual(Report.calls, 2)
def test_through_the_class_you_get_the_descriptor(self):
self.assertIsInstance(Report.total, Once)
unittest.TextTestRunner(verbosity=2).run(
unittest.defaultTestLoader.loadTestsFromTestCase(Cached)
)Hint 1: the idea
Make Once generic in the type the wrapped function returns — Once[T] — and
let the constructor’s annotation tie it together: it takes a function from an
instance to a T. Then give __get__ the same two overloads as Field.__get__
above: through the class you get the descriptor, through an instance you get a
T.
Hint 2: a first step
Start with class Once[T]: and annotate __init__ so function is a
Callable[[Any], T]. Then copy the two overload stubs from Two ways to read a
field and adapt them. The playground’s tests only check behaviour; the type
check happens when you run mypy --strict locally.
Show one solution
class Once[T]:
def __init__(self, function: Callable[[Any], T]) -> None:
self.function = function
self.name = function.__name__
def __set_name__(self, owner: type, name: str) -> None:
self.name = name
@overload
def __get__(self, instance: None, owner: type | None = None) -> Self: ...
@overload
def __get__(self, instance: object, owner: type | None = None) -> T: ...
def __get__(self, instance: object | None, owner: type | None = None) -> Self | T:
if instance is None:
return self
value = self.function(instance)
instance.__dict__[self.name] = value
return valueCallable[[Any], T] is what ties it together: the decorated method takes an
instance and returns a T, so @Once over a method returning int produces an
Once[int], and the second overload reports int.
[Any] for the instance parameter rather than a second type variable is a
deliberate simplification. Being precise there means making Once generic in
the owner too, which buys nothing here: nothing in the class reads the instance’s
type.
Note that this descriptor defines no __set__, so it’s a non-data
descriptor — which is exactly why writing the value into instance.__dict__
under the same name works as a cache. A field, which does define __set__,
has to store under _name instead, because the descriptor always wins.
What you learned
class Field[T]makes a descriptor generic, and each concrete field fixes whatTis.- Two
@overloadstubs describe class-versus-instance access; the implementation is invisible to callers and must satisfy both. Selfis the right return type forsaveandget, and the reasonPost.get(...)is aPost.castis a claim, not a conversion — acceptable where only a subclass can prove the type, and always narrower than a baretype: ignore.