Come Back Later: Review Challenges

The Course
•
September 24, 2026
•
6 min read

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.

Week 1: predict Python
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 instance

A 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.

Week 1: fix Python
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 2

Returning 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:

Week 1: write Python
# 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.

Month 1: predict Python
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: 1

Unlike 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'].

Month 1: fix Python
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:

Month 1: write Python
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)
1

Because 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.

Last updated on September 24, 2026

Was this article helpful?

Your response is saved on this device.