04 Sep 2026

feedPlanet Python

Trey Hunner: Python Morsels now has spaced repetition

Nearly every book I've read on teaching and education over the past decade has talked about the value of spaced repetition. For much of that time, spaced repetition has been something I would recommend learners do, but it wasn't something I helped anyone do… until now.

Python Morsels now has Daily Recall: a spaced repetition system for Python programmers.

We learn by recalling, not by reading

The most effective learning techniques all rely on active recall: trying to remember something without looking it up, whether with flash cards, by explaining an idea in your own words, or by doing a task that requires it.

We don't learn by putting information into our heads. We learn by retrieving information from our heads. That's why Python Morsels has always been built around exercises rather than videos: writing code is the most useful form of recall for a Python programmer.

But not everything worth remembering warrants an entire Python exercise.

A 20-minute exercise is overkill for practicing something that small. But practicing it just once isn't enough either. A quick question, asked again just before you'd forget, is a much better fit.

Spaced repetition beats the forgetting curve

Many of the things I learned in school are long gone, especially the ones I haven't thought about even once in years. I think I could explain photosynthesis in 9th grade. I can't today.

This is explained by the forgetting curve: we forget what we don't recall, and the rate of forgetting is somewhat predictable. Spaced repetition is about using active recall to beat the forgetting curve. Instead of recalling an idea over and over right after learning it, you wait until it has started to fade, and then try to recall it. Each successful recall earns a longer wait before the next one: minutes at first, then hours, then days, and eventually weeks and months.

The tricky part is the timing: when should you try to recall each thing? That's where an algorithm helps. A spaced repetition system tracks every idea you're trying to remember and prompts you to recall each one right before you'd forget it.

What Daily Recall does

Back in April, just before Earth Day, I made Whereabouts.Earth to help me learn the name and location of every country in the world. When I started, I could name about 90 of the 197 countries on a map. By mid-June, with about 10 minutes of practice a day, I knew all of them.

Daily Recall is the same idea, but for Python. You pick the packs you want to practice, and each day it asks you a few questions from them. Answer a question correctly and it'll be a while before you see that one again. Miss it and you'll see it again soon.

