The object model, descriptors, exception design, cancellation — a week from now some of it will have blurred. Trying to recall it before you reread it is what brings it back sharper, which is the whole reason for this page.
Bookmark this page. Come back in about a week for the first set and about a month for the second. Those gaps are a sensible starting point, not a magic schedule.
- Try each one from memory first. Rereading first skips the part that does the work.
- Use the hints to check. Each names the lesson it comes from.
- Keep a list of the ones you needed help with. That’s your reading list.
A week later
1. Predict. Two descriptors, one with __set__ and one without, and an
instance dict that has an entry for both.
class Data:
def __get__(self, instance, owner=None):
return "descriptor"
def __set__(self, instance, value):
pass
class NonData:
def __get__(self, instance, owner=None):
return "descriptor"
class Post:
a = Data()
b = NonData()
post = Post()
post.__dict__.update(a="instance", b="instance")
print(post.a, post.b)Check your prediction
descriptor instanceA data descriptor — one with __set__ — wins over the instance dict; a
non-data descriptor loses to it. That precedence is why a field must define
__set__, and why a cached property must not. From Descriptors: Attributes
With Behaviour.
2. Fix. Narrowing a query shouldn’t change the one it came from: this should
print 1 2.
class Query:
def __init__(self, filters=()):
self.filters = list(filters)
def where(self, column, value):
self.filters.append((column, value))
return self
base = Query().where("status", "published")
recent = base.where("year", 2026)
print(len(base.filters), len(recent.filters))Check your fix
class Query:
def __init__(self, filters=()):
self.filters = list(filters)
def where(self, column, value):
return Query([*self.filters, (column, value)])
base = Query().where("status", "published")
recent = base.where("year", 2026)
print(len(base.filters), len(recent.filters))1 2Returning self after mutating made base and recent the same object.
Return a new query instead, and chaining still reads the same. From
Copying, Shallow and Deep.
3. Write a Plugin base class: each subclass registers itself under the
name it’s given, and a name that’s already taken is refused when the class is
defined. This should print the two lines shown after it:
# class Plugin: ...
class CSV(Plugin, name="csv"):
pass
class JSON(Plugin, name="json"):
pass
try:
class Other(Plugin, name="csv"):
pass
except ValueError as error:
print("refused:", error)
print(sorted(Plugin.registry))refused: 'csv' is taken
['csv', 'json']
Check your answer
class Plugin:
registry = {}
def __init_subclass__(cls, name, **kwargs):
super().__init_subclass__(**kwargs)
if name in Plugin.registry:
raise ValueError(f"{name!r} is taken")
Plugin.registry[name] = cls
class CSV(Plugin, name="csv"):
pass
class JSON(Plugin, name="json"):
pass
try:
class Other(Plugin, name="csv"):
pass
except ValueError as error:
print("refused:", error)
print(sorted(Plugin.registry))refused: 'csv' is taken
['csv', 'json']Class keywords arrive as arguments to __init_subclass__, which runs as each
class statement finishes. From init_subclass and What Happens at class.
4. Explain. No code: why does refresh validate the whole row before
assigning any of it?
Check your explanation
So that a rejected value leaves the object exactly as it was. Assigning field by field, a bad value partway through leaves the object half-updated — new title, old views — with an exception on its way up: an operation that raised has still changed its subject. From The Edge Cases You’ve Been Avoiding.
A month later
1. Predict both lines.
def fail():
raise ExceptionGroup("batch", [ValueError("a"), KeyError("b"), ValueError("c")])
try:
fail()
except* ValueError as group:
print("values:", len(group.exceptions))
except* KeyError as group:
print("keys:", len(group.exceptions))Check your prediction
values: 2
keys: 1Unlike except, every matching except* clause runs, each receiving the part
of the group it matches. From Tasks, Groups and Exception Groups.
2. Fix. The cache should never hold more than two entries, dropping the
least recently used: this should print ['c', 'd'].
from collections import OrderedDict
class Cache:
def __init__(self, size):
self.size = size
self.entries = OrderedDict()
def put(self, key, value):
self.entries[key] = value
cache = Cache(size=2)
for key in "abcd":
cache.put(key, key)
print(list(cache.entries))Check your fix
from collections import OrderedDict
class Cache:
def __init__(self, size):
self.size = size
self.entries = OrderedDict()
def put(self, key, value):
self.entries[key] = value
self.entries.move_to_end(key)
while len(self.entries) > self.size:
self.entries.popitem(last=False)
cache = Cache(size=2)
for key in "abcd":
cache.put(key, key)
print(list(cache.entries))['c', 'd']A cache with no limit is a leak with good intentions. From Memory: Finding What Never Goes Away.
3. Write CountingConnection: it wraps a real connection, passes every
execute through, and counts the select statements. This should print 1:
import sqlite3
# class CountingConnection: ...
conn = CountingConnection(sqlite3.connect(":memory:"))
conn.execute("create table t (x)")
conn.execute("insert into t values (?)", (1,))
conn.execute("select * from t")
print(conn.selects)Check your answer
import sqlite3
class CountingConnection:
def __init__(self, real):
self.real = real
self.selects = 0
def execute(self, sql, parameters=(), /):
if sql.lstrip().lower().startswith("select"):
self.selects += 1
return self.real.execute(sql, parameters)
conn = CountingConnection(sqlite3.connect(":memory:"))
conn.execute("create table t (x)")
conn.execute("insert into t values (?)", (1,))
conn.execute("select * from t")
print(conn.selects)1Because the library depends on a Connection protocol, anything with the
right methods can stand in — including a wrapper that counts. From Measuring
Before Optimising and Protocols: Say What You Use.
4. Explain. No code: what does marking a test @unittest.expectedFailure
commit you to?
Check your explanation
It turns a known limitation into executable documentation: the suite stays green while the defect exists and goes red the day someone fixes it — the moment to remove the decorator and the README note. The docstring must say what the limitation is and what fixing it would take, and there should be very few. From The Edge Cases You’ve Been Avoiding.
After that
If these came easily, build something: the briefs at the end of On Your Own are a good start. If some didn’t, you now know exactly which lessons to reread.