Daily Recall uses FSRS for scheduling, which is one of the most effective spaced repetition algorithms (it's an option in the Anki flash card app).

If you have trouble remembering which operations on different data structures are fast and which are slow, there's a Time Complexity pack for that. If you're struggling to remember the many different subcommands that uv supports, there's a uv pack for that. There are also packs on string methods, built-in functions, dictionaries, iterable unpacking, f-strings, pytest, and what's new in Python 3.13 and 3.14. I'm hoping to release about one new pack each week over the next many months.

Daily Recall also works well on a phone because recall questions don't require typing a bunch of code. So you can replace 5 minutes of your daily doomscrolling with 5 minutes of extra Python learning.

Early users who practiced about 5 minutes a day ended their first month with 25 to 50 new things they could still recall weeks after last seeing them.

You can use Daily Recall for free

Most Daily Recall packs are free, and the rest are included with the All Access plan. To get started, create a free Python Morsels account, pick a pack or two, and answer a few questions.

And whether or not you ever use Daily Recall, I'd recommend spaced repetition. If you'd rather write your own flash cards, on Python or anything else, Anki works well too. The next time you learn something new in Python, don't just read it a second time. Try to recall it tomorrow, and then again next week.

04 Sep 2026 12:30am GMT

03 Sep 2026

feedPlanet Python

Graham Dumpleton: Phased behaviour in wrapture

Most of what a test configures on a patch holds until the test changes it. Retry logic is the classic case where that is not enough: the code under test keeps calling, and the test needs the behaviour to change on its own as it does. Fail twice and then succeed. Hand out a sequence of canned responses. Run the real thing until it breaks and then fail fast. unittest.mock handles the first two of those with a list passed as side_effect, consumed one entry per call. wrapture models the same idea as phases, and this post is about what that buys you beyond the list.

The code under test

A client that fetches a URL, and a function that retries on a timeout:

class Client:
    def fetch(self, url):
        if "bad" in url:
            raise ConnectionError(f"cannot reach {url}")
        return {"url": url, "status": 200}


def fetch_with_retry(client, url, attempts=3):
    for attempt in range(1, attempts + 1):
        try:
            return client.fetch(url)
        except TimeoutError:
            if attempt == attempts:
                raise

With mock the retry test is a side_effect list, and it works fine:

with patch.object(Client, "fetch", side_effect=[TimeoutError("busy"), TimeoutError("busy"), {"url": "/x", "status": 200}]):
    assert fetch_with_retry(Client(), "/x") == {"url": "/x", "status": 200}

What the list cannot say is "and then run the real code". Every entry is a fabricated outcome, so the third call is a canned dictionary rather than the real fetch(), and the test proves the loop retries but not that the real method is what it eventually reaches.

Phases

In wrapture the behaviour configured on on_call is phase 0, and then() adds the phase that takes over from it, with the argument saying when the hand-over happens. Each phase is a complete behaviour of its own with the full vocabulary, and nothing is inherited between them, so a phase with no terminal runs the real operation:

fetch = wrapture.binding(Client, "fetch")
fetch.on_call.raises(TimeoutError("busy"))

recovered = fetch.on_call.then(after=2)
recovered.passes_through()

The first two calls raise, and every call after that is real. Stating passes_through() on a fresh phase is optional, since that is what an empty phase does anyway, but worth writing when running the real thing is the point of the phase. Recording it shows the hand-over, and the tape marks which outcomes were injected and which were real:

with wrapture.timeline(fetch) as tape:
    print(fetch_with_retry(Client(), "/orders"))
    print(tape.tree())
{'url': '/orders', 'status': 200}
__main__:Client.fetch(url='/orders')  !! TimeoutError (injected)
__main__:Client.fetch(url='/orders')  !! TimeoutError (injected)
__main__:Client.fetch(url='/orders')  -> {'url': '/orders', 'status': 200}

Each event carries the index of the phase that handled it, so the recording can be filtered by regime, and the binding knows which phase it is in:

fetch.events.in_phase(0).assert_times(2)
fetch.events.in_phase(1).assert_once()
assert fetch.phase == 1

binding.phase is the index of the phase currently active, and in_phase() filters the recorded events to those a given phase handled. The two answer different questions, since a phase can be entered and left without handling a call. Phases restart at 0 on every apply(), so a binding handed to timeline() starts its script afresh in each test that uses it.

The give-up path is the same binding with a bigger count. With then(after=3) all three attempts raise, fetch_with_retry() re-raises the last one, and the tape shows three injected failures and no real call.

The verbs on a phase return the phase, so a phase can be configured in one chain, then(after=1).validates_args(check).returns(b). Holding it in a variable named for what the phase is, and configuring it line by line as with on_call, usually reads better, and it is the style I would use in a test.

Ending a phase on a condition

A count is one of three ways a phase can end. then(until=fn) ends the phase once fn(event) is true for a call it handled. The event is the same one a timeline would record, seen as the caller saw it, so the condition can look at the arguments, the result, or whether the call raised. That is enough to build a circuit breaker: run the real call until one fails, then fail fast without touching the remote at all.

class CircuitOpen(Exception):
    pass


def failed(event):
    return event.exception is not None


fetch = wrapture.binding(Client, "fetch")
fetch.on_call.passes_through()

tripped = fetch.on_call.then(until=failed)
tripped.raises(CircuitOpen("circuit open"))

Fetch two good URLs, one bad one that the real fetch() rejects, and then another good one:

__main__:Client.fetch(url='/a')  -> {'url': '/a', 'status': 200}
__main__:Client.fetch(url='/b')  -> {'url': '/b', 'status': 200}
__main__:Client.fetch(url='/bad')  !! ConnectionError
__main__:Client.fetch(url='/c')  !! CircuitOpen (injected)

The ConnectionError is real, raised by the real method for a real reason, and the CircuitOpen after it is the binding's. A side_effect list has no way to express a phase whose boundary depends on what the real code did.

Sequences

For "return the next value on each call" a phase per value would be tiresome, so returns_from(iterable) is a terminal that draws successive values, one per call, lazily. A generator or itertools.cycle() works. When the sequence runs out the phase ends and the call that found it empty is handled by the successor, so a bare then() after a sequence means "when it is exhausted". A polling loop is the natural example:

class Job:
    def status(self):
        return "done"


def wait_for(job, polls=5):
    for _ in range(polls):
        if job.status() == "done":
            return True
    return False


status = wrapture.binding(Job, "status")
status.on_call.returns_from(["queued", "running", "running"])

settled = status.on_call.then()
settled.returns("done")
__main__:Job.status()  -> 'queued' (injected)
__main__:Job.status()  -> 'running' (injected)
__main__:Job.status()  -> 'running' (injected)
__main__:Job.status()  -> 'done' (injected)

This is the closest thing to mock's side_effect list, and the deliberate difference is that values and exceptions are kept apart. side_effect=[a, b, Err] becomes returns_from([a, b]) followed by a phase that raises(Err), which is more lines for the same three outcomes but each phase says what it is. Running out with no successor is a loud SequenceExhaustedError at the call site rather than a StopIteration leaking out of the code under test, and the message says to add a phase with then() or supply an endless sequence.

A known sequence of "random" numbers is another use, making code that jitters or samples deterministic without seeding tricks: binding(random, "random").on_call.returns_from([0.1, 0.9, 0.5]).

Advancing from outside

The third way a phase ends is that something other than this binding's own calls decides it should. A bare then() with no condition ends only when the test calls binding.advance(), which also works whatever the exit condition, so a test can force the next phase early. The simplest use is a test that sits between calls:

remote = wrapture.binding(Client, "fetch")
remote.on_call.raises(ConnectionError("down"))
remote.on_call.then().passes_through()

with remote:
    client = Client()

    with pytest.raises(ConnectionError):
        client.fetch("/x")

    remote.advance()
    assert client.fetch("/x")["status"] == 200

The more interesting use is when the trigger lives in a different binding. Here the remote stays down until a health check, itself a binding, reports it healthy, and the health check's own result stage advances the remote:

class Monitor:
    def check(self):
        return "healthy"


remote = wrapture.binding(Client, "fetch")
remote.on_call.raises(ConnectionError("down"))

online = remote.on_call.then()
online.passes_through()

health = wrapture.binding(Monitor, "check")
health.on_call.returns_from(["unhealthy", "unhealthy", "healthy"])
health.on_call.then().returns("healthy")


def note_recovery(result):
    if result == "healthy":
        remote.advance()


health.on_call.validates_result(note_recovery)

Run code that polls the monitor and tries the client each time round, and the tape shows the two scripts interleaving:

__main__:Monitor.check()  -> 'unhealthy' (injected)
__main__:Client.fetch(url='/x')  !! ConnectionError (injected)
__main__:Monitor.check()  -> 'unhealthy' (injected)
__main__:Client.fetch(url='/x')  !! ConnectionError (injected)
__main__:Monitor.check()  -> 'healthy' (injected)
__main__:Client.fetch(url='/x')  -> {'url': '/x', 'status': 200}

Note that a stage such as validates_result() belongs to the phase it was configured on, which follows from phases inheriting nothing from each other. That is why "healthy" is the last value of the phase 0 sequence above rather than the value the successor phase returns; if the stage were on phase 0 and the triggering value only ever came from phase 1, the recovery would never be noticed. A stage that should run in every phase is configured in every phase. When the condition is visible in the binding's own calls, then(until=...) says it more directly than a stage calling advance(), and is the form to reach for first.

Where phases fit in a test

Phases are for behaviour that must change within one call of the code under test, as it happens with a retry loop, a breaker, or a polling wait. A test that sits between calls does not need them; it reconfigures the binding in place, on_call.returns(...) again, and carries on. That is why the decorator form deliberately leaves then() out of its chain: how behaviour changes over time is the test's script, and it is configured in the body through the injected handle, where the phase markers can be given names.

The attribute channels have phases too, on_get in particular has returns_from(), so a module constant can read one way for two reads and then another, which I will come back to in the next post. And passes_through() on a base namespace clears phase 0 only; to drop the whole chain and start again, on_call.reset() is the tool.

What's next

Everything in this series so far has been about calls. The next post is about everything a binding can name that is not a call: attribute reads and writes, a value held in a slot for the duration of a test, the whole content of a settings dict, and what happens item by item as a generator is consumed.

03 Sep 2026 9:39pm GMT

Graham Dumpleton: Beyond callables in wrapture

Every example in this series so far has wrapped a call. A binding named a method, and what flowed through the call was recorded or changed. Plenty of what a test needs to control is not a call, though. An outcome stored in an attribute, an environment variable that must be set or missing, a settings dict that other modules imported by reference at import time, a formatter looked up in a registry, and a generator whose interesting behaviour is spread over its consumption. unittest.mock and pytest between them cover most of this with patch.dict, monkeypatch.setattr, monkeypatch.setenv and so on, one idiom per shape. wrapture spells all of them as bindings, which buys the same lifecycle everywhere, and in a couple of places lets the binding observe as well as hold.

Attribute bindings

A binding on a class attribute which is not a callable is detected as attribute mode, and instead of on_call it has on_get, on_set and on_delete, one channel per operation. Under the covers it installs a data descriptor on the class, wrapping whatever was there before, so a property's getter still runs and writes still land in the instance dictionary. Take a model with a status:

class Model:
    status = "draft"

    def publish(self):
        self.status = "published"

    def archive(self):
        self.status = "archived"

Inside a timeline, reads and writes record as get and set events on the same tape as everything else:

status = wrapture.binding(Model, "status")

with wrapture.timeline(status) as tape:
    model = Model()
    model.status
    model.publish()
    model.status

    print(tape.tree())

    status.events.of_kind("set").with_value("published").assert_once()
get __main__:Model.status -> 'draft'
set __main__:Model.status = 'published'
get __main__:Model.status -> 'published'

That assertion says publish() wrote the status exactly once, without the test knowing anything about how publish() works inside. A get event records the value read in result, the same field a call's return value uses, and a set event records the value written in value.

The channels carry the same kinds of verb as on_call. on_get.returns(value) answers a read without touching the real attribute, on_set.rejects() makes a write an AttributeError, on_set.validates(check) checks a written value and lets it through, and decorates() takes full control with the real operation handed in as a function. A guard on state transitions, which needs the current value as well as the new one, is a decorates():

ALLOWED = {("draft", "published"), ("published", "archived")}

def guard(write, instance, value):
    current = instance.status
    if (current, value) not in ALLOWED:
        raise ValueError(f"cannot move from {current} to {value}")
    write(value)

status.on_set.decorates(guard)

With that applied, publish() on a fresh model works, a second publish() raises cannot move from published to published, and archive() then works. The real write happens through write(value) when the guard allows it. Attribute channels have phases too, so on_get.returns_from([...]) can read one way for two reads and another afterwards.

Two details come up as soon as this is used on real code. An attribute assigned in __init__ rather than defined on the class does not exist when the binding is created, so the binding takes missing_ok=True, and the write made in __init__ is then recorded like any other. And when the attribute is a property whose getter does work, the get event is the parent of whatever that work recorded, which is exactly the question a lazy-loading bug turns on:

class Account:
    def __init__(self):
        self._balance = None

    def load(self):
        return 42

    @property
    def balance(self):
        if self._balance is None:
            self._balance = self.load()
        return self._balance
get __main__:Account.balance -> 42
  __main__:Account.load()  -> 42
get __main__:Account.balance -> 42

The first read triggered the load and the second was served from the cache, which is what the property was written to do, and a test can now assert it.

One limit follows from the mechanism. A descriptor on a class fires for access through instances, so Model.status read off the class itself returns the descriptor without recording, and a class-level write replaces the descriptor outright, which the binding reports by going inactive rather than pretending. The known limitations page has the details.

Module attributes

A module's plain data is detected as attribute mode too, so a constant or a flag on a module gets the same three channels. A module cannot take a descriptor directly, so while a binding on it is applied the module is given a private subclass of its type with the descriptor installed there, and the original type comes back when the last binding is removed. isinstance(module, ModuleType) and inspect.ismodule() are unaffected, and the class is named module so reprs read the same.

What is intercepted is access through the module object. Code that did from config import TIMEOUT at import time holds the value already, and reads through vars(config) bypass the descriptor, which is the same caveat that applies to patching a module attribute with mock.

Value bindings

Often a test does not want to observe anything. It wants an environment variable set, a settings key changed, or a module constant lowered, for the duration of the test and then put back. That is a value binding: name the owner positionally, name the slot with attr= for an attribute or item= for a mapping entry, and say what it should hold. The pricing function below reads its configuration from all the usual places:

config.SETTINGS = {"currency": "USD", "tax_rate": 0.2}
config.TIMEOUT = 30.0
config.FORMATTERS = {"plain": lambda total: f"total={total:.2f}"}

def price(amount, style="plain"):
    if "API_KEY" not in os.environ:
        raise RuntimeError("API_KEY is not configured")
    total = amount * (1 + config.SETTINGS["tax_rate"])
    formatter = config.FORMATTERS[style]
    return f"[{config.SETTINGS['currency']} within {config.TIMEOUT}s] " + formatter(total)

An environment variable is one entry of os.environ, so it is item=. overrides() holds the value while applied, and on exit the prior state comes back, whether the variable existed before or not:

api_key = wrapture.binding(os.environ, item="API_KEY")

with api_key.overrides("sk_test"):
    print(price(100))

print("API_KEY" in os.environ)
[USD within 30.0s] total=120.00
False

The other direction is hides(), under which the slot is absent, which is how the missing-configuration branch gets tested even on a machine where the variable is set. overrides(None) cannot say that, since None is a value that is there. A module constant is the same shape with attr=, and the module can be named by import path so the test needs no import of its own:

with wrapture.binding("config", attr="TIMEOUT").overrides(0.5), api_key.overrides("sk_test"):
    print(price(100))
[USD within 0.5s] total=120.00

A value binding holds a value and observes nothing. It has no channels, no events and no phases, and it says so if you ask for them. The two spellings differ by exactly that: binding("config", attr="TIMEOUT") holds, and binding("config", "TIMEOUT") intercepts. When the question shifts from "hold this value" to "does the retry path re-read the timeout, or did it cache it", the same location upgrades to the interception form and each read becomes an event:

timeout = wrapture.binding("config", "TIMEOUT")
timeout.on_get.returns(0.5)

with timeout, wrapture.timeline() as tape, api_key.overrides("sk_test"):
    price(100)
    price(100)

print([event.kind for event in tape.for_binding(timeout)])
['get', 'get']

Two calls, two reads. price() reads the timeout every time, and the tape proves it.

Everything around bindings applies to value bindings. They are context managers, they can be suspended and resumed, active reports whether the slot still holds what the binding put there so a teardown can see that something else overwrote it, and the pytest plugin's leak sweep reports one left applied. In the fixture shape one binding is applied holding nothing and each test says what the slot should be, api_key.overrides("sk_test") in one test and api_key.hides() in the next.

Mapping bindings

The settings dict has a complication. Other modules did from config import SETTINGS at import time, so they hold the same dict by reference, and a test that replaces config.SETTINGS with a new dict strands them with the old one. mode="mapping" on the location mutates the one dict in place and never replaces it, so every holder sees the test's content, and the original entries come back on exit in their original order:

SETTINGS = config.SETTINGS      # a holder, as another module would have

settings = wrapture.binding(config, "SETTINGS", mode="mapping")

with settings.updates({"tax_rate": 0.0}), api_key.overrides("sk_test"):
    print(price(100))

with settings.overrides({"currency": "EUR", "tax_rate": 0.1}), api_key.overrides("sk_test"):
    print(price(100))

print(SETTINGS, SETTINGS is config.SETTINGS)
[USD within 30.0s] total=100.00
[EUR within 30.0s] total=110.00
{'currency': 'USD', 'tax_rate': 0.2} True

updates() merges the named keys over what is there, which is patch.dict's default, and overrides() makes the given entries the whole content, which is patch.dict(..., clear=True). Both took effect through the holder's reference and both restored it. Three dict spellings exist for three different intentions: item= for one entry changed or absent, attr= to make config.SETTINGS a different object with holders of the old one unaffected, and mode="mapping" for the one dict to hold these entries for every holder.

Bindings group, and a group applies and removes atomically, so a test that needs several of these pinned at once does it in one declaration, and as a fixture the group is a with around a yield:

pinned = wrapture.bindings(
    api_key=wrapture.binding(os.environ, item="API_KEY").overrides("sk_test"),
    settings=wrapture.binding(config, "SETTINGS", mode="mapping").overrides({"currency": "EUR", "tax_rate": 0.0}),
    timeout=wrapture.binding("config", attr="TIMEOUT").overrides(0.5),
)

with pinned:
    print(price(100))
[EUR within 0.5s] total=100.00

A callable held in a mapping

The formatter registry is configuration too, a callable in a dict. A value binding could swap the entry wholesale, but naming the entry with mode="callable" wraps it instead. The stand-in is installed in the slot, records like any bound callable, has phases like any bound callable, and the original entry comes back on removal:

loud = wrapture.binding(config.FORMATTERS, item="plain", mode="callable")
loud.on_call.transforms_result(str.upper)

with loud, api_key.overrides("sk_test"):
    print(price(100))

print(config.FORMATTERS["plain"](120.0))
[USD within 30.0s] TOTAL=120.00
total=120.00

The real formatter ran and its result was adjusted on the way out. This reaches a handler in a dispatch table with the whole call vocabulary, which is something that previously needed the callable to be pulled out and wrapped by hand.

Generators and iteration

A callable that returns a generator produces its values later, one at a time, as the caller iterates. That changes both what recording means and what behaviour can do. Take a paginated catalogue and two consumers, one that reads to the end and one that stops as soon as it finds what it wants:

class Catalogue:
    def __init__(self, records, page_size=2):
        self.records = records
        self.page_size = page_size

    def pages(self, cursor=0):
        while cursor < len(self.records):
            yield {"cursor": cursor, "items": self.records[cursor:cursor + self.page_size]}
            cursor += self.page_size


def collect_ids(pages):
    ids = []
    for page in pages:
        ids.extend(item["id"] for item in page["items"])
    return ids


def first_match(pages, predicate):
    for page in pages:
        for item in page["items"]:
            if predicate(item):
                return item
    return None

A test that hands the consumer a canned list of pages proves it can add up ids and nothing else. A list is never lazy, cannot be abandoned, and cannot fail between items, so the properties a streaming consumer is written to have are exactly the ones such a test cannot check.

Binding the generator method records one event covering the whole iteration, not one per page, and the event's items field counts what was pulled through it. Reading to the end fills in result with the generator's return value, None here:

pages = wrapture.binding(Catalogue, "pages")

with wrapture.timeline(pages) as tape:
    collect_ids(catalogue.pages())
    event = pages.events.first
    print(event.items, event.result)
3 None

Stopping early looks different. first_match() finds id 3 on the second page and returns, dropping the generator before it is exhausted. The event closes with the item count reached and no result at all, wrapture.MISSING rather than None, and no -> in the tree, which is the honest signal that the iteration never finished:

with wrapture.timeline(pages) as tape:
    first_match(catalogue.pages(), lambda item: item["id"] == 3)
    event = pages.events.first
    print(event.items, event.result is wrapture.MISSING)
2 True

That already answers "how far did it read" and "did it finish" without touching the consumer. Item values are deliberately not captured on the tape, since a long stream would retain every item and no policy can guess which ones matter. When a test wants to see the items, or react to them, it says so with an iterator proxy. iterator() creates a factory with no target, behaviour is configured on its channels, and calling the factory with a generator returns a wrapped generator applying that behaviour. Since the factory takes an iterator and returns one it slots straight into the binding's transforms_result():

cursors = []
outcomes = []

watch = wrapture.iterator()
watch.on_item.validates_item(lambda page: cursors.append(page["cursor"]))
watch.on_finish.validates(lambda value: outcomes.append(("finished", value)))
watch.on_abandon.notifies(lambda: outcomes.append(("abandoned", None)))

pages.on_call.transforms_result(watch)

with pages:
    collect_ids(catalogue.pages())
print(cursors, outcomes)

cursors.clear(); outcomes.clear()

with pages:
    first_match(catalogue.pages(), lambda item: item["id"] == 3)
print(cursors, outcomes)
[0, 2, 4] [('finished', None)]
[0, 2] [('abandoned', None)]

on_abandon fires when a started, unexhausted generator is closed, whether explicitly or because the consumer dropped it and the garbage collector closed it. That is the question nothing else can see asked: the loop that stopped early, the generator left half-consumed. The proxy also has on_error for an iteration that raised, and on_item.transforms_item() to rewrite each item on its way through.

An item stage that raises fails the iteration at that point, as if the generator itself had raised while producing that page, which is how to test what a consumer does when page two fails to arrive:

def fail_at(position, exc):
    seen = 0

    def check(page):
        nonlocal seen
        seen += 1
        if seen == position:
            raise exc

    return check

flaky = wrapture.iterator()
flaky.on_item.validates_item(fail_at(2, OSError("page 2 failed")))
pages.on_call.transforms_result(flaky)

With that applied, collect_ids() receives the first page and then an OSError on the second, and a consumer written to cope with that can be tested doing so.

One lifecycle for all of it

The thread through everything here is that whichever shape a patch takes, it is a binding, and everything that applies to a binding applies to it. It is a context manager and it has a decorator form. It groups with other bindings and the group applies and removes as one unit. It can be suspended and resumed, it knows whether it is still in place, and the pytest plugin's leak sweep reports it if a test forgets to remove it. Where the shape allows it, the same binding that holds a value can be upgraded to one that sees who reads it, and a callable pulled out of a dict gets the same phases and recording as one on a class.

The monkey patching guide is the full reference for every binding mode, and the worked examples on pinning configuration, checking that resources are released and testing generators and streamed results each take one of the questions above further than a blog post has room for.

03 Sep 2026 9:39pm GMT

02 Sep 2026

feedDjango community aggregator: Community blog posts

Django and deployments

I have been pondering the wider deployment space in Django for a while and from various angles. This includes my released package django-prodserver but also wondering if the DSF could provide hosting as a small scale commercial operation or what via alternatives I could offer in hosting for Django specifically. Then also I have considered what the wider API in Django could be for deployments.

These thoughts come at a good time, Will Vincent has done two talks on deploying python projects this year and I think his talks would serve as a great theoretical starting point to ensure we cover 90% of what is required. Then after DjangoCon US last week, Paolo made toot suggesting it's time for a deploy command. That toot triggered two things, first a memory of the chats I had in Athens this year and DjangoCon Europe and that I had been meaning to write about this topic for a while.

First let's consider the high level conceptual stages when deploying a project:

  1. Prepare the overall environment - signing up for an account, creating a project or just booting up a VPS
  2. Prepare Django and it's settings - these are changes made to the project repository
  3. Get the Django project from source control to the environment
  4. Do the first time setup - ideally this would be idempotent.
  5. Start the production process
  6. Doing a second deployment - because code always changes and then repeat step 5.

From this list, I think a single managed.py deploy might be too much magical to begin with, but I do think it's possible eventually. I'm thinking it's more likely deploy to be a command that stitches together several lower level commands and each of those commands correspond to a step in the above list. So we could have something like:

  1. manage.py init_deploy_env
  2. manage.py productionize
  3. manage.py deploy_project --first
  4. manage.py initialize --production
  5. manage.py prodserver web and manage.py worker
  6. manage.py deploy_project

A couple of very important points, first those names are simply examples for this post to communicate the idea and perhaps it would be best to have them all within a namespace of deploy, so manage.py deploy productionize etc.

Second and most importantly, I am very aware of the numerous possible combinations that exist when it comes to how a project can be deployed today and I am very much NOT suggesting Django support any of them. What I am suggesting is that we focus on the common API inside Django and we have packages and plugins like Eric has with django-simple-deploy. My approach here would be create an API that explicitly does nothing but simply prints expected inputs and outputs from each step. We can then start to automate the parts worth automating in a package, which may get us to a single deploy command.

Let me know your thoughts! As the maintainer of django-prodserver I have a vested interest in this space! :D

PS It's worth noting that there have been years of packages that have done similar things and we should use as reference, django-production is one such package or dj-lite for sqlite configuration in production.

02 Sep 2026 5:00am GMT

01 Sep 2026

feedDjango community aggregator: Community blog posts

"Premature" optimization

"Premature optimization is the root of all evil" - our field's favorite half-sentence, quoted far more often than the sentence it was cut from. Usually it serves as permission: build it fast, profile later, fix a thing or two, done. Let's put the sentence back together and ask what it licenses: when is optimization premature, and when is "premature" the excuse?

“Premature” optimization

01 Sep 2026 10:00am GMT

28 Aug 2026

feedDjango community aggregator: Community blog posts

Issue 352: PyCharm & Django Fall Fundraiser


News

PyCharm & Django Fall Fundraiser

Buy or renew an annual PyCharm Professional license through the campaign link and you get 30% off while JetBrains donates a matching amount to the DSF, which is working to close the gap between the roughly $300,000 it raises each year and the $500,000 that would make a full-time Executive Director sustainable. The campaign runs through September 10, 2026, so buy or renew before then. A renewal adds 12 months to your existing subscription.


Django Software Foundation

DEP 0020: Annual Release Cycle

Django moves to one feature release each January under YYYY.N calendar versioning, starting with 2028.0 in place of what would have been 7.0. Every release now gets a year of mainstream support and two years of security fixes, which retires the LTS label.

The Block and Tackle of Django's Code of Conduct Working Group

The machinery behind Django's move to Contributor Covenant 3.0: a 30-day public comment period enforced by a GitHub workflow, CODEOWNERS gating changes to the CoC text, and an automated decision changelog. The working group is releasing its case-tracking templates under CC BY 3.0 for other projects to adopt.


Python Software Foundation

RISC-V is now officially supported by CPython!

CPython now supports RISC-V at tier 3, the entry level for new platforms, which means the open instruction set architecture gets ongoing testing on real hardware through buildbots donated by the RISE Project. Stan Ulbrych led the work with Ludovic Henry, Furkan Onder, and Emma Smith, backed by a Sovereign Tech Agency fellowship.


Wagtail CMS News

Wagtail 8.0

A read-and-write v3 REST API, custom base page models, a global permission policy registry, and formalized Django 6.1 support. Two smaller wins: StreamField block IDs are now available as template context variables, and AVIF and WebP images are no longer converted to PNG by default.

Wagtail security releases: 7.0.9, 7.3.4, and 7.4.3

7.0.9, 7.3.4, and 7.4.3 carry the same five fixes as 8.0: permission handling in the Pages, Documents, Images, and translation APIs, document identification by SHA1, and snippet copying. Upgrade to the patch release matching your version.

Streamlining content ops with LLMs: Wagtail user guide

Google Summer of Code contributor Raghad Dahi rebuilt the user guide site, retiring a versioning scheme that made editors duplicate the whole site per release in favor of blocks readers can filter by version. For translations, an evaluation suite scored LLM providers on cost and quality before settling on DeepSeek V4 Flash, now covering 52 languages plus right-to-left support.

CMS with AI, not AI CMS: Wagtail 8.0's new API

The thinking behind that API: rather than bolting AI buttons onto the admin, Wagtail exposes 50+ admin operations with OpenAPI docs and Markdown rich text, drivable from curl, a script, or an MCP implementation. Worked examples include fixing SEO descriptions with an LLM and a generic content importer in about 100 lines.


Django Fellow Reports

Django Fellow Report - Jacob

Jacob Tyler Walls filed an early report before heading to DjangoCon US, with his usual prolificacy triaging three tickets, reviewing nine, and authoring seven. In addition, engaged with regular security reports and provided 1-1 mentoring to his GSoC mentee, Pravin.

Editors Note

All 3 Fellows are at DjangoCon US this week, so no formal report from Sarah or Natalia. All 3 also gave excellent talks that we will link to when the videos are available later this year.


Articles

Fuzzy String Matching in Django and PostgreSQL

Four ways to match misspelled and variant names, with the trade-offs spelled out. It expands on the author's DjangoCon US 2026 talk on search-as-you-type across 54 million names.

Modern Django Deployments in 2026: My DjangoCon US 2026 Conference Talk

The slides and notes from Will Vincent's recent talk on deployments.

Nifty Django Feature: Third-Party Packages

A helpful overview of Django's third-party package ecosystem, where to look, and how to apply it in your Django projects.

When Python is Too Slow

An opinionated guide on where to turn when Python feels too slow in your application (the answer isn't always switch to Rust).

The Move to Python 3 Begins!

CCP is moving EVE Online's 2.4 million lines of Python off Stackless Python 2.7, in place since 2010, with the first changes deployed on August 25. Stage one leans on automated tools to make the code compile under both versions: 95.9% of roughly 20,000 files already do, leaving about 3,300 blocking lines (1,500 print statements, 800 long literals like 123L, 600 old-style except clauses) and another 20,000 lines that compile but behave differently and need a human to look at each one.

Core Dispatch #10

A roundup of CPython development from August 5 to 27: Python 3.12.14, 3.11.16, and 3.10.21 shipped on the 12th, 3.15.0 release candidate 2 is due September 1, and six PEPs moved, including PEP 805 on safe parallel execution and PEP 833 reaching Final for the simple repository API. Most of the discussion energy went to the competing module export proposals in PEPs 842, 843, and 844.


Events

DjangoCon Europe 2027 in Austria!

Five days of Django, Python, and community in Innsbruck, Austria, February 17-21, 2027.

DjangoCon US 2027 in Riverside, California

Join us for five days of inspiration, education, and networking at the Riverside Convention Center in beautiful Riverside, California, September 13-17, 2027.

Call for Organizers: DjangoCon US 2027

That Riverside conference needs people to run it, and more than 15 committees are recruiting, from program and sponsorship to Code of Conduct, A/V, website, and sprints. Leadership roles carry the heaviest load, with weekly check-ins and monthly board reports, but most positions do not require previous organizing experience: email hello@djangocon.us to volunteer.


Django Forum

Public thanks to our 3 Fellows

A short forum thread appreciating Django's Fellows.


Podcasts

Django Chat #205: Django Developers Survey 2026

A special summer episode on the just-released 2026 Django Developers Survey, working through what it says about Django 6.1, HTMX, async, AI, deployment, testing, and Python tooling.


Django Job Board

Two foundation roles anchor the board this week, with the DSF hiring its first Executive Director and the PSF looking for a Security Developer, alongside two full stack engineering openings.

Full Stack Software Engineer (Hybrid) at Provision

Executive Director at Django Software Foundation

AI-Assisted Software Engineer, Web Applications at Logical Media Group

Security Developer at Python Software Foundation


Projects

15r10nk/matchify

Converts eligible if/elif/else chains into Python 3.10+ match statements, preserving runtime behavior and source formatting, including isinstance checks that become class patterns with attributes.

matiasb/django-tasks-fennel

A Django Tasks backend which uses Celery as its underlying queue. Mentioned as part of the author's talk at DjangoCon US this week: Teach Django Tasks to speak Celery: Building a Celery backend for Django Tasks.

28 Aug 2026 3:00pm GMT

06 Aug 2026

feedPlanet Twisted

Hynek Schlawack: Production-ready Python Docker Containers with uv

Starting with 0.3.0, Astral's uv brought many great features, including support for cross-platform lock files uv.lock. Together with subsequent fixes, it has become Python's finest workflow tool for my (non-scientific) use cases. Here's how I build production-ready containers, as fast as possible.

06 Aug 2026 12:00am GMT

23 Jun 2026

feedPlanet Twisted

Glyph Lefkowitz: Adversarial Communication

As I have discussed in previous posts, "AIs" can make mistakes. In fact, they do make mistakes, and their mistake-making patterns are such that where and how they will make mistakes is both uncertain and constantly changing.

Thus, in any scenario where you want to attempt to make "productive" use of "AI", you must have a system in place for checking every result. Not checking some results; checking every result. If each result might have a consequence for you (and if it didn't have a consequence, why bother automating it?) and you cannot predict in advance which kinds of results will need verification, then verification is always required.

The verification often ends up being just as expensive as doing the work in the first place, which means that if you want your usage of "AI" to be personally profitable, you have to find someone else to externalize the cost of verification onto. This person becomes your adversary, and, if you are successful, your "AI's" victim.

The Ladder-Climber And Their Reverse-Centaur Rungs

One way that this constellation of facts can straightforwardly assemble themselves into a dystopian nightmare is the phenomenon, described by Cory Doctorow, of the reverse centaur. This is when your employer non-consensually turns you into the verification system. The "AI" does the fun part of initially performing the work, and then you do the boring part where you check if the robot is right and clean up its messes, even if everyone already knows that it would, in aggregate, be cheaper for you to do the work in the first place.

Reverse centaurs can be made from any automation, not only "AI" automation. I think that there is a reason that this term happens to have emerged in the "age of AI", though, and not with earlier automation technologies (even those which were considerably more viscerally horrific). That reason is: the wrongness of "AI" output is not merely a technical feature that must be compensated for, it is a generalized externality.

As I mentioned above, if you are responsible for the entirety of the work, both extruding the "AI" output and checking it, it's usually cheaper to have humans do the entirety of the work to begin with. When humans do the writing directly, we can check as we go, and thus verification doesn't need to be as comprehensive.

When "AI" coding advocates say "code review is the bottleneck", what they are observing is that the LLM is still rolling the dice for each PR, and a human is still necessary to verify that each of those rolls is a winner. But calling this process "code review" is a bit of a misnomer; it's not really "code review" in the traditional sense, it's human understanding.

Before the advent of "AI", the human understanding was implicit in the process of writing the code in the first place1, and the code review was a way of diffusing and extending that understanding. Now that the code can be authored with no initial understanding taking place, that cost has not gone away, it has moved.

Human understanding was always the bottleneck.

However, this is taking a collaborative view of a software project, where satisfying the needs and solving the problems of your customers are the goals. We can see that "AI" is a bad tool to satisfy those goals, because all it's doing is converting the first half of the work, that of understanding the code as you write it, to understanding the agent's output as you read it.

What if, instead, we were to take the view that every software company is a Hobbesian nightmare, red in tooth and claw? In this view, the only goal of a software project is for the individual developers to make their promo cycles and get their bonuses. Given that there is only a certain amount of money to go around, this is a zero-sum game where each programmer wants to look more productive than their colleagues.

Pretty much every organization finds it easy to reward "productivity" as expressed by lines of code emitted, but the benefits of doing thorough and thoughtful design, analysis, and code review very difficult to reward. In this world, an LLM is an invaluable tool for the sociopathic ladder-climber, particularly if your legacy organization is still structuring their workflows as if the person prompting the bot is "writing" the code, and then they get to foist off the act of "reviewing" the code onto someone else.

Here, the prompter effectively externalizes the cost of the LLM's failures but internalizes any benefits. The prompter will vibe-code a big feature, so large that the assigned reviewer can't possibly comprehend it all effectively. When this happens, the reviewer will, eventually, be pressured to approve it, even if they can try to spot a few problems along the way. The reviewer has their own work to get back to, after all, the obligation to review the prompter's (read: the bot's) code is a drain on their time that they are not going to get rewarded for.

If this feature is a big success, the prompter gets a promotion. If it causes a big issue, well, the reviewer must not have been careful enough.

This is why LLMs are "good for coding", and also why their biggest promoters keep having outages.

The Generative Gish Galloper

Coding is the biggest "success story" of this type of adversarial communication, but it is by far not the only instance of such a thing. LLMs create a new form of leverage that can turn Brandolini's law from a linear advantage into an exponential one. If you are engaged in a political debate where you want to overwhelm the other side in nonsense, an LLM can generate bullshit faster than it is physically possible for a human being to type, let alone respond thoughtfully. There is an asymmetry to the utility of this weapon as well: only one side of the political spectrum wants to flood the zone and destroy trust in institutions and the concept of truth. There's a good reason that the fascists love it.

Straightforward Spam and Fraud

This is kind of obvious, but LLMs can generate lightly-customized, plausible-looking text much more quickly than any human being. This facilitates their use in fraud, spam, and scams. In a spamming or fraudulent interaction, once again, the costs are externalized onto the victim: the recipient of a spam message has to do all the work of "checking" the LLM's output. Spammers already expect very low hit rates from boilerplate, and if the LLM can increase those percentages from 1% to 5% the technology will pay for itself; they don't need anything like reliable accuracy.

Customer "Support"

If you have any kind of commercial relationship with a company, I probably don't even need to mention this: customer "support" bots are a misery. Everybody knows it at this point. But customer support is usually conceptualized by businesses as an adversarial interaction, because it is a cost center. They maintain internal metrics on time-to-resolution and try to optimize them. Implicitly, this creates a dynamic where the goal of the customer service agent's job is not to solve your problem, but to emit noise that will cause you to think your problem is resolved, or to give up, as fast as possible. Unsurprisingly, LLMs can emit this noise faster than humans can, getting those customers off the phone. But those customers will remember those interactions, and the story outside the TTR metrics is horrible.

Similarly to the situation in software development, LLMs can look very good on paper for customer support, but mostly what they are doing is illuminating the problems with the industry's existing metrics, by turning "winning the metrics battle against the customer" into a more obvious and immediate defeat for the company's long term reputation.

"Education"

In 2026 it is sadly a fact of life that students cheat all the time using "AI", and that this cheating is very successful, in that the teachers find it very hard to detect.

LLMs are great for cheating on schoolwork because the student is externalizing the work of the checking onto the teachers, who are often starting at a disadvantage to begin with, at least in the US.

My view is that this is happening because of a divergence in the way that students vs. teachers (or, more accurately, "the broader educational system") view grading.

When a student is asked to write an essay, the teachers see the effort as both intrinsically worthwhile for the student, as well as useful as a pedagogical tool to evaluate and react to the student's progress. The student, by contrast, sees a stumbling block designed to knock them off the path to success and into a permanent underclass. It is no wonder that the student sees "AI" as useful to their own goals and has no compunction about deploying it.

There is a bitter irony that the ability to understand the inherent value of actually writing the essay on their own is the sort of thing that students can really only learn by writing a bunch of essays. There's no way that I can think of which makes the benefit legible as long as a shortcut is available.

The net effect here is a downward spiral, where the already-wobbling educational system is sustaining an attack that it doesn't have the resources to recover from. The individual students' attacks against their teachers and their schools' grading systems might appear to momentarily succeed, but they will win the battle and lose the war.

Spamming "For Good"?

Usually when we talk about someone unilaterally choosing to enter into an adversarial relationship, that's an "attack" and for good reasons we have a negative impression of the attacker. However, I would be remiss if I did not point out that there are some cases where the relationship was already adversarial; just because you're the attacker doesn't mean that you are evil.

For example we might imagine use-cases like automatically filing appeals for prior authorizations against health insurance. It's relatively well-known at this point that the main way for-profit insurers maintain their margins is by denying claims right up to the line of the policies themselves being fraud, so using a spamming tool to fight them might be entirely justifiable2 in that case.

Similarly, using an LLM could be justified in a fight against a company refusing to honor a warranty. One could imagine using an LLM to immediately generate replies and escalations.

However, even in imagined cases like these, the underlying problem is that the insurers and the vendors already have a tremendous amount of structural power, so it is more likely that they will have the advantage in deploying a communications weapon like an LLM, as well as enacting policies to simply ignore any LLM-based communication that you might submit. Worse, if these strategies were to become widespread, they might provide an excuse to reject any communications by feeding them into an unreliable "LLM detector" and issuing an automated "computer says no" even to hand-written correspondence.

It is also worth stressing that these cases are imagined, as compared to the very real coworker-abuse, spam, scam, fraud, and disinformation campaigns being waged in real life today.

Therefore, while legitimate uses might exist, it's hard to imagine that there's anywhere they would be genuinely valuable and sustainable. In the best case "AI" will provide a temporary advantage for underdogs that will provoke an arms race which the resource-advantaged adversaries will win in the long run, in the worst case the arms race itself will cement permanent structural change that will make things worse.

"Search" By Stealing

Most of the adversarial utility of "AI" is on the "write" side, since write-amplification is more obviously aggressive than reading. But the "read" side of LLMs - summarization and question-answering - can be a form of attack as well.

To begin with, the act of reading itself is currently enormously destructive, but that's arguably not a fundamental aspect of this technology. They could set reasonable rate-limits and respect things like robots.txt, as search engines have for decades now. They could also refrain from committing criminal levels of copyright infringement. But, today, using "AI" tools does suborn this sort of out-of-control crawling.

More insidiously, consider the scenario described in this YouTube video. The LTT Bros decided to try Linux again, and in the course of so doing, they had problems. When trying to solve these problems, they were faced with a choice: they could consult Reddit, or they could ask an LLM. Asking an LLM would "gaslight the heck out of" them, but they still found it preferable, because they would at least get an answer without getting yelled at.

Initially this sounds great. But it also means that you want to extract knowledge from a community, while mechanically eliding any values or norms that the community may want to impart as part of offering that knowledge. As someone who spent many years in a community tech support role, this is worrying. Many requests for support are people asking how to do things that will momentarily solve a superficial problem but create a long-term reliability problem or even an immediate security risk, that the question-asker doesn't want to hear about. Consider the question "I'm tired of entering my password so much, how do I make it so my laptop unlocks automatically". An obsequious chatbot will helpfully tell you how to do this without pushback.

But, this is also a sort of ethically murky area. The Linux community is somewhat famously, for many years now, a toxic cesspool of general hostility, misogyny, etc. It is certainly a good thing that people can get access to this knowledge without subjecting themselves to abuse. But it also means that the people with the power and the privilege to change the community for the better can just quietly withdraw, rather than fixing the problems. It also means that the positive elements of culture cannot be transmitted, and people will have no opportunity to learn about unknown unknowns.

In this case, the "adversarial" communication is with society. The thing that using an LLM for search lets you do is withdraw from society and avoid forming any personal connections. There are some personal connections which are painful and annoying, and so that can feel like a momentary balm. But the need to make connections in general is, like, the concept of society itself.

Who Am I Hurting?

LLMs are good at adversarial communication. They are so good at it, relative to their other benefits, that they will tend to make communications adversarial if you are not remaining vigilant about the possibility that it might do so. My request to you, dear reader, if you are going to use such tools, is to always ask yourself, "who might I be hurting, if I use an LLM for this?"

If you're using an "AI", who is its adversary? If you haven't given it one yet, who might the "AI" turn into an adversary? Who might you overwhelm with an asymmetric amount of output, or, if you're receiving information and not sending it, who are you taking that information from without consulting?

Figure out the answers to these questions and conduct yourself accordingly; the answer might be "yourself".

Acknowledgments

Thank you to my patrons who are supporting my writing on this blog. If you like what you've read here and you'd like to read more of it, or you'd like to support my various open-source endeavors, you can support my work as a sponsor!


  1. One of the reasons that software developers tend to prefer greenfield development is that when you are given a blank page, you can project your own specific understanding onto it. You can structure the codebase in a way that works for your brain, down to the variable naming conventions and the module layouts. LLM-assisted development makes everything into instant brownfield work, which makes developers instantly miserable; even those who are excited about the technology will frequently complain about how it feels like their agency has been stolen and their joy in the work has been diminished. But I digress.

  2. Modulo the massive amount of other externalities involved in using LLMs, of course, but I don't have the time or energy to get into those here.

23 Jun 2026 8:06pm GMT

09 Jun 2026

feedPlanet Twisted

Hynek Schlawack: How to Ditch Codecov for Python Projects

Codecov's unreliability breaking CI on my open source projects has been a constant source of frustration for me for years. I have found a way to enforce coverage over a whole GitHub Actions build matrix that doesn't rely on third-party services.

09 Jun 2026 12:00am GMT