skip to navigation
skip to content

Planet Python

Last update: May 24, 2026 07:43 PM UTC

May 24, 2026


The Python Coding Stack

1. From Answer to Outcome

Something has shifted in how we use AI. We still talk about “chatbots” and “prompts” and “getting a good answer.” But underneath those familiar words, a different kind of system has been quietly taking shape. One that doesn’t just answer your question. One that does something about it.

This Agents Unpacked series is about that shift. Not the hype version, not the science-fiction version, but the practical reality of what it means when an AI system can act, remember, and persist — when it can take a goal and work toward it, rather than waiting for you to type the next message.

If you have used ChatGPT, Claude, or Gemini to help with your work, you already know the issue: the answer comes back, it’s good, and then... the real work begins. This series is about what happens when the AI can do some of that real work too.


The Half-Done Feeling

You ask an AI chatbot to draft a project proposal. It gives you a solid one — well-structured, sensible, ready to polish. Then you close the chat window, and the proposal lives nowhere. The assistant doesn’t remember it. It doesn’t know where to file it, who needs to see it, or what happened the last time you wrote a similar proposal. If you come back tomorrow, you’re starting from scratch.

Or you ask it to research a topic. It gives you a good summary. But it didn’t check your existing notes first, it didn’t save what it found, it didn’t organise the sources, and it didn’t flag the gaps. The answer is useful. The process is incomplete.

This isn’t a criticism of chatbots. They do exactly what they were designed to do: you ask, they answer. The problem is that real work doesn’t stop at the answer. The proposal needs filing, the research needs organising, the plan needs tracking. The chatbot gave you a great starting point and then left you to do everything that comes after.

That gap between a useful answer and a finished job is where agentic AI enters the picture.


What Changes When AI Can Act

A chatbot is like a consultant you can phone. You describe the problem, they give you advice, and then you hang up and do the work yourself. Good advice (hopefully), but the consultant doesn’t pick up the phone again tomorrow and ask how it went.

An agent is different. It’s more like a capable colleague you’ve given a desk, a filing cabinet, and access to your systems. It has a workspace. It can remember what happened yesterday. It can read files, search the web, send messages, run calculations, ask you questions — not just talk about doing those things, but actually do them. And given a goal, it can work out the steps itself.

The difference sounds small. In practice it changes what you can delegate, what you can automate, and what you still need to do yourself.


The Loop Underneath

Every agent runs on an agentic loop. It sounds technical, but the pattern is surprisingly familiar:

  1. Observe — What is the situation? What did the user ask? What information is already available?

  2. Think — What needs to happen next? Is one step enough, or should this be broken into parts?

  3. Act — Use a tool, look something up, write a file, send a message.

  4. Repeat — Look at what happened, decide whether the job is done, and continue if it isn’t.

A chatbot usually observes your message, thinks once, and replies. That’s steps one through three, then stop. An agent goes further: after it acts, it looks at the result. Was that enough? Did the web search return useful information, or does it need to try again with different terms? Did the first draft cover everything, or are there gaps to fill? Is the job actually done, or is there more to do?

You might be thinking: chatbots already learned to use tools. Isn’t the agentic loop just that, plus one more step? Four items instead of three. But the gap between “can use a tool” and “decides whether to keep going” is not small. It is the difference between a system that performs a task and a system that pursues a goal. The first is impressive. The second changes what you can trust it to do unsupervised.

That continuation — the decision to check, adjust, and keep going — is what makes the loop matter. A single action might solve a simple request. But most real tasks aren’t simple — they need a sequence of steps, each one informed by the result of the last.

The loop is the architecture that turns language into work. Without it, you have a very clever answering system. With it, you have something that can move through a task, make intermediate decisions, recover from partial failures, and stop only when the job is actually done.

Stephen: I don’t get why the ‘Repeat’ step is needed? Wouldn’t the ‘Act’ provide the output I need?

A single action might solve a simple request. But most real tasks aren’t simple — they need a sequence of steps, each one informed by the result of the last. The ‘Act’ step does produce an output. But the output is not the same as the outcome.

After ‘Act’ runs, the agent looks at what happened: Was that enough? Did the web search return useful information, or does it need different terms? Did the first draft cover everything, or are there gaps to fill? Is the job actually done, or is there more to do?

That check — that ‘Repeat’ step — is what closes the gap between a technically complete action and a genuinely finished job. Without it, you have a system that acts and stops. With it, you have a system that works until the job is actually done.

Subscribe now


Tools Are the Hands

An agent isn’t just “a better language model.” It has capabilities — things it can actually do in the world. Those are called tools.

Some tools are built in: read files, write files, run commands, search the web, inspect images. Others are external: send emails, query databases, call APIs, trigger workflows. The specific tools vary by platform, but the principle is the same — tools are the bridge between thinking and doing.

Think of it like this: an LLM on its own is like a brilliant mind with no hands. Tools give it hands. The loop is what decides when and how to use them.


Keeping Track

An agent can also hold information across steps and sessions. It remembers what it has already tried, what worked, what you prefer, what is still outstanding. This is not a personality trait. It is the system keeping relevant context available over time — the same way you rely on a notebook or a project board when you are working on something complex.

Stephen: So when the agent remembers my preferences, that’s like what you’ve done to write this article. You learnt my learning style, read my writing (including my writing about technical writing), absorbed my preferences, so you can adapt how you explain things. Is that roughly right?

That is exactly right. What an agent does with memory is not mysterious. It is practical. The system can store what you told it, what it observed, what it tried, and what the result was. When you come back, it can pick up where it left off. When it is working on a long task, it can hold the overall goal in view while handling the individual steps. That continuity is what turns a series of disconnected exchanges into something that feels like a sustained conversation.

You do not have to repeat yourself. The agent remembers. That is the difference — not just remembering facts, but maintaining a thread.


A Concrete Example

Let’s make this less abstract.

Imagine you ask for help planning a short research trip to a city you haven’t visited before. You need flights, accommodation, a sense of the neighbourhoods, and a rough itinerary that fits your schedule.

A chatbot might give you an excellent summary of the city, suggest some hotels, and recommend a few neighbourhoods. That’s genuinely useful. But then you have to: check whether those hotels are actually available on your dates, compare prices, figure out which neighbourhood works best for your meetings, build the itinerary around your existing calendar, and keep track of it all so you can adjust later.

An agent can take that same request and do something different.

It might:

The agent doesn’t just tell you about the city. It assembles a usable, integrated plan. It uses tools to search, compare, read your calendar, write the plan, and flag what is missing. It loops through those actions until the trip is actually planned or until it hits something it cannot resolve without your input.

That is the difference between an answer and an outcome.


The Key Transition

The shift to understanding agents is not about capabilities. It is about the move from isolated exchanges to sustained work.

A chatbot gives you an answer. An agent helps you reach an outcome. One is a single exchange; the other is a process. One is clever; the other is useful in a different way — not smarter, but more continuous.

The loop is what makes that continuity possible. Observe, think, act, check what happened, adjust, and continue until the work is done.

Once you see that pattern, you start to notice it everywhere. A junior colleague troubleshooting a problem is running a loop: try something, see if it worked, try something else. A project manager steering a complex task is running a loop: check the status, identify what needs attention, act, and review.
The same pattern appears everywhere in life. A cook adjusts a recipe by tasting as they go. A teacher tries an explanation, sees whether the student understood, and tries a different approach if they didn’t.

Stephen: So, tell me if I got this right: The agent loop is mimicking how we, humans, work.

We understand the problem, get the relevant context, perform an action, look at the result of our action and then decide whether that solves our problem. If it doesn’t, we explore why, come up with a new plan, implement the new actions, and repeat the process.

It feels like the agent is going through the same process.

That is exactly right. The agent loop isn’t some exotic new form of intelligence. It’s a pattern humans use all day every day, made explicit and embedded in a system that can act. The insight isn’t that the AI has become more intelligent. It’s that the AI has gained the ability to persist, to use tools, and to continue — the same things that turn a one-off answer into real, completed work.

That is why this moment in AI feels different from previous ones. It is not that models suddenly became smarter. It is that they gained the ability to take action in a loop, over time, toward a goal.


What This Series Will Do

This series is for people like me (Stephen, not Priya!), who already understand how LLMs work, who have used chatbots like ChatGPT or Claude, and who are now hearing about “agents” and wondering what that actually means. I felt I was lagging in the AI world so I started this learning process to make sure I’m not left behind!

We will move beyond the half-done feeling and into the architecture of agentic systems. We will look at what agents actually are, how they are structured, what tools and skills give them their power, how multiple agents can coordinate, and how to think about trust, evaluation, and oversight.

We will also look at the platforms and frameworks that exist today — what they offer, how they differ, and what tradeoffs you are choosing when you pick one. This is not a manual for any one platform. It is a guide to understanding the category itself, so you can make good choices about whether, where, and how to use agentic AI in your own work.

The field moves fast. Some tools that looked promising six months ago may be superseded by the time you read this. That is fine. The principles matter more than the products. If you understand the loop, the anatomy, and the tradeoffs, you can look at whatever the current landscape happens to be and know what you are seeing.


Here’s the draft Table of Contents of this series in Agents Unpacked. This is likely to change as Priya and I progress through this project:


<< Previous Post: Stephen’s Preface to Agents Unpacked

>> Next Post: Coming Soon

Table of Contents


stephengruppetta.com

May 24, 2026 07:02 PM UTC

Table of Contents • Agents Unpacked

Here’s the planned Table of Contents. This is likely to change as Priya and I work on this:

May 24, 2026 06:19 PM UTC

Stephen's Preface to Agents Unpacked

Like many, I started using chatbots when GPT whatever-version-it-was came out and took the world by storm. It was really not very good at the time (compared to today’s top-end chatbots), but it was clearly the start of something.

But things moved quickly, and I couldn’t quite catch up. I was busy with, you know, actual work, family, and life.

Then I started hearing lots of new terms, lots of new acronyms. I didn’t know what they were. I still don’t know what most of them are.

Then it was all about agents. I remember clearly thinking to myself: “Is this really any different from the ChatGPT-type chatbots?”

And here’s where this new series comes in. I decided to dive into agents and created a few. One of them is a learning tutor agent that I personalised to suit me.

I gave the agent all my tutorials and books. I gave the agent all the articles I wrote about my views on learning and technical writing.

I asked the agent to figure out from all this how I like to learn, how I like to communicate. I teach the way I like to learn, so it’s fine to put my teaching style in the mix. Then, I had a good long chat with my learning tutor agent to make sure we’re on the same page.

I gave a name to my agent (I named all my agents!) My personalised tutor is Priya.

This series is the joint effort between Priya and me to help me understand agents. What are they? How do they work?

Yes, it’s AI-generated content. But...

  1. It’s generated by an AI that’s extremely well-versed in my style of learning and communication.

  2. I had an active role in steering and editing the content. Here’s how...

What you’ll read in the following articles was created using the following process:

  1. Priya researched the topic following my brief and created a course outline.

  2. She drafted the first chapter.

  3. I read through the file, leaving comments and questions along the way, directly within the text.

    1. I marked some comments as private.

    2. I marked some comments as public.

  4. Priya revised the chapter by incorporating my comments and questions.

    1. She deleted the private comments from the text after making changes to address my comments.

    2. She kept my public comments and questions in the text, clearly marked as “Stephen’s questions”, and she answered them directly in the text.

  5. I reviewed the chapter again and left more comments, and Priya revised the chapter again. We iterated through this until I was happy with the final text. And I was happy with the final text when I felt I understood everything in it and all my questions had been answered.

  6. Priya then moves on to draft the second chapter, and the whole process starts again.

I will post these chapters as they emerge from this process. This is how I’m learning this topic. Hopefully, they may help some other people, too.

Here’s the planned table of contents for this Agents Unpacked series. But note, this may change!

Subscribe now

May 24, 2026 06:15 PM UTC


Graham Dumpleton

Async support for wrapt.synchronized

Continuing the tour through the wrapt 2.2.0 release, the last piece worth a closer look is the new async support in wrapt.synchronized. The decorator has been part of wrapt from the start, but until 2.2.0 it only really did the right thing for synchronous code. Applying it to an async def function used to give the appearance of working without actually serialising anything, and the context manager form had no async variant at all. Both are now fixed.

A quick recap of synchronized

wrapt.synchronized is the bundled decorator for ensuring that a callable is only executed by one caller at a time. The lock it acquires is created lazily and attached to the right object depending on what is being decorated: a per-function lock for plain functions, a per-instance lock for instance methods, a per-class lock for class methods or when the decorator is applied to a class body, and so on. None of that bookkeeping is the caller's responsibility.

import wrapt

class Counter:
    def __init__(self):
        self.value = 0

    @wrapt.synchronized
    def increment(self):
        self.value += 1

There is also a context manager form, where you supply the object that should own the lock. The decorator form and the context manager form share the same auto-created lock when they name the same object, so they can be mixed freely:

counter = Counter()

with wrapt.synchronized(counter):
    counter.value += 1

The lock used in both cases is a threading.RLock. That choice matters and I will come back to it.

Where it fell apart on async

Applying the same decorator to an async def method in wrapt 2.1.x looked promising at first glance. The call returned a coroutine, awaiting it ran the body, and nothing raised. It was only when you tried it under contention that the problem became visible:

import asyncio
import wrapt

class Counter:
    def __init__(self):
        self.value = 0

    @wrapt.synchronized
    async def inc(self):
        cur = self.value
        await asyncio.sleep(0.01)
        self.value = cur + 1

async def main():
    c = Counter()
    await asyncio.gather(*(c.inc() for _ in range(10)))
    print(c.value)

asyncio.run(main())

Run under wrapt 2.1.2, this prints 1. Ten tasks all read cur = 0, all sleep, all write 1 back. The lock attached to the instance was a threading.RLock, and it was acquired and released around the construction of the coroutine, not around the awaited body. By the time anything interesting happened, the lock was gone.

The context manager form did not help either. There was no async with support, so writing:

async with wrapt.synchronized(counter):
    ...

failed with an AttributeError complaining about a missing __aenter__. If you wanted serialised access to a shared resource from async code, you were on your own.

What 2.2.0 changes

In 2.2.0 the decorator inspects the wrapped function and picks a different locking primitive when it sees a coroutine function:

import asyncio
import wrapt

class Counter:
    def __init__(self):
        self.value = 0

    @wrapt.synchronized
    async def inc(self):
        cur = self.value
        await asyncio.sleep(0.01)
        self.value = cur + 1

async def main():
    c = Counter()
    await asyncio.gather(*(c.inc() for _ in range(10)))
    print(c.value)

asyncio.run(main())

This now prints 10. The wrapper still returns a coroutine, but the lock acquisition and release happen inside that coroutine using await, so the awaited body is actually serialised across tasks.

The lock attached to the instance in this case is an asyncio.Lock, stored under a different attribute (_synchronized_async_lock) than the synchronous version (_synchronized_lock). A class that mixes synchronous and asynchronous synchronized methods on the same instance therefore gets two distinct locks, which is what you want, because mixing threading and asyncio primitives on the same lock would not work anyway.

The context manager form has gained an async variant alongside the synchronous one. The same call now supports both spellings, picking the right behaviour based on whether you write with or async with:

async with wrapt.synchronized(counter):
    counter.value += 1

For plain async functions, async classmethods, and any other shape that wrapt's decorator machinery already knew how to dispatch on, the same rule applies. If the wrapped callable is async def, you get an asyncio.Lock and an async wrapper. If it is not, you get a threading.RLock and a synchronous wrapper. The choice between them is automatic.

The reentrancy difference

There is one place where the synchronous and asynchronous paths deliberately do not match up: the synchronous lock is reentrant and the asynchronous lock is not. Calling a synchronized method from inside another synchronized method on the same instance is fine in the synchronous case, because threading.RLock allows the same thread to acquire the lock more than once. The async equivalent deadlocks:

import asyncio
import wrapt

class A:
    @wrapt.synchronized
    async def outer(self):
        return await self.inner()

    @wrapt.synchronized
    async def inner(self):
        return "done"

async def main():
    a = A()
    try:
        result = await asyncio.wait_for(a.outer(), timeout=0.5)
        print(result)
    except asyncio.TimeoutError:
        print("deadlocked")

asyncio.run(main())

This prints deadlocked. The same outer then inner chain on synchronous methods would print done and move on.

The reason the async case behaves this way is that the standard library does not provide a reentrant async lock. There is no asyncio.RLock, only asyncio.Lock. Whether one ought to exist has been a recurring discussion on the Python issue tracker and on discuss.python.org for the better part of a decade, and the short version is that there is no agreement.

The case for adding one is the obvious one. Code being ported from a synchronous codebase often relies on the reentrancy of threading.RLock to allow public methods that take a lock to call other public methods that take the same lock. Without a reentrant async equivalent, the same restructuring work has to be done by hand.

The case against is partly about scope (every primitive in the standard library carries a maintenance cost) and partly about the conceptual mismatch between threads and tasks. threading.RLock is reentrant per thread, and a thread is a long-lived identity that a function can simply ask about. The analogous identity in async code is the current task, which is well defined but feels less stable to reason about: tasks are cheap, can be created mid-call, and suspend at every await. A reentrant lock keyed on the current task can paper over genuine design problems where one task ends up holding a lock across an await that gives another piece of code a chance to re-enter, in ways that are much easier to spot when the lock simply refuses to be acquired twice.

There are third-party packages that implement reentrant async locks for people who want them, but wrapt deliberately stays in step with the standard library here. The synchronous side uses threading.RLock because that is what the standard library provides; the async side uses asyncio.Lock for the same reason.

The practical consequence is that the usual workaround for non-reentrant locks applies on the async side. Public methods that acquire the lock should delegate to private helpers that assume the lock is already held:

import asyncio
import wrapt

class Counter:
    def __init__(self):
        self.value = 0

    @wrapt.synchronized
    async def add_two(self):
        await self._incr()
        await self._incr()
        return self.value

    async def _incr(self):
        cur = self.value
        await asyncio.sleep(0.001)
        self.value = cur + 1

async def main():
    c = Counter()
    await asyncio.gather(*(c.add_two() for _ in range(5)))
    print(c.value)

asyncio.run(main())

That prints 10, with the lock acquired exactly once per call to add_two. The pattern is a bit more disciplined than relying on reentrancy, but it makes the locking boundaries explicit, which is no bad thing in async code.

Wrapping up

The full set of changes to wrapt.synchronized is in the changelog, and the decorator itself is documented on the bundled decorators page. The feature is in wrapt from 2.2.0 onwards, with the usual recommendation to grab the latest release from PyPi since there have been follow-up releases on the 2.2.x branch. Issues and questions, as ever, go to the issue tracker on Github.

May 24, 2026 10:00 AM UTC

Reshaping decorated functions with wrapt

Most decorators leave the function's outward shape alone. The same parameters go in, the same return type comes out, and inspect.signature and inspect.iscoroutinefunction give the same answers they would have given for the undecorated function.

Sometimes you want a decorator that actively changes that shape. Adds or removes a parameter. Changes the return annotation. Turns a sync function into something that should be awaited, or runs an async function to completion so it can be called from sync code. The mechanics of doing the work in the wrapper body are usually straightforward. The harder part is making sure that downstream tools, which decide how to call or treat the function based on what introspection tells them, see the shape of the wrapper rather than the shape of the wrapped target.

wrapt has had a partial answer to this for a long time via the adapter argument on @wrapt.decorator. The 2.2.0 release replaced that with a cleaner standalone with_signature decorator and added a new piece, mark_as_sync / mark_as_async, for the calling-convention side that the existing API did not address at all. There are also a couple of convenience bridges, async_to_sync and sync_to_async, that do the bridging and the marking together for the common cases.

What introspection is for

When this post talks about introspection, it means runtime introspection. Specifically, the answers given by inspect.signature, inspect.iscoroutinefunction, inspect.isasyncgenfunction, inspect.isgeneratorfunction and their friends, computed from the function object after the program has started running.

This is distinct from static type checking. mypy and pyright work from source-level type hints before the program runs and rely on different mechanisms (typing.ParamSpec, typing.Concatenate, properly annotated wrapper signatures and so on). The wrapt decorators in this post fix up runtime introspection. They do not, in general, satisfy a static type checker. That is a separate problem with separate tools.

The runtime side matters because a noticeable amount of modern Python ecosystem behaviour is driven by it. FastAPI inspects function signatures to build request parsing and parameter validation. ASGI frameworks ask iscoroutinefunction to decide whether to await a handler directly or dispatch it to a threadpool. pytest-asyncio decides whether to treat a test as async based on the same check. Click and Typer build their CLIs from inspect.signature of the command function. Sphinx and similar doc tooling pull signatures the same way. Each of these is making a real decision based on what introspection says, and if a decorator silently lies about its shape the decision goes wrong.

The signature side: the old way

wrapt shipped a way to handle this from very early on, via the adapter argument on @wrapt.decorator. The argument takes a prototype function whose signature is borrowed and presented as the decorated function's:

import wrapt

def _prototype(payload): pass

@wrapt.decorator(adapter=_prototype)
def inject_session(wrapped, instance, args, kwargs):
    return wrapped("session#1", *args, **kwargs)

@inject_session
def handle(session, payload):
    return f"{session} processing {payload}"

import inspect
print("call result      :", handle("hello"))
print("introspected sig :", inspect.signature(handle))

Output:

call result      : session#1 processing hello
introspected sig : (payload)

The user's handle function is defined with (session, payload). The decorator hides the session parameter and presents the result as (payload), while internally still calling the real handle with a session it provides. inspect.signature reports the shape the user actually sees, not the underlying.

The adapter argument also accepts an inspect.getfullargspec() tuple or a formatted argspec string, and there is a wrapt.adapter_factory(...) helper for cases where the prototype has to be generated lazily.

The catch is twofold. First, the prototype has to be specified on the decorator factory call, which separates it from the wrapper body and makes the whole thing a little harder to read. Second, the adapter argument is being deprecated in favour of the standalone wrapt.with_signature decorator described below.

The signature side: with_signature

The 2.2.0 replacement is wrapt.with_signature. It is a standalone decorator that overrides what inspect.signature reports for a callable, without changing what the callable actually accepts when called. It takes exactly one of three keyword arguments.

The simplest form is prototype=, which borrows the signature from a dummy function:

import inspect, wrapt

def _prototype(payload: str) -> str: pass

@wrapt.with_signature(prototype=_prototype)
def handle(*args, **kwargs):
    return f"session#1 a:{args[0]}"

print("call result :", handle("hello"))
print("signature   :", inspect.signature(handle))

Output:

call result : session#1 a:hello
signature   : (payload: str) -> str

The body of handle accepts *args, **kwargs because the implementation needs to be flexible (or in the decorator case, because that is what the wrapper signature is). Introspection sees the prototype's (payload: str) -> str instead.

The second form is signature=, which takes a prebuilt inspect.Signature object. This is useful when the parameter list has to be assembled programmatically:

sig = inspect.Signature(
    parameters=[
        inspect.Parameter("payload",
                          inspect.Parameter.POSITIONAL_OR_KEYWORD,
                          annotation=str)
    ],
    return_annotation=str,
)

@wrapt.with_signature(signature=sig)
def handle(*args, **kwargs):
    return f"session#2 b:{args[0]}"

The third form is factory=, which takes a callable that receives the wrapped function and returns either a Signature or a prototype. This is the right choice when the presented signature is derived from the wrapped function's own signature. A factory that strips the first parameter, for instance, would let an "inject the first argument" decorator present whatever the underlying function had with the first slot removed:

def strip_first(wrapped):
    s = inspect.signature(wrapped)
    return s.replace(parameters=list(s.parameters.values())[1:])

@wrapt.with_signature(factory=strip_first)
def handle(*args, **kwargs):
    return f"session#3 c:{args[0]}"

When used together with @wrapt.decorator, the stacking matters. with_signature applies to whatever it directly decorates, so the right place for it is at the use site (above the decorator-built decorator applied to the user's function), or baked into a custom decorator factory that applies it to the wrapped result. Stacking it above a @wrapt.decorator-built decorator definition does not propagate the signature override to the final wrapped result.

A clean pattern for an "inject this argument" decorator that uses with_signature looks like:

def _prototype(payload): pass

@wrapt.decorator
def _wrap(wrapped, instance, args, kwargs):
    return wrapped("session", *args, **kwargs)

def inject_session(fn):
    return wrapt.with_signature(prototype=_prototype)(_wrap(fn))

@inject_session
def handle(session, payload):
    return f"{session} processing {payload}"

inspect.signature(handle) reports (payload). The decorator is a regular function that composes wrapt.decorator and wrapt.with_signature explicitly. A bit more code than the adapter= form, but the pieces are more visible, and wrapt.with_signature is itself reusable in plenty of cases that have nothing to do with @wrapt.decorator.

The important property that with_signature only touches argument-shape code-object flags. The calling-convention bits are left alone. That cleanly separates this concern from the next one.

The calling-convention side: the problem

Suppose a third-party decorator (or one you wrote a while back) takes an async def function and produces a sync callable that runs the coroutine to completion. The body of the decorated function is async, but the result is something you call without await. What does inspect.iscoroutinefunction say?

import asyncio, inspect, wrapt

@wrapt.decorator
def run_to_completion(wrapped, instance, args, kwargs):
    return asyncio.run(wrapped(*args, **kwargs))

@run_to_completion
async def fetch():
    return 42

print("call result        :", fetch())
print("iscoroutinefunction:", inspect.iscoroutinefunction(fetch))

Output:

call result        : 42
iscoroutinefunction: True

Calling fetch() returns 42 because the decorator collapsed the async work to a sync call. But inspect.iscoroutinefunction(fetch) still returns True, because introspection sees the underlying async def. Anything that asks "is this a coroutine function?" to decide what to do with fetch will pick the wrong path. An ASGI framework would await it. pytest-asyncio would treat it as async. Each of those is now making a decision that does not match the actual calling convention.

The mirror case is the same shape in reverse. A plain def function wrapped by something that returns a coroutine reads as not-a-coroutine through introspection, but actually requires await.

with_signature does not help here. It is the wrong tool. The calling-convention bits live in different co_flags slots.

mark_as_sync and mark_as_async

The 2.2.0 answer is a pair of small pass-through decorators that adjust the calling-convention bits and nothing else. They do not bridge anything. They only correct what introspection reports about a stack whose effective convention has already been changed by something else.

The async-to-sync case becomes:

import asyncio, inspect, wrapt

@wrapt.decorator
def run_to_completion(wrapped, instance, args, kwargs):
    return asyncio.run(wrapped(*args, **kwargs))

@wrapt.mark_as_sync
@run_to_completion
async def fetch():
    return 42

print("call result        :", fetch())
print("iscoroutinefunction:", inspect.iscoroutinefunction(fetch))

Output:

call result        : 42
iscoroutinefunction: False

And the symmetric sync-to-async case:

import asyncio, inspect, wrapt

@wrapt.decorator
def schedule(wrapped, instance, args, kwargs):
    async def runner():
        return wrapped(*args, **kwargs)
    return runner()

@wrapt.mark_as_async
@schedule
def compute(x, y):
    return x * y

print("iscoroutinefunction:", inspect.iscoroutinefunction(compute))
print("awaited result     :", asyncio.run(compute(6, 7)))

Output:

iscoroutinefunction: True
awaited result     : 42

It is worth being explicit about what the markers do not do. Putting @wrapt.mark_as_sync directly on an async def does not magically make it sync-callable. It only changes what iscoroutinefunction reports. The bridging from one convention to the other has to be done by some other piece of code in the stack. The markers exist so that introspection can be made to match the reality that something else has already established.

Generator nuance

There are four kinds of callable when you consider the generator/coroutine axes together: plain function, sync generator, coroutine function, async generator. The markers handle all four via an optional generator= keyword that takes None (default, preserve), True (mark as the generator variant of the chosen convention), or False (mark as the non-generator variant).

For example, if an inner decorator drains an async generator into a list and presents the result as a plain sync function returning that list, mark_as_sync(generator=False) makes introspection see "plain sync function" rather than the underlying "async generator function". The mirror case for async iterables produced from a sync generator uses mark_as_async(generator=True). Most code only needs the default form, but the option is there when both axes need to move at once.

async_to_sync and sync_to_async

For the common case of actually bridging between conventions, 2.2.0 bundles two convenience decorators. They do the bridging and apply the right marker, so introspection lines up without a separate mark_as_* step.

wrapt.async_to_sync runs the coroutine to completion via asyncio.run on each call, and marks the result as sync:

import inspect, wrapt

@wrapt.async_to_sync
async def add(a, b):
    return a + b

print("iscoroutinefunction:", inspect.iscoroutinefunction(add))
print("call result        :", add(2, 3))

Output:

iscoroutinefunction: False
call result        : 5

wrapt.sync_to_async is the mirror. It schedules a sync function onto the default executor via loop.run_in_executor, and marks the result as async:

import asyncio, inspect, wrapt

@wrapt.sync_to_async
def mul(a, b):
    return a * b

print("iscoroutinefunction:", inspect.iscoroutinefunction(mul))
print("awaited result     :", asyncio.run(mul(4, 5)))

Output:

iscoroutinefunction: True
awaited result     : 20

These are the same family of utility as asgiref.sync.sync_to_async and asgiref.sync.async_to_sync, which Django leans on heavily for mixing sync and async code. The wrapt versions are smaller and pre-marked, which is the convenient thing. If you need richer behaviour, like explicit executor selection or structured concurrency through anyio, the third-party tools are still the right call. In that case you can apply wrapt.mark_as_sync or wrapt.mark_as_async after the third-party bridge to bring introspection into line:

@wrapt.mark_as_sync
@third_party_async_to_sync
async def work(...):
    ...

Composing both axes

The whole point of keeping with_signature and the markers as separate decorators is that they touch different parts of the function's code-object flags and can be combined freely. A decorator that changes both the parameter shape and the calling convention works by stacking them in the natural order:

import inspect, wrapt

def _prototype(payload: str) -> int: ...

@wrapt.async_to_sync
@wrapt.with_signature(prototype=_prototype)
async def handler(*args, **kwargs):
    return len(args[0])

print("signature           :", inspect.signature(handler))
print("iscoroutinefunction :", inspect.iscoroutinefunction(handler))
print("call result         :", handler("hello"))

Output:

signature           : (payload: str) -> int
iscoroutinefunction : False
call result         : 5

with_signature overrides the signature presented by introspection. async_to_sync bridges the async body to a sync call and marks the result accordingly. The two concerns are completely independent and the stacking just falls out of which one needs to be closer to the function (with_signature) and which produces the outer wrapper (async_to_sync).

Wrap up

The summary of where to reach for each piece is short.

For signature changes, use wrapt.with_signature for new code. The adapter= argument to wrapt.decorator still works and is not going away tomorrow, but it is being deprecated in favour of with_signature, which is the cleaner option going forward.

For calling-convention changes, where introspection needs to report a different sync/async/generator answer than the underlying function would suggest, use wrapt.mark_as_sync or wrapt.mark_as_async to correct what introspection reports. Remember that the markers do not bridge anything. They annotate a stack whose effective convention has already been changed.

For the common bridging cases, wrapt.async_to_sync and wrapt.sync_to_async do the bridging and the marking together. For more sophisticated async runtime needs, keep using asgiref or anyio and apply the markers afterwards.

The full set of these tools is documented across signature changing decorators, signature override and calling convention markers and adapters. Release notes are in the changelog, the latest release is on PyPi, and issues go to the issue tracker on Github.

May 24, 2026 08:00 AM UTC

Lazy monkey patching with wrapt

This post is for the people who write APM agents, tracers, profilers, debuggers, and anything else that instruments Python code without asking the user to change it. Everyone else is welcome along.

The reason I want to call out the audience up front is that wrapt was created for this kind of work, and the original purpose is sometimes obscured by how widely the project has been adopted for its decorator API. The decorator side of wrapt (which the recent posts on stateful decorators and per-instance lru_cache have covered) grew out of needing reliable building blocks for monkey patching, not the other way around.

There is a side of wrapt that, until April 2026, had no dedicated page in the official documentation. I have covered it in various conference talks over the years, but that is not the same thing as having proper docs. The mechanism for deferred monkey patching, registering a patch against a module that has not been imported yet, with the patch only applied when the module is later imported, has been part of wrapt from day one. The monkey patching documentation page finally landed in the lead-up to the 2.2.0 release, which also added a small ergonomic piece. A new ? modifier on module names closes the last awkward gap in how the deferred form composes with the convenient decorator syntax.

So this post is amplification of a pattern that has been there all along, not breaking news. The new modifier is just polish.

With Python 3.15 about to ship PEP 810 explicit lazy import syntax, the timing matters. Any instrumentation library that force-imports its target modules at agent startup is now actively undoing user-level lazy imports. That has always been a little impolite for cold-start performance. With 3.15 it becomes a direct conflict with how users want to write their code.

A monkey patching primer

The smallest useful piece of wrapt's monkey patching API is wrap_function_wrapper. You give it a module, the dotted name of an attribute on that module, and a wrapper function. It replaces that attribute with a FunctionWrapper that calls your wrapper around the original.

A timing wrapper on json.dumps looks like this:

import json
import time
import wrapt

def time_call(wrapped, instance, args, kwargs):
    start = time.perf_counter()
    try:
        return wrapped(*args, **kwargs)
    finally:
        elapsed = (time.perf_counter() - start) * 1e6
        print(f"json.dumps took {elapsed:.0f} us")

wrapt.wrap_function_wrapper("json", "dumps", time_call)

print(json.dumps({"a": 1, "b": [2, 3]}))

Output:

json.dumps took 16 us
{"a": 1, "b": [2, 3]}

The wrapped, instance, args, kwargs signature of time_call is the same uniform wrapper signature that @wrapt.decorator uses, and that the stateful decorators post has already shown in the decorator context. That is not a coincidence. The decorator API in wrapt is built on top of this same wrapper mechanism, not the other way around, so the body you would write for a @wrapt.decorator-style decorator is the same body you would write for a monkey patch. Whatever you have learned about writing wrappers in the decorator context carries straight over.

The user code that calls json.dumps does not change. The instrumentation is added entirely by wrap_function_wrapper. That is the whole point. APM agents and similar tools want to add visibility to third-party code without asking the user to modify it.

The forced-import problem

wrap_function_wrapper takes the module name as a string and imports the module to find the attribute to wrap. The act of registering the patch loads the target.

import sys
import wrapt

def trace(wrapped, instance, args, kwargs):
    return wrapped(*args, **kwargs)

print("before:", "xml.etree.ElementTree" in sys.modules)
wrapt.wrap_function_wrapper("xml.etree.ElementTree", "fromstring", trace)
print("after :", "xml.etree.ElementTree" in sys.modules)

Running this prints:

before: False
after : True

For a single module that is mildly wasteful. For an APM agent that supports, say, requests, httpx, urllib3, aiohttp, django, flask, fastapi, sqlalchemy, psycopg, redis, pymongo and kafka-python, importing the agent loads every one of those modules at agent startup, regardless of which the user's app actually uses.

The price shows up in three places. Cold start time gets a noticeable chunk added, which matters disproportionately in serverless and short-lived worker environments where the process lifetime is measured in seconds. Memory holds code that is never going to be called. And the user's own lazy import statements get silently undone, because by the time their code runs the modules are already loaded.

The long-standing answer

The mechanism that solves all three problems has been in wrapt from the early days. The idea originally came from PEP 369, which proposed post-import hooks for the Python standard library. That PEP was withdrawn, but wrapt provides its own implementation via a sys.meta_path finder.

The low-level entry point is register_post_import_hook(hook, name). The hook is a callback that takes the module as its argument and runs once the named module is imported. If the module is already imported when the hook is registered, the hook fires immediately.

The decorator form, when_imported(name), is the one most code uses:

import sys
import wrapt

def trace_reader(wrapped, instance, args, kwargs):
    print("[traced csv.reader]")
    return wrapped(*args, **kwargs)

@wrapt.when_imported("csv")
def install(module):
    wrapt.wrap_function_wrapper(module, "reader", trace_reader)

print("after register:", "csv" in sys.modules)
import csv
print("after import  :", "csv" in sys.modules)
for row in csv.reader(["a,b,c"]):
    print("row:", row)

Output:

after register: False
after import  : True
[traced csv.reader]
row: ['a', 'b', 'c']

Two things to notice. Registering the hook does not touch sys.modules. The module is only loaded when the user's code does import csv. And the wrapping happens automatically as a side effect of that import, so the patched csv.reader is what the user code sees.

This is the mechanism that every reputable APM agent already uses one way or another, because they had to. It just was not very visible from the outside.

The decorator-form gap

wrap_function_wrapper has a more convenient cousin called patch_function_wrapper which is the decorator form. It lets you keep the wrapper definition at module top level rather than nested inside a callback:

@wrapt.patch_function_wrapper("html.parser", "HTMLParser.feed")
def trace_feed(wrapped, instance, args, kwargs):
    return wrapped(*args, **kwargs)

This is the form you really want for a patch registry. One decorated wrapper function per supported third-party target, all at the top level of one file. Easy to read, easy to grep, no nested closures.

The catch, before wrapt 2.2.0, was that this decorator form force-imported its target the same way wrap_function_wrapper did:

import sys
import wrapt

print("before:", "html.parser" in sys.modules)

@wrapt.patch_function_wrapper("html.parser", "HTMLParser.feed")
def trace_feed(wrapped, instance, args, kwargs):
    return wrapped(*args, **kwargs)

print("after :", "html.parser" in sys.modules)
before: False
after : True

The lazy alternative meant restructuring into a when_imported callback with the wrapper defined inside it. Workable but ugly, especially repeated across a dozen targets, and you lose the clean "one decorated function per target" layout that makes a patch registry readable.

The ? modifier in 2.2.0

wrapt 2.2.0 closes the gap by recognising a trailing ? on a module name. With the ?, both wrap_function_wrapper and patch_function_wrapper defer registration via a post-import hook when the target module is not yet loaded. If the module is already in sys.modules, the patch is applied immediately. Same behaviour as before, just without the side effect of forcing the import.

import sys
import wrapt

def trace(wrapped, instance, args, kwargs):
    return wrapped(*args, **kwargs)

wrapt.wrap_function_wrapper("gzip?", "compress", trace)
print("after register (with ?):", "gzip" in sys.modules)
import gzip
print("after import           :", "gzip" in sys.modules)
after register (with ?): False
after import           : True

And the decorator form, which is the case that actually motivated the change:

import sys
import wrapt

@wrapt.patch_function_wrapper("tempfile?", "mkdtemp")
def trace_mkdtemp(wrapped, instance, args, kwargs):
    print("[traced tempfile.mkdtemp]")
    return wrapped(*args, **kwargs)

print("after register (with ?):", "tempfile" in sys.modules)
import tempfile
print("after import           :", "tempfile" in sys.modules)
print("mkdtemp:", tempfile.mkdtemp())
after register (with ?): False
after import           : True
[traced tempfile.mkdtemp]
mkdtemp: /var/folders/.../tmpktve96ix

Under the hood, the ? form is genuinely just shorthand. The implementation in wrapt's patches.py is roughly:

if target.endswith("?"):
    target = target[:-1]
    if target in sys.modules:
        return wrap_object(sys.modules[target], name, FunctionWrapper, (wrapper,))
    def callback(module):
        wrap_object(module, name, FunctionWrapper, (wrapper,))
    register_post_import_hook(callback, target)
    return None

No new mechanism, no new dispatch path. The work is still done by the same register_post_import_hook that has been in wrapt for years. The benefit is purely the authoring style. @patch_function_wrapper("...?", "...") at top level is now an option that previously was not.

Composition with PEP 810 lazy imports

Python 3.15 ships PEP 810 explicit lazy imports. The user can write:

lazy import requests

and the import is deferred until the name requests is first used. The discussion in Lazy imports using wrapt covers the PEP's motivation in more detail.

This raises a question that was not quite so sharp before. If a user's code uses lazy import for a module, and an APM agent registers a non-lazy wrap_function_wrapper for that module, what happens?

# apm_eager.py — simulated APM patches
import wrapt
def trace(wrapped, instance, args, kwargs):
    return wrapped(*args, **kwargs)
wrapt.wrap_function_wrapper("gzip", "compress", trace)
# user_code.py — user's app
import sys
import apm_eager   # APM agent loaded at process startup

lazy import gzip

print("after lazy import:", "gzip" in sys.modules)
gzip.compress(b"hello")
print("after first use  :", "gzip" in sys.modules)

Output:

after lazy import: True
after first use  : True

The user wrote lazy import gzip, but gzip is already in sys.modules by the time their import statement runs. The APM agent loaded it on the user's behalf. Whatever benefit the user expected from lazy import has been quietly undone.

Switching the APM agent to use the ? form fixes it:

# apm_lazy.py
import wrapt
def trace(wrapped, instance, args, kwargs):
    return wrapped(*args, **kwargs)
wrapt.wrap_function_wrapper("gzip?", "compress", trace)

With the same user code as before, this now prints:

after lazy import: False
after first use  : True

gzip is only loaded at the moment the user's code first touches it, and at that moment the patch fires too. Lazy patching and lazy imports compose correctly.

Putting it together: a patch registry

For an APM agent or similar, the practical pattern looks like this. A single file declares all the patches as a flat list of top-level decorated functions:

# my_apm_patches.py
import wrapt

@wrapt.patch_function_wrapper("xml.etree.ElementTree?", "fromstring")
def trace_fromstring(wrapped, instance, args, kwargs):
    return wrapped(*args, **kwargs)

@wrapt.patch_function_wrapper("csv?", "reader")
def trace_reader(wrapped, instance, args, kwargs):
    return wrapped(*args, **kwargs)

@wrapt.patch_function_wrapper("gzip?", "compress")
def trace_compress(wrapped, instance, args, kwargs):
    return wrapped(*args, **kwargs)

@wrapt.patch_function_wrapper("html.parser?", "HTMLParser.feed")
def trace_feed(wrapped, instance, args, kwargs):
    return wrapped(*args, **kwargs)

Importing this module registers all four patches but loads none of the target modules:

import sys
import my_apm_patches

targets = ["xml.etree.ElementTree", "csv", "gzip", "html.parser"]
for m in targets:
    print(f"  {m:30s} {'loaded' if m in sys.modules else 'not loaded'}")

Output:

  xml.etree.ElementTree          not loaded
  csv                            not loaded
  gzip                           not loaded
  html.parser                    not loaded

Whichever modules the user's code actually imports is the set that ends up getting patched. The rest stay out of memory entirely. The agent has paid no cold-start cost for the targets the user does not care about, and the user's own lazy imports continue to do what they say on the tin.

As a bonus, the test story is also better. The instrumentation library's test suite no longer needs every supported third-party package installed just to import the library, only the ones it actually exercises.

What changed and what didn't

Strictly speaking, nothing in wrapt 2.2.0 enables any behaviour that was not possible before. The deferred patching mechanism is the same register_post_import_hook it always was. What changed is the authoring ergonomics. The ? modifier lets you write the lazy version of a patch as concisely as the eager version, including in the decorator form that suits patch-registry files best. And the monkey patching docs page that landed in April 2026 finally makes the mechanism easy to discover.

If you maintain instrumentation code that still force-imports its targets, Python 3.15 is a good prompt to refactor. The change is mechanical. Add a ? to the module name in each wrap_function_wrapper and patch_function_wrapper call. The behaviour for already-loaded modules is unchanged, and for not-yet-loaded modules the patch now fires when (and only when) the user's code actually imports them.

The full release notes for wrapt 2.2.0 are in the changelog. The latest release is on PyPi, and issues go to the issue tracker on Github.

May 24, 2026 06:30 AM UTC

Per-instance lru_cache using wrapt

Following on from the previous post on stateful decorators, there is another small addition in wrapt 2.2.0 worth a closer look. A new wrapt.lru_cache helper has been added that fixes the long-standing issues with using functools.lru_cache on instance methods.

The thing I want to emphasise up front is that wrapt.lru_cache is not a replacement for functools.lru_cache. The actual caching is still done by the standard library implementation, all of its keyword arguments are passed straight through, and the eviction behaviour is identical. What wrapt.lru_cache adds is a thin layer on top, built using wrapt's decorator machinery, that fixes how the underlying functools.lru_cache is applied when the decorated function turns out to be a method on a class.

What lru_cache gives you

functools.lru_cache is a small but very useful decorator. You wrap a function with it and the function's return values are remembered, keyed on the arguments, up to some maximum cache size. Repeat calls with the same arguments skip the function body and return the cached result.

from functools import lru_cache

@lru_cache(maxsize=128)
def expensive(n):
    print("computing", n)
    return n * n

expensive(2)
expensive(2)
expensive(3)

Running this prints computing 2 and computing 3 once each. For pure functions of their arguments this is exactly what you want.

Where it falls apart

It is when you reach for the same decorator on an instance method that things start to go wrong. The standard library implementation has no concept of the wrapped function being a method, so it treats self as just another argument and includes it in the cache key. That single design choice causes three distinct problems.

Problem 1: instances share a single cache budget

from functools import lru_cache

class Computer:
    @lru_cache(maxsize=2)
    def compute(self, x):
        return x * 2

a = Computer()
b = Computer()

a.compute(1)
a.compute(2)
b.compute(1)
b.compute(2)

print(a.compute.cache_info())

The cache is a single shared structure attached to Computer.compute. With maxsize=2, four distinct (self, x) pairs across the two instances are competing for two cache slots. cache_info() reports hits=0, misses=4, currsize=2. With one hundred instances and the default maxsize=128, each instance ends up with rather close to a single cache slot of its own.

Problem 2: cached instances cannot be garbage collected

Because self is part of the cache key, the cache holds a strong reference to it. The instance can never go out of scope while there is a cached entry for one of its method calls:

import gc, weakref
from functools import lru_cache

class Big:
    @lru_cache
    def compute(self, x):
        return x

b = Big()
ref = weakref.ref(b)

b.compute(1)
del b
gc.collect()

print(ref())

ref() returns the original Big instance rather than None. It is still alive, kept around by the cache, with no easy way to find or release it short of calling Big.compute.cache_clear() and dropping every other instance's cached results along with it.

Problem 3: self must be hashable

Cache keys have to be hashable. The standard library implementation therefore requires that self is hashable too. Any class that defines __eq__ without also defining __hash__ is implicitly unhashable, and the decorator will fail at call time:

from functools import lru_cache

class Record:
    def __init__(self, name):
        self.name = name
    def __eq__(self, other):
        return isinstance(other, Record) and self.name == other.name

    @lru_cache
    def upper(self):
        return self.name.upper()

Record("a").upper()

That raises TypeError: unhashable type: 'Record'. None of the function's actual arguments are involved in the failure; it is purely about self.

The wrapt version

The wrapt.lru_cache helper sidesteps all three problems by recognising when the decorated callable is being invoked as a method, and arranging for a separate functools.lru_cache-wrapped helper to exist for each decorated method on each instance. The helper is stored directly on the instance under an attribute named after the wrapped method, so for a method called compute the cache lives at instance._lru_cache_compute. The cache key is built from the genuine arguments only, with self providing the lookup of which cache to use rather than being a participant in the key.

The same three examples now look like:

import wrapt

class Computer:
    @wrapt.lru_cache(maxsize=2)
    def compute(self, x):
        return x * 2

a = Computer()
b = Computer()

a.compute(1)
a.compute(2)
b.compute(1)
b.compute(2)

print(a.compute.cache_info())

Each instance has its own cache for compute with the full maxsize=2 budget. The cache_info() call here returns the stats for the cache attached to a (hits=0, misses=2, currsize=2), not a shared total. Calling b.compute.cache_info() reports its own independent set of numbers. If Computer had several @wrapt.lru_cache methods then each would get its own per-instance cache, stored under a separate attribute (_lru_cache_compute, _lru_cache_other_method, and so on), with no contention between them.

The garbage collection case works correctly because each instance owns its own cache attributes, and when the instance is collected the caches stored on it go with it:

import gc, weakref
import wrapt

class Big:
    @wrapt.lru_cache
    def compute(self, x):
        return x

b = Big()
ref = weakref.ref(b)

b.compute(1)
del b
gc.collect()

print(ref())

ref() now returns None.

And unhashable instances are fine, because self was never part of the cache key in the first place:

import wrapt

class Record:
    def __init__(self, name):
        self.name = name
    def __eq__(self, other):
        return isinstance(other, Record) and self.name == other.name

    @wrapt.lru_cache
    def upper(self):
        return self.name.upper()

print(Record("a").upper())

That prints A, with no TypeError.

For plain functions, class methods and static methods (where there is no per-instance state to keep separate) wrapt.lru_cache defers to a single shared functools.lru_cache, so the behaviour is indistinguishable from using functools.lru_cache directly:

@wrapt.lru_cache(maxsize=32)
def factorial(n):
    return n * factorial(n - 1) if n else 1

What is and is not new here

To restate the point at the top of the post, none of this is a new caching algorithm. The eviction strategy, the cache statistics, the keyword arguments, the CacheInfo tuple, the cache_info() / cache_clear() / cache_parameters() methods are all functools.lru_cache, untouched. What wrapt.lru_cache adds is the descriptor-protocol-aware machinery to ensure that for instance methods, the right cache is created and consulted, with no global cache pollution, no reference leaks, and no hashability requirement on the instance.

This is the kind of problem wrapt exists to handle. The recommended way to write a decorator with wrapt gives you a uniform wrapper signature that knows whether it has been called as a function, instance method, class method or static method, and the lru_cache helper is essentially a small, focused use of that machinery to delegate to the standard library decorator in a way that respects the calling convention.

The lru_cache helper is documented over on the bundled decorators page, and the full release notes for the rest of wrapt 2.2.0 are in the changelog. The feature is available from 2.2.0 onwards, although as before it is worth grabbing the latest release from PyPi since there have been follow-up releases on the 2.2.x branch. Issues and questions go to the issue tracker on Github.

May 24, 2026 04:30 AM UTC

Stateful decorators in wrapt

A new version of wrapt was released earlier this week. Version 2.2.0 introduces a small helper that makes it noticeably easier to write decorators that need to keep state across calls. It is the kind of thing that does not look like much until you try to write the equivalent code without it, so it is worth a closer look.

The full release notes are in the changelog. What I want to walk through here is the stateful decorator side of the release, because it touches on something that has always been a bit awkward in plain Python.

Why a decorator might need state

The idea of a stateful decorator is straightforward enough. You attach a wrapper to a function, and the wrapper remembers something across invocations. Counting how many times the function has been called is the canonical example. Other examples include accumulating timing statistics, caching results in a way you want to inspect, tracking which arguments have been seen, or maintaining a registry of what the wrapped function has done.

The complication is not the bookkeeping itself, it is exposing the state back to the caller. If a decorator is purely passive and does its work without anyone ever needing to look at the internals, state can live in a closure and nobody is any the wiser. Once you decide that the user of the decorated function should be able to ask "how many times has this been called?", you need a way to reach into that state from the outside.

The closure approach

The simplest pattern in plain Python is to push state onto the wrapper function as an attribute:

import functools

def call_tracker(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        try:
            return func(*args, **kwargs)
        finally:
            wrapper.call_count += 1
    wrapper.call_count = 0
    return wrapper

@call_tracker
def add(x, y):
    return x + y

add(1, 2)
add(3, 4)
print(add.call_count)

Running this prints 2. That works fine for a regular function, but the moment you apply the same decorator to an instance method things get more subtle. The wrapper itself is still a function, so the descriptor protocol kicks in and self is passed through correctly. The state however lives on the single wrapper object that was created at class definition time, so it is shared across every instance of the class. Whether that is what you want depends on the use case, but you have no real control over it from the way the decorator is written.

The class approach

If you want to keep both the state and the wrapper logic together, the next natural step is to write the decorator as a class:

import functools

class CallTracker:
    def __init__(self, func):
        functools.update_wrapper(self, func)
        self.func = func
        self.call_count = 0

    def __call__(self, *args, **kwargs):
        try:
            return self.func(*args, **kwargs)
        finally:
            self.call_count += 1

@CallTracker
def add(x, y):
    return x + y

add(1, 2)
print(add.call_count)

This works for plain functions. The problem appears when the same decorator is applied to a method:

class Calculator:
    @CallTracker
    def add(self, x, y):
        return x + y

Calculator().add(1, 2)

That raises TypeError: add() missing 1 required positional argument: 'y'. The reason is that Calculator.add is now a CallTracker instance rather than a function. When the attribute is looked up via an instance, the descriptor protocol does not kick in, because instances of user-defined classes are not descriptors by default. The Calculator instance is therefore never bound to self in the wrapped function, and the call sees x as 1 with no value for y.

You can fix this by adding a __get__ method to CallTracker to make it behave as a descriptor, but then you also need to think about whether each access creates a fresh bound version, how classmethod and staticmethod interact with it, what happens when the descriptor is accessed on the class versus the instance, and so on. There is a real amount of code involved in getting all of this right, and it is exactly the code that wrapt exists to provide.

Doing it with wrapt

wrapt handles the descriptor machinery for you. The recommended way to write a decorator with wrapt is to use @wrapt.decorator, which gives you a uniform wrapper signature across functions, instance methods, class methods and static methods. You always get wrapped, instance, args and kwargs, with instance set appropriately depending on how the call was made.

Before version 2.2.0, layering state on top of that meant a little bit of manual plumbing. You had to construct the state object yourself, write the wrapper to close over it, then explicitly attach the state to the wrapper after the fact so it could be reached from outside. Something like this:

import wrapt

class CallTracker:
    def __init__(self):
        self.call_count = 0

    def __call__(self, func):
        tracker = self

        @wrapt.decorator
        def wrapper(wrapped, instance, args, kwargs):
            try:
                return wrapped(*args, **kwargs)
            finally:
                tracker.call_count += 1

        wrapped_func = wrapper(func)
        wrapped_func.tracker = tracker
        return wrapped_func

It is not exactly painful, but it is noisy. You have to remember to assign the state attribute, you have to alias self so the closure captures it rather than something else, and the actual interesting code (the try/finally) is buried under boilerplate.

The new helper

In wrapt 2.2.0 the same decorator can now be written like this:

import wrapt

class CallTracker:
    def __init__(self):
        self.call_count = 0

    @wrapt.bind_state_to_wrapper(name="tracker")
    @wrapt.decorator
    def __call__(self, wrapped, instance, args, kwargs):
        try:
            return wrapped(*args, **kwargs)
        finally:
            self.call_count += 1

The __call__ method is defined directly with the standard wrapt decorator signature, with an extra self at the front so it can reach the state on the CallTracker instance. The @wrapt.bind_state_to_wrapper descriptor sits on top of @wrapt.decorator and takes care of two things. When __call__ is accessed via an instance of CallTracker, it returns a wrapper that knows about the right self. And when that wrapper is applied to a function, the CallTracker instance is automatically attached to the resulting wrapped function under the name supplied in the name argument.

Using it looks like:

@CallTracker()
def add(x, y):
    return x + y

add(1, 2)
add(3, 4)
print(add.tracker.call_count)

The output is 2. Where the previous approaches forced a choice between keeping state with the decorator class and supporting methods correctly, wrapt lets you have both. Applied to an instance method, the same decorator just works:

class Calculator:
    @CallTracker()
    def add(self, x, y):
        return x + y

calc = Calculator()
calc.add(1, 2)
calc.add(3, 4)
print(calc.add.tracker.call_count)

This also prints 2. The wrapper handles descriptor binding correctly, self is passed through to the underlying method, and the state attribute remains reachable on the bound version of the wrapper because attribute lookup on a bound function wrapper now falls through to the parent function wrapper. That last bit is another small change in 2.2.0 that I won't dwell on here, but without it the cleaner syntax above would not be reachable through an instance.

A little extra polish

One refinement worth pointing out is what to do when you want the decorator to be usable both with and without arguments. That is, the @CallTracker versus @CallTracker(call_count=100) distinction. Construction can be wrapped up in a static method on the class:

class CallTracker:
    def __init__(self, call_count=0):
        self.call_count = call_count

    @wrapt.bind_state_to_wrapper(name="tracker")
    @wrapt.decorator
    def __call__(self, wrapped, instance, args, kwargs):
        try:
            return wrapped(*args, **kwargs)
        finally:
            self.call_count += 1

    @staticmethod
    def track(func=None, /, *, call_count=0):
        tracker = CallTracker(call_count=call_count)
        if func is None:
            return tracker
        return tracker(func)

You can now write either @CallTracker.track or @CallTracker.track(call_count=100) and get sensible behaviour in both cases. None of that is specific to wrapt, it is just the usual Python trick for optional-argument decorators, but it composes nicely with the rest.

Why this matters

The reason wrapt exists in the first place is that writing decorators that behave correctly across functions, instance methods, class methods and static methods is harder than it looks. The descriptor protocol, functools.wraps, the inspect module, and the time-honoured Python habit of "just stick it on the function as an attribute" all interact in slightly awkward ways once you try to combine them. The uniform wrapper signature in wrapt removes most of that friction.

What bind_state_to_wrapper adds is the last missing piece for the common case of a stateful decorator. The state lives on the decorator class, the wrapper has direct access to it via self, and the state is exposed back to callers through a named attribute on the wrapped object with no extra plumbing. Documentation for both pieces is over in the decorators guide and the examples page if you want to look at the full set of variations.

The feature is available in wrapt from version 2.2.0 onwards, although you should grab whatever the latest release is from PyPi since there have been follow-up releases on the 2.2.x branch since. If you are coming to this from the Wrapt version 2.0.0 announcement last year, it builds on the same BaseObjectProxy reshuffle that release prepared the ground for. As always, if you find any issues there is an issue tracker on Github.

May 24, 2026 01:55 AM UTC


Armin Ronacher

Building Pi With Pi

Pi is now part of Earendil, but in the important sense it is still Mario’s project. He has been living with its issue tracker longer than I have, and he has been exposed to the weirdness of the new form of agent traffic in Open Source projects for longer too. This post is mostly a reflection of my own experience after spending more time in the tracker, using Pi to work on Pi, and watching what I have learned about it so far.

Slop Issues

Unsurprisingly, we are using Pi to build Pi. That sounds like a cute dogfooding thing but it really helps understand what we do. An interesting effect of building with agents is that it changes the role of the issue tracker a tiny bit. The issue descriptions are not just messages from a user to a maintainer because we also use them as inputs for prompts in Pi sessions. It is something I might hand to my clanker1 and say: “understand this, reproduce it, inspect the code, and propose a fix.”

That means the shape of the issue matters in a new way. A bad issue was always annoying, but at least a lot of issues were vague. Now we are also dealing with a class of issues that are 5% human and 95% clanker-generated and largely inaccurate shit. A bad issue that contains a plausible but wrong diagnosis creates extra work.

The most frustrating failure mode right now is that people submit issues that are not in their own voice. They contain an observed problem somewhere, but it has been thrown into a clanker and the clanker reworded it and made a huge mess of it. Typically, it was prompted so badly that the conclusions produced are more often than not inaccurate but always full of confidence. The result is complete guesswork on root causes, fake-minimal repros, suggested implementation strategies, analogies to adjacent but often the wrong code, and long lists of error classes that might or might not matter.

That is worse than no diagnosis.

I don’t want to point to specific issues because I really do not want to bad mouth anyone, but it is frustrating. It is also frustrating because when I give that issue to Pi, Pi sees the wrong diagnosis too. It does not treat the issue body as a rumor. It treats it as evidence. It will happily go down the path that the issue already prepared for it, because the prose is confident and the code references look plausible. We use a custom slash command called /is, which specifically has this instruction in it:

Do not trust analysis written in the issue. Independently verify behavior and derive your own analysis from the code and execution path.

Unfortunately, it does not fully work, because when humans first throw their issue through the clanker wringer, their clanker expands scope almost immediately. What was once a very narrow and fact based bug observation, turns into a much expanded surface area full of hypotheses. So at least personally, I increasingly want issue reports to be condensed to what the human actually observed:

  1. I ran this command.
  2. I expected this to happen.
  3. This happened instead.
  4. Here is the exact error or log.

That is enough. If you used an LLM to understand the problem, great, maybe leave it as a follow-up comment. But the issue and the issue text should be something you own. If you do not know the root cause, say that. I too can operate a clanker, and I would rather do this myself than use your slop. If your repro is a guess, say that. If the only hard fact is one stack trace, give me the stack trace and stop there.

Slop Begets Slop

That we’re seeing issues full of slop is just a result of the present day quality of these machines. Sadly, their failures in creating good issues extend to a lot of code that is generated. Not all of it, but a lot of code. Over and over I keep running into them over-engineering the hell out of issues and implementations.

If you tell them that “this malformed session log crashes the reader,” the clanker will often add a tolerant reader. Then it will add a fallback, then maybe a migration, then more debug output, then a test for all of this. None of this is necessarily wrong in isolation, but it can be the wrong move for the system.

At Pi’s core is a rather well-designed session log with invariants that must be upheld. The clanker’s present-day behavior is to just assume that no such invariants exist, and instead to make the system work with all kinds of malformedness, blowing up the complexity in the process.

Almost always, the correct fix is not to handle the bad state, but to make the bad state impossible. This matters a lot for persisted data such as Pi session logs. They are opened, branched, compacted, exported, shared, and analyzed. The goal here is to never write bad session data. Yet if you just let the clanker roam freely, it will attempt to handle every case of bad data in the session log with a more permissive reader.

I have complained about this plenty, but working on Pi’s code base continues to reinforce the point. This is one of the ways LLM authored code grows so much needless complexity. All these models see a local failure and try to locally defend against it. As maintainers we have to keep pulling the conversation back to the global invariant, which is harder than it should be, and it’s laborious.

Volume Is The Problem

Then there is the issue of volume. The tracker is receiving a lot of issues and PRs, and a significant fraction of them are clearly LLM-assisted. Some are good, none are excellent, and most are just bad. The total throughput is a maintenance problem by itself.

As you might know, Pi’s issue tracker is automated to close all issues and pull requests from new contributors, and there is a manual process by which we might reopen some of them or approve individuals. So auto-close -> reopen -> close again is an interesting statistic for us to look at.

I pulled the public GitHub tracker data while writing this over the last 90 days. Excluding Earendil members, that leaves 3,145 external issues and pull requests. Of those, 2,504 were auto-closed because they were from non-approved individuals. 17% were reopened. For pull requests the number is worse: less than 10% were merged.

Weekly external volume and acceptance rate of Pi issues and pull requests from February 24 to May 24, 2026, excluding Earendil members, with reopened pull requests shown when they were later merged.

Many of the issues and PRs are complete slop and in some cases the humans did not even realize that they created them. Sources of low-quality spam include OpenClaw instances, as well as some skills that people put into their context that seemingly encourage issue creation.

GitHub clearly is not built to deal with this new form of Open Source, but I’m increasingly feeling the need to put the blame less on GitHub than on all the people involved who make that experience painful. If your clanker shits on someone else’s issue tracker then it’s not the fault of GitHub, it’s yours alone.

Careful Parallelism

Pi might be built with Pi, but we’re quite far off today from where Bun and OpenClaw already are: fully detached, automated software engineering. Maybe we will reach that point, I don’t know. Today it does not seem like we know how to pull off a dark factory and we also don’t yet have the desire. That said, there is quite a bit of parallelism going on, and it is mostly for reproducing issues.

The small setup we use for this is three tiny pieces in Pi’s own committed .pi folder. /is (for analyze issue) is a prompt for analyzing GitHub issues: it labels and assigns the issue, reads the full thread and links, then explicitly tells the agent not to trust the analysis in the issue and to derive its own diagnosis from the code. Then an extension adds a prompt-url-widget which watches the prompt before the agent starts, recognizes the GitHub issue or PR URL that /is (or the PR equivalent) put into the prompt, fetches the title and author with gh, renders that in a little UI widget, and renames the session. It also rebuilds that state on session start or session switch, so if we reopen an older investigation the window still tells the developer which issue it belongs to.

In practice this means it’s possible to have several Pi windows open, each running /is against a different issue, and the UI keeps the investigations visually distinct while the agents do their independent reproduction and code reading. Once the investigations are done, one can work through them sequentially. To finish off everything, /wr (wrap it up) is the matching wrap-up prompt: it infers the GitHub context from the session, updates the changelog, drafts or posts the final issue comment with a disclaimer, commits only the files changed in that session, adds the appropriate closes #... when there is exactly one issue, and pushes from main.

Pi terminal session showing an agent analysis with a GitHub issue widget displaying the issue title, author, and URL.

Open Source Is About Hard Problems Worth Fixing

You will have noticed this already but Open Source in a post-AI world is under a strange new pressure. We are getting more code, more projects, and more issues. Projects appear with no real users, or a temporary audience of one, and even projects with thousands of stars can have a shelf life of weeks.

For us, Pi’s harness layer is worth maintaining carefully because it solves hard coordination problems and creates a platform we and others can build on. We also know that coordination and cooperation lifts us all up. Many times the right answer is not to work around a problem locally, but to make the upstream behavior correct. Mario has been very good at refusing to make Pi paper over every misconfigured gateway, and we’re trying to preserve that discipline. When a gateway behaves correctly, everybody benefits.

Sadly that type of thinking is quickly disappearing because these machines make local workarounds cheap, so code accumulates local defenses against every misbehavior. Instead of humans talking to humans about where a fix belongs, one human and one machine work around the problem in isolation.

Keep in mind that AI has not increased the number of people who need software, or the number of maintainers who can review it. It has mostly increased the amount of code and the number of projects competing for attention. Some of that is healthy, but a lot of it fragments effort that should be shared.

We need stronger foundations, not weaker ones. Open Source needs more collaboration, not more isolated work with a machine. Human communication is hard, and it is tempting to avoid it when you can sit alone with your clanker. But isolation is not where Open Source derives its value. The value is in the community and the structure that lets projects outlive their original creators.

  1. To me, clanker is a much preferable term for agent. Agency lies with humans, not with machines. Calling these things agents I still believe is a mistake, but alas.

May 24, 2026 12:00 AM UTC

May 23, 2026


Kay Hayen

Nuitka Release 4.1

This is to inform you about the new stable release of Nuitka. It is the extremely compatible Python compiler, “download now”.

This release adds many new features and corrections with a focus on async code compatibility, missing generics features, and Python 3.14 compatibility and Python compilation scalability yet again.

Bug Fixes

Package Support

New Features

Optimization

Anti-Bloat

Organizational

Tests

Cleanups

Summary

This release builds on the scalability improvements established in 4.0, with enhanced Python 3.14 support, expanded package compatibility, and significant optimization work.

The --project option seems usable now.

Python 3.14 support remains experimental, but only barely made the cut, and probably will get there in hotfixes. Some of the corrections came in so late before the release, that it was just not possible to feel good about declaring it fully supported just yet.

May 23, 2026 10:00 PM UTC


EuroPython

Call for Onsite Volunteers: Make EuroPython 2026 Happen

We need volunteers to make EuroPython 2026 happen. And you might be exactly who we&aposre looking for!

Before sharing all the information, here is a personal story from me:

The first time I attended EuroPython in-person was as a volunteer. It was the first year after Covid, and I was nervous about traveling abroad for a conference where I didn&apost know anyone personally; there were only friendly faces from the previous year of volunteering online. When I volunteered online, it was easier. I could stay in my comfort zone. But stepping out of that zone to meet people face-to-face? That changed everything 🐍❤️

Those online faces became really good friends. Now I want to go for every EuroPython because I will get to meet them again. Volunteering with friends became such fun I didn&apost even notice that I was constantly stepping outside my comfort zone 💃

So, if you&aposre thinking of volunteering, just do it! You will meet awesome humans and have fun while helping people surrounded by positive vibes 💖

alt

As a volunteer, you&aposre the face of the conference. Your job is to make sure everyone has a great time. We need volunteers to be welcoming, helpful, and collaborative; making sure everyone (including yourself) is comfortable and happy.

There are lots of different ways to help, depending on your interests and availability:

You can sign up for as many or as few slots as you want. Even a couple of hours helps. We&aposd appreciate it if you could do more than one, but no pressure, whatever you can give is valuable.

In the volunteering form, tell us what sounds interesting. Get matched with a role that fits your skills and availability. Show up, help out, and be part of something amazing.

That&aposs it. No experience necessary. You don&apost need to be a Python expert. You just need to care about the community and be willing to help out. Whether that&aposs greeting people at the door, managing the schedule, troubleshooting tech issues, or making sure speakers have what they need - we have a place for you.

What do you get?

Check out this page for all the details, including descriptions of various roles: https://ep2026.europython.eu/volunteering/

And if you have more questions? Just reach out volunteers@europython.eu. We&aposre here to help.

🎁 Sponsor Spotlight

We&aposd like to thank Manychat for sponsoring EuroPython.

Manychat builds AI-powered chat automation for 1M+ creators and brands at real production scale.

alt

👋 Stay Connected

Follow us on social media and subscribe to our newsletter for all the updates:

👉 Sign up for the newsletter: https://blog.europython.eu/portal/signup

Hopefully, we’ll see you on this side soon 🔜 😉

Cheers,

Sangarshanan Veera, EuroPython 2026 Communications Team

May 23, 2026 07:00 AM UTC

May 22, 2026


Python Morsels

What types of exceptions should you catch?

The trickiest programming bugs are often caused by catching exceptions that you didn't mean to catch or handling exceptions in ways that **obfuscate the actual error that's occurring. Which exceptions should you catch and which should you leave unhandled?

Table of contents

  1. Catching many exceptions at once
  2. When will a NameError be raised?
  3. When will a ValueError be raised?
  4. When will a TypeError be raised?
  5. When will a KeyError be raised?
  6. Handling problems preemptively
  7. When should you catch all possible types of exceptions?
  8. Summary

Catching many exceptions at once

When catching an exception, it's generally considered a good idea to only catch exceptions if you understand their origin.

Here we have some code that catches many exception types at once. We're catching a ValueError, a TypeError, a KeyError, and a NameError exception:

import csv
import datetime
import sys

def parse_date(date_string):
    return datetime.date.fromisoformat(date_string)

[filename] = sys.argv[1:]
with open(filename) as csv_file:
    reader = csv.DictReader(csv_file)
    for n, row in enumerate(reader, start=1):
        name = row["name"]
        try:
            start, end = parse_date(row["start"]), parse_date(row["end"])
        except (ValueError, TypeError, KeyError, NameError) as e:
            error = type(e).__name__
            print(f"{error}: Invalid date on line {n}", file=sys.stderr)
            continue
        time = end - start
        print(f"{name}: {time.days} days")

It's not entirely clear why it catches each of these types of exceptions.

When will a NameError be raised?

We probably shouldn't be catching …

Read the full article: https://www.pythonmorsels.com/what-types-of-exceptions-should-you-catch/

May 22, 2026 05:45 PM UTC


Real Python

The Real Python Podcast – Episode #296: Managing Polars Schema Issues & Profiling GitHub Users

How can you avoid schema problems in your Polars data pipeline when adding new columns? How can you quickly examine a GitHub user's profile to decide how much to invest in their contributions? Christopher Trudeau is back on the show this week with another batch of PyCoder's Weekly articles and projects.


[ Improve Your Python With 🐍 Python Tricks 💌 – Get a short & sweet Python Trick delivered to your inbox every couple of days. >> Click here to learn more and see examples ]

May 22, 2026 12:00 PM UTC

Quiz: Build a Tic-Tac-Toe Game With Python and Tkinter

In this quiz, you’ll test your understanding of Build a Tic-Tac-Toe Game With Python and Tkinter.

By working through this quiz, you’ll revisit how to design game logic with Python classes, lay out and update Tkinter widgets, and wire up button clicks to a handler through the event loop.


[ Improve Your Python With 🐍 Python Tricks 💌 – Get a short & sweet Python Trick delivered to your inbox every couple of days. >> Click here to learn more and see examples ]

May 22, 2026 12:00 PM UTC


Glyph Lefkowitz

Opaque Types in Python

Let’s say you’re writing a Python library.

In this library, you have some collection of state that represents “options” or “configuration” for a bunch of operations. Such a set of options is a bundle of potentially ever-increasing complexity. Thus, you will want it to have an extremely minimal compatibility surface, with a very carefully chosen public interface, that is either small, or perhaps nothing at all. Such an object conveys state and might have some private behavior, but all you want consumers to be able to do is build it in very constrained, specific ways, and then pass it along as a parameter to your own APIs.

By way of example, imagine that you’re wrapping a library that handles shipping physical packages.

There are a zillion ways to do it ship a package. There are different carriers who can ship it for you. There’s air freight, and ground freight, and sea freight. There’s overnight shipping. There’s the option to require a signature. There’s package tracking and certified mail. Suffice it to say, lots of stuff.

If you are starting out to implement such a library, you might need an object called something like ShippingOptions that encapsulates some of this. At the core of your library you might have a function like this:

1
2
3
4
5
async def shipPackage(
        how: ShippingOptions,
        where: Address,
    ) -> ShippingStatus:
    ...

If you are starting out implementing such a library, you know that you’re going to get the initial implementation of ShippingOptions wrong; or, at the very least, if not “wrong”, then “incomplete”. You should not want to commit to an expansive public API with a ton of different attributes until you really understand the problem domain pretty well.

Yet, ShippingOptions is absolutely vital to the rest of your library. You’ll need to construct it and pass it to various methods like estimateShippingCost and shipPackage. So you’re not going to want a ton of complexity and churn as you evolve it to be more complex.

Worse yet, this object has to hold a ton of state. It’s got attributes, maybe even quite complex internal attributes that relate to different shipping services.

Right now, today, you need to add something so you can have “no rush”, “standard” and “expedited” options. You can’t just put off implementing that indefinitely until you can come up with the perfect shape. What to do?

The tool you want here is the opaque data type design pattern. C is lousy with such things (FILE, pthread_*_t, fd_set, etc). A typedef in a header file can easily achieve this.

But in Python, if you expose a dataclass — or any class, really — even if you keep all your fields private, the constructor is still, inherently, public. You can make it raise an exception or something, but your type checker still won’t help your users; it’ll still look like it’s a normal class.

Luckily, Python typing provides a tool for this: typing.NewType.

Let’s review our requirements:

  1. We need a type that our client code can use in its type annotations; it needs to be public.
  2. They need to be able to consruct it somehow, even if they shouldn’t be able to see its attributes or its internal constructor arguments.
  3. To express high-level things (like “ship fast”) that should stay supported as we add more nuanced and complex configurations in the future (like “ship with the fastest possible option provided by the lowest-cost carrier that supports signature verification”).

In order to solve these problems respectively, we will use:

  1. a public NewType, which gives us our public name...
  2. which wraps a private class with entirely private attributes, to give us an actual data structure, while not exposing the constructor,
  3. a set of public constructor functions, which returns our NewType.

When we put that all together, it looks like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
from dataclasses import dataclass
from typing import Literal, NewType

@dataclass
class _RealShipOpts:
    _speed: Literal["fast", "normal", "slow"]

ShippingOptions = NewType("ShippingOptions", _RealShipOpts)

def shipFast() -> ShippingOptions:
    return ShippingOptions(_RealShipOpts("fast"))

def shipNormal() -> ShippingOptions:
    return ShippingOptions(_RealShipOpts("normal"))

def shipSlow() -> ShippingOptions:
    return ShippingOptions(_RealShipOpts("slow"))

As a snapshot in time, this is not all that interesting; we could have just exposed _RealShipOpts as a public class and saved ourselves some time. The fact that this exposes a constructor that takes a string is not a big deal for the present moment. For an initial quick and dirty implementation, we can just do checks like if options._speed == "fast" in our shipping and estimation code.

However, the main thing we are doing here is preserving our flexibility to evolve the related APIs into the future, so let’s see how we might do that. For example, let’s allow the shipping options to contain a concrete and specific carrier and freight method:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
from dataclasses import dataclass
from enum import Enum, auto
from typing import NewType

class Carrier(Enum):
    FedEx = auto()
    USPS = auto()
    DHL = auto()
    UPS = auto()

class Conveyance(Enum):
    air = auto()
    truck = auto()
    train = auto()

@dataclass
class _RealShipOpts:
    _carrier: Carrier
    _freight: Conveyance

ShippingOptions = NewType("ShippingOptions", _RealShipOpts)

def shipFast() -> ShippingOptions:
    return ShippingOptions(_RealShipOpts(Carrier.FedEx, Conveyance.air))

def shipNormal() -> ShippingOptions:
    return ShippingOptions(_RealShipOpts(Carrier.UPS, Conveyance.truck))

def shipSlow() -> ShippingOptions:
    return ShippingOptions(_RealShipOpts(Carrier.USPS, Conveyance.train))

def shippingDetailed(
    carrier: Carrier, conveyance: Conveyance
) -> ShippingOptions:
    return ShippingOptions(_RealShipOpts(carrier, conveyance))

As a NewType, our public ShippingOptions type doesn’t have a constructor. Since _RealShipOpts is private, and all its attributes are private, we can completely remove the old versions.

Anything within our shipping library can still access the private variables on ShippingOptions; as a NewType, it’s the same type as its base at runtime, so it presents minimal1 overhead.

Clients outside our shipping library can still call all of our public constructors: shipFast, shipNormal, and shipSlow all still work with the same (as far as calling code knows) signature and behavior.

If you need to build and convey some state within your public API, while avoiding breakages associated with compatibility churn, hopefully this technique can help you do that!


Acknowledgments

Thanks for reading, and 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. The overhead is minimal, but it is not completely zero. The suggested idiom for converting to a NewType is to call it like a function, as I’ve done in these examples, but if you are wanting to use this pattern inside of a hot loop, you can use # type: ignore[return-value] comments to avoid that small cost. 

May 22, 2026 12:33 AM UTC


Bob Belderbos

What production AI agents actually require

Most "AI agents" shipping right now are demos wearing production paint. They answer questions fluently and break the moment they touch a workflow with money, state, or consequences.

The agent illusion

Multi-agent frameworks benchmark beautifully. Five specialist LLMs cooperate, the demo plays cleanly, the README has a diagram with arrows. Then someone wires the thing to a real billing system and it issues three refunds for the same chargeback because a tool call retried on a flaky network.

That gap is the actual job most agent tutorials skip.

When I review AI code, the same pattern keeps appearing. The LLM call is fine. The agent loop is fine. What is missing is the layer underneath: state, idempotency, audit, and a tool surface the agent cannot use to hurt you.

The unsexy layer

Systems answer four questions before the agent does anything:

  1. What did the agent already do? Persisted state, not "look at the conversation history."
  2. What happens if this action runs twice? Idempotency keys on every external effect.
  3. Who approved this? An audit log a human can read during a postmortem.
  4. Can I roll this back? A clear inverse for every irreversible operation, or a freeze before execution.

None of this is glamorous. It is also what separates a system that is a toy demo from one that can run mostly unsupervised in production.

The shape of that contract in code:

class ExpenseAction(BaseModel):
    idempotency_key: str
    requested_by: str
    requested_at: datetime
    approval_required: bool = True
    dry_run: bool = True
    payload: ExpensePayload

def submit(action: ExpenseAction, repo: ExpenseRepo) -> Result:
    if repo.find_by_key(action.idempotency_key):
        return Result.duplicate()
    if action.dry_run:
        return Result.preview(action.plan())
    if action.approval_required and not action.is_approved():
        return Result.pending_approval()
    repo.persist(action)
    return Result.ok(action.execute())

The agent does not call the side effect. It builds a typed plan. A function decides whether to run it.

State that survives retries

Agents need state management that works across restarts and network failures. The Telegram expense bot we build in our Agentic AI cohort program, uses context.user_data to track multi-step flows:

async def handle_expense_text(self, update, context):
    text = update.message.text
    result = self._preprocessor.preprocess(text)

    if not result.is_valid:
        await update.message.reply_text(f"Invalid: {result.error}")
        return ConversationHandler.END

    response = self._build_service().classify(result.text).response

    # Store state for the callback handler
    context.user_data["expense_description"] = result.text
    context.user_data["classification_response"] = response

    keyboard = build_category_confirmation_keyboard(
        suggested_category=response.category,
        all_categories=[c.value for c in ExpenseCategory],
    )

    await update.message.reply_text(
        f"I categorized this as {response.category} ({response.total_amount} {response.currency}). Confirm or pick another category:",
        reply_markup=keyboard,
    )

    return ConversationState.WAITING_FOR_CATEGORY

async def handle_category_selection(self, update, context):
    query = update.callback_query
    await query.answer()

    # Retrieve state from previous handler
    description = context.user_data.get("expense_description")
    response = context.user_data.get("classification_response")

    if description is None or response is None:
        await query.edit_message_text("Session expired. Send expense again.")
        return ConversationHandler.END

    _, category = query.data.split(":", 1)

    self._build_service().persist_with_category(
        expense_description=description,
        category_name=category,
        response=response,
        telegram_user_id=update.effective_user.id,
    )

    await query.edit_message_text(f"Saved as {category}!")
    return ConversationHandler.END

The .get() with defensive error handling is what saves you when the bot restarts mid-conversation. No silent corruption, no half-written database rows. The user just has to resend their expense description and pick the category again. This is the work of production agents.

Tools the agent cannot trust

LLMs are undeterministic and hallucinate. Design your tool surface for mistrust:

The agent is not the brain of your application. It is a planner that we acknowledge is fallible. The real logic lives in the tools, and the agent's job is to call them with valid inputs and ask for help when it is unsure.

Input validation before the LLM sees anything

Validate at system boundaries before user input reaches your tools. This prevents XSS, length attacks, and malformed data from consuming tokens:

from dataclasses import dataclass, field
import re

XSS_PATTERNS = ("<script", "javascript:", "onerror=", "onload=")
CURRENCY_SYMBOLS = {"$": "USD", "€": "EUR", "£": "GBP", "¥": "JPY"}
AMOUNT_PATTERN = re.compile(r"\d+([.,]\d+)?")

@dataclass
class PreprocessingResult:
    text: str
    is_valid: bool
    warnings: list[str] = field(default_factory=list)
    error: str | None = None

class InputPreprocessor:
    def preprocess(self, text: str) -> PreprocessingResult:
        text = text.strip()

        if len(text) < 3:
            return PreprocessingResult(text, False, error="Input too short")
        if len(text) > 500:
            return PreprocessingResult(text, False, error="Input too long")

        if any(pattern in text.lower() for pattern in XSS_PATTERNS):
            return PreprocessingResult(text, False, error="Invalid characters")

        for symbol, code in CURRENCY_SYMBOLS.items():
            text = text.replace(symbol, code)

        warnings = []
        if not AMOUNT_PATTERN.search(text):
            warnings.append("No amount detected")

        return PreprocessingResult(text, True, warnings)

This runs before the LLM call, returning error messages without burning tokens or risking injection.

Human-in-the-loop as a design pattern

Production agents are not fully autonomous. They classify, extract, or suggest, then wait for a human to confirm. Confidence scores guide when to ask:

from dataclasses import dataclass

@dataclass(frozen=True)
class ClassificationResult:
    response: ExpenseCategorizationResponse
    persisted: bool

def process_with_hitl(result: ClassificationResult, threshold: float = 0.8) -> str:
    if result.response.confidence >= threshold:
        return result.response.category

    print(
        f"Low confidence ({result.response.confidence:.0%}): '{result.response.category}' — {result.response.reason}"
    )
    user_input = input(
        f"Accept '{result.response.category}'? (Enter to confirm, or type a category): "
    ).strip()

    if not user_input:
        return result.response.category
    return user_input

In the Telegram bot, this becomes an inline keyboard. The bot states its category guess and asks the human to confirm or pick a different one, with the AI suggestion highlighted.

Telegram expense bot confidence-based keyboards

The pattern: AI proposes, human disposes. This surfaces in the service layer we built in prior weeks:

@dataclass
class ClassificationService:
    assistant: Assistant
    expense_repo: ExpenseRepository

    def classify(self, description: str) -> ClassificationResult:
        messages = self._build_messages(description)
        response = self.assistant.completion(messages)
        return ClassificationResult(response=response, persisted=False)

    def persist_with_category(
        self,
        expense_description: str,
        category_name: str,
        response: ExpenseCategorizationResponse,
        telegram_user_id: int | None = None,
    ) -> None:
        """Store the user's chosen category, not the AI guess."""
        expense = Expense(
            amount=response.total_amount,
            currency=response.currency,
            category=ExpenseCategory(category_name),
            description=expense_description,
            telegram_user_id=telegram_user_id,
        )
        self.expense_repo.add(expense)

The persist_with_category method accepts the human's decision. The database stores what the user confirmed, not what the model guessed. As the ExpenseCategorizationResponse captures the AI's original category and confidence, we can analyze overrides later to identify model weaknesses.

Dependency injection for testable agents

The service layer pattern separates business logic from LLM provider details. Inject dependencies rather than hardcoding them:

from unittest.mock import create_autospec
from decimal import Decimal

def test_classify_calls_assistant():
    # No real OpenAI call, no .env file, no network
    mock_assistant = create_autospec(Assistant)
    mock_assistant.completion.return_value = ExpenseCategorizationResponse(
        category="Food",
        total_amount=Decimal("5.50"),
        currency=Currency.USD,
        confidence=0.95,
        cost=Decimal("0.001"),
    )

    mock_repo = create_autospec(ExpenseRepository)
    service = ClassificationService(assistant=mock_assistant, expense_repo=mock_repo)
    result = service.classify("Coffee at Starbucks $5.50")

    mock_assistant.completion.assert_called_once()
    assert result.response.category == "Food"
    assert result.persisted is False

Because the service receives its dependencies rather than creating them, you can test classification logic without burning API credits or waiting on network calls. This is a key strategy to test the interface at the service layer, not the LLM provider.

The same service powers the CLI, Telegram bot, and REST API. Change providers (OpenAI to Anthropic) or add caching by swapping the Assistant implementation. Business logic stays untouched.

Speed vs safety

The tradeoff is iteration speed vs execution safety.

Put the LLM behind a typed service boundary and you can swap models without touching business logic. Store actions as events instead of overwriting state, and your audit log writes itself. I wrote about why event sourcing pays off.

Agentic loops with typed tool results

The tool-use loop needs to handle partial results, retries, and tool failures. Here is the pattern from the warm up exercises you can do on our Agentic Cohort page:

from typing import cast
import anthropic
from anthropic.types import (
    MessageParam,
    TextBlock,
    ToolUseBlock,
    ToolResultBlockParam,
)

# TOOLS defined with JSON schema for get_exchange_rate(from_currency, to_currency)

def answer_with_tools(question: str, client: anthropic.Anthropic) -> str:
    messages: list[MessageParam] = [{"role": "user", "content": question}]

    while True:
        response = client.messages.create(
            model="claude-sonnet-4-6",
            max_tokens=512,
            tools=TOOLS,
            messages=messages,
        )

        if response.stop_reason == "end_turn":
            return cast(TextBlock, response.content[0]).text

        if response.stop_reason != "tool_use":
            # anything other than tool_use here means no tool calls to process — looping would spin forever
            raise RuntimeError(f"Unexpected stop reason: {response.stop_reason}")

        tool_uses = [
            cast(ToolUseBlock, b) for b in response.content if b.type == "tool_use"
        ]
        tool_results: list[ToolResultBlockParam] = [
            {
                "type": "tool_result",
                "tool_use_id": b.id,
                "content": str(get_exchange_rate(**cast(dict[str, str], b.input))),
            }
            for b in tool_uses
        ]

        messages.append({"role": "assistant", "content": response.content})
        messages.append({"role": "user", "content": tool_results})

The loop continues until stop_reason == "end_turn". Tool results are typed, preventing schema drift between the tool definition and implementation.

In production, wrap get_exchange_rate() in a try/except and return error results to the LLM when tools fail. The agent can retry, pick a different tool, or surface the error to the user.


The fix is separation of concerns, typed interfaces, and a well-defined contract between the agent and its tools.

Keep reading

May 22, 2026 12:00 AM UTC

May 21, 2026


The Python Coding Stack

How I Learn (2026 Version) • My Tutor Agent

I know how I like to learn new things. Over the years, I figured out what works for me and what doesn’t. If you read my articles or attend my courses, then you know how I like to learn since I teach in the same way.

The challenge when learning something new is finding resources that are just right for me. And that’s not easy. I know I can learn things better and quicker with resources that fit my style well, but you can’t always find these resources.

I recently got particularly annoyed learning about the biomechanics of sprinting – I do have non-Python interests, yes – because all three textbooks I read, and lots of the online writing in this field, are just, let’s say, not great.

But I now found the solution.

After many decades of learning in the same way, I have now upgraded how I learn thanks to my new tutor, Priya.

Yes, I gave her a name. No, she’s not a real person. Priya is my personalised tutor agent. I’ll tell you all about her below.

And you’ll experience her teaching, too (not on the Python articles, though, I’ll keep writing those the old-fashioned way.) I’ll tell you more about this below, too, but let me first tell you why this works for me.

My Tutor, My Style

I’ve been thinking about the way I learn and teach for many years, from way back when I was a young University lecturer faced with 120 students in a lecture hall. I wasn’t that much older than the students, but I learnt fast. And they liked my teaching (I even have awards to prove it!)

More recently, I’ve been writing a lot. I wrote articles here on The Python Coding Stack and elsewhere. I wrote a Python textbook. I even wrote about learning and technical writing in Breaking the Rules: the substack and the book.

All this meant that I could ask my freshly-spawned agent to spend a bit of time reading what I wrote to understand how I teach, which is how I like to learn. Priya analysed the techniques I use in my writing and understood my motivations for doing what I do through my technical writing texts.

Then, Priya and I had a good chat to refine ideas, to make sure she captured the essence of “my style”.

And since Priya is an AI agent, “my style” became her knowledge base. This knowledge now lives in several lengthy markdown files and is summarised in shorter context packs and an index to ensure Priya’s short-term memory (the context window) isn’t overwhelmed.

Then I was ready to go. Any topic I wanted to learn, large or small, I could ask Priya to research it thoroughly, creating a new set of knowledge files, this time specific to the topic she needed to teach rather than my learning style. And then, she’s ready to teach me.

And it worked. The stuff she prepared was exactly the way I like it.

The Tutor-Student Conversation Course

And here’s the format I settled on (for now). Once the agent completes her research about the topic I want to learn, I ask her to plan a course spanning several modules.

But here’s the refinement loop that makes the real difference:

  1. I ask Priya to draft the first module. She writes this in a markdown file.

  2. I read through her draft and leave comments and questions directly within the text.

  3. Priya reads my questions and revises the text to address my questions. (But read on to find out more about the two categories of comments/questions I leave for her.)

  4. Repeat steps 2 and 3 until I feel I understand the topic.

  5. Move on to the next module and repeat steps 1 to 4.

This is a human-in-the-loop approach to creating the learning material. Yes, Priya is trained in my way of learning and teaching and in my writing style. But I’m actively having a conversation with her within the text.

This is equivalent to raising your hand in a lesson and asking the teacher a question. A good teacher will then revise how they present the material to address your question.

Priya’s learning materials are just like that. In fact, I will take credit for her output. Sure, I’m not an expert in the subject matter she’s teaching me – that’s the whole point, right? But the output reflects my views and ideas about teaching and includes my questions and queries as I tried to understand and master the topic.

This is a collaboration. Priya and I are co-authors, even though Priya did most of the “writing”.

I tried this approach on several topics, but there are two I want to share with you. I’m setting up two new sections here on The Python Coding Stack, which I’ll use to learn these two topics in public. I’ll publish the “transcripts” of the conversations Priya and I are having. It’s mostly Priya doing the talking, but my questions are there, too.

The first topic I’m learning in public with Priya’s help is Agentic AI. It’s very meta to use agentic AI to learn about agentic AI! I’ll publish an introduction and the first module in the coming days in the new section here on The Python Coding Stack called Agents Unpacked. You can already see this section in the menu on the homepage.

I’ll set up another section to deal with the second topic in a week or so. No spoilers for now except to say it’s directly related to programming but it’s distinct from the articles I publish in the main section on The Python Coding Stack and in The Club.

By the way, you’ll be able to select which sections you want to receive regularly by email. So if you’re interested in my Python core content but not in these other topics, you can pick and choose what to opt out of. You can always go to The Python Coding Stack to read the other sections, of course.

How Priya and I Create These “Courses”

But let me expand on how Priya – my tutor agent – and I created these courses. [Incidentally, those are my em-dashes – I use them often and have always done. Commas would be ambiguous in that context!]

I provide two types of questions or comments to my agent as I read through the drafts: private and public.

Private Questions and Comments

When Priya reads the private questions or comments, she makes changes to the text, but then she deletes my input. So, you won’t see my intervention explicitly in these cases. However, Priya’s text reflects my thoughts. My interventions guide Priya. This type of intervention is similar to an editor’s role, but I’m intervening as a learner more than as an editor.

Public Questions and Comments

However, when Priya comes across a comment or question I mark as public, she leaves it in the text, acknowledges the question, and answers it directly. So, you’ll see my public questions in the text. Priya and I decided not to include too many of these public questions to keep the text flowing. However, I think it’s beneficial to see some of my interventions. My questions may also be your questions.

More Learning. More Articles. More Fun

As with everything to do with AI, this is all very new. It’s a work in progress. I may refine and revise how I interact with my agent. But it’s been fun learning this way, and I hope you enjoy reading my interactions with Priya and you find it useful, too.

To state the obvious, the posts I’ll publish in these two new sections are mostly AI-generated content. If you read this far, then you won’t be surprised by that statement. A year ago, I would never have thought I’d publish anything written by AI. But a year is a long time in the AI world. And this AI content reflects me and my thinking. The agent is my mentee – someone I trained to teach the way I do, to write the way I do. But she’s also my tutor, teaching me new stuff.

So there’s a lot of “me” in what you read, even if it’s mostly written by Priya!

The posts in the main section of The Python Coding Place and those in The Club (for premium subscribers) won’t change. They’re still my writing from beginning to end. Every word and letter you read in those posts is the result of nerve signals going from my brain to my fingers, which tap keys on a keyboard. In this era of AI doing a lot of work for us, I think it’s more important than ever for me to keep using my pre-AI skills. Otherwise, my brain will atrophy, and I don’t want that!

So, in summary, there will soon be four sections here on The Stack:

  1. The main area in The Python Coding Stack – no change here, you’ll get the same type of Python articles you’ve been reading for the past 3+ years

  2. The Club – the extra Python posts for premium subscribers

  3. Agents Unpacked – the Agentic AI course Priya and I are creating for me to learn all about this agentic stuff. Learn with me (and Priya) if you’re interested.

  4. Mystery Fourth Section – Stay tuned, you won’t have to wait long. This is also a Priya-Stephen collaboration.

Next post will be the introduction and first section in Agents Unpacked. Soon after, I have another Python post I’m planning for you.

Subscribe now

stephengruppetta.com


Photo by detait

May 21, 2026 09:23 PM UTC


Kevin Renskers

uv is fantastic, but its package management UX is a mess

UPDATE

May 22, 2026: This article hit the Hacker News front page. Readers pointed out a couple of things I’d missed and one bit of framing I should have been clearer about. See the Corrections and clarifications section at the bottom.

Astral’s uv has taken the Python world by storm, and for good reason. It is blisteringly fast, handles Python versions with ease, and replaces a half-dozen tools with a single binary. I’ve written multiple articles about it before.

Getting started with a new Python project using uv and adding your first dependencies is very easy. But once you move past the initial setup and into the maintenance phase of a project, i.e. checking for outdated packages and performing routine upgrades, the CLI starts to feel surprisingly clunky compared to its peers like pnpm or Poetry.

Finding outdated packages

In my JavaScript projects, if I want to see what needs an update, I run:

$ pnpm outdated 

This gives a clean, concise list of outdated packages, their current version, the latest version, and the version allowed by your constraints.

In uv, there is no uv outdated. Instead, you have to memorize the following mouthful:

$ uv tree --outdated --depth 1 

The output is also a problem. It doesn’t just show you what is outdated; it shows you your entire top-level dependency tree, with a small annotation next to the ones that have updates available. If you have 50 dependencies and only two are outdated, you still have to scan a 50-line list.

Poetry isn’t much better with its command poetry show --outdated, but at least it only shows actual outdated packages.

Unsafe version constraints by default

This is the most significant philosophical departure uv takes from pnpm and Poetry, and it’s a dangerous one for production stability.

How pnpm/Poetry handle it

When you add a package using pnpm add, it writes it to package.json using the caret requirement (^1.23.4). The caret at the beginning means that any 1.x.x version is allowed, but it will not update to 2.0.0.

Poetry does the same by default, using a format like >=1.23.4,<2.0.0. I find this less readable than ^1.23.4, but the effect is the same.

In both cases, updates are safe by default. You can run pnpm update or poetry update every morning and have high confidence that your build won’t break due to a major API change (assuming the packages you depend on respect SemVer).

How uv handles it

When you run uv add pydantic, it inserts this into your pyproject.toml:

dependencies = [ "pydantic>=2.13.4", ] 

Note the lack of an upper bound. In the eyes of uv, pydantic version 2, 3, and 100 are all perfectly acceptable.

This means uv updates are unsafe by default. If you run a bulk update, you aren’t just getting bug fixes; you are opting into every breaking change published by every maintainer in your dependency graph.

The bad UX of the upgrade command

The commands to actually perform an update in uv feel like they were designed for machines rather than humans.

If you want to update everything in pnpm or Poetry, it’s a simple pnpm update or poetry update command. In uv, you use:

$ uv lock --upgrade 

THOUGHTS

Why isn’t this simply uv update or uv upgrade? Who designed this command line interface? It’s not uv lock --add or uv lock --remove either!

Because of the “no upper bounds” issue mentioned above, uv lock --upgrade is a nuclear option. It will upgrade every single package in your lockfile to their absolute latest versions, ignoring SemVer safety. And this includes deep, nested dependencies you’ve never heard of! Good luck, better hope there are no breaking changes anywhere.

Once you realize this is too risky, you’ll want to upgrade only specific packages. After scouring the subpar output of uv tree --outdated --depth 1 to find them, the syntax becomes a repetitive chore.

How pnpm does it:

$ pnpm update pydantic httpx uvicorn 

How uv does it:

$ uv lock --upgrade-package pydantic --upgrade-package httpx --upgrade-package uvicorn 

Having to repeat the --upgrade-package flag for every single item is a huge hassle when you want to update a bunch of packages. I don’t understand why the UX of uv’s commands is so poor.

There is hope: the bounds flag

Luckily uv has recently introduced a --bounds option for uv add:

$ uv add pydantic --bounds major 

This produces the safer pydantic>=2.13.4,<3.0.0 constraint we’ve come to expect. However, this is currently an opt-in feature. You have to remember to type it every time, and as of now, it is considered a preview feature.

Until --bounds major (or a similar configuration) becomes the default behavior, uv users are essentially forced to choose between two bad options:

  1. Manually edit pyproject.toml to add upper bounds for every single dependency.
  2. Live in fear that uv lock --upgrade will accidentally pull in a breaking major version change.

What I’d like to see

I love uv. Its speed is transformative, and the way it manages Python toolchains is second to none. But as a package manager, the developer experience for maintaining a project is currently a step backward from the tools that came before it.

We need a dedicated uv outdated command that filters noise, a more ergonomic update command that doesn’t require repeating flags, and default version constraints that respect the sanity of Semantic Versioning.

Until then, I’ll be double-checking every single line of my lockfile changes with a healthy dose of suspicion.

Corrections and clarifications

After this article hit Hacker News, readers pointed out two things I’d missed and one bit of framing I should have been clearer about up front.

  1. Use uv pip list --outdated instead of uv tree --outdated --depth 1. The uv pip command actually filters to only outdated packages, which makes the “Finding outdated packages” critique much weaker than I made it out to be. The remaining complaint is that this lives under the pip-compatibility namespace rather than as a first-class top-level command, which is a discoverability issue, not a noisy-output one.

  2. You can set the --bounds default in pyproject.toml. You don’t have to remember to type --bounds major on every uv add. You can set it once:

    [tool.uv] add-bounds = "major" 

    This invalidates the “two bad options” framing in the bounds-flag section. The actual situation is closer to: set this once in your config, and you get sensible defaults from then on. It’s still a preview feature, and for applications it would be better as the default, but the ergonomics are not nearly as bad as I painted them.

  3. Scope: applications vs. libraries. The standard Python packaging advice is that libraries published to PyPI should not pin upper bounds, and that advice is correct. If every library pins upper bounds, downstream consumers end up with dependency trees that can’t resolve. But for applications, where you are the terminal node in the dependency graph and nobody resolves against your constraints, the calculus is reversed: upper bounds cost you nothing and protect you from surprise major version bumps. This article is about maintaining applications (websites, services, internal tools), not publishing libraries. I should have been explicit about that from the start, because the “no upper bounds” default is indeed reasonable for the library case.

May 21, 2026 06:08 PM UTC


Real Python

Quiz: Context Managers and Using Python's with Statement

In this quiz, you’ll test what you learned in the video course Context Managers and Using Python’s with Statement.

By working through this quiz, you’ll revisit how the with statement runs setup and teardown for you, how to use standard-library context managers like open(), and how to write your own context managers as classes or with the @contextmanager decorator.


[ Improve Your Python With 🐍 Python Tricks 💌 – Get a short & sweet Python Trick delivered to your inbox every couple of days. >> Click here to learn more and see examples ]

May 21, 2026 12:00 PM UTC


PyCharm

Making software accessible often comes down to removing small but repeated points of friction in everyday workflows. Today, on Global Accessibility Awareness Day, we’re sharing recent improvements in JetBrains IDEs across several areas: compatibility with assistive technologies on various platforms, keyboard navigation, and non-visual feedback. Some of these improvements are already available, and some are coming later this year.

You can use the audio player below to listen to this blog post.

Accessibility Blog Post Audio

Better compatibility with assistive technologies

One of the key areas we’ve been working on is improving how JetBrains IDEs interact with OS-level accessibility tools.

Improved Magnifier support on Windows

Screen magnifiers are among the most commonly used assistive technologies in JetBrains IDEs. Until recently, the built-in Windows Magnifier didn’t reliably follow the text cursor in the editor, making navigation and editing more difficult for low-vision users. We’ve implemented support for cursor tracking so Magnifier follows text as you type, just as it does in other applications.

This builds on earlier work on macOS, where we addressed text cursor tracking with macOS Zoom. Now, the same support is being extended to Windows.

Orca and GNOME Magnifier support on Linux

With version 2026.2, coming this summer, JetBrains IDEs will allow you to use the Orca screen reader and GNOME Magnifier in supported Linux environments

This is an active area of work, with multiple related tasks already underway. Accessibility shouldn’t depend on your operating system, and we’re continuing to improve support across platforms.

More predictable keyboard navigation

We’ve also been making it easier to move through the IDE without relying on a mouse.

Main menu access with Alt on Windows

In native Windows applications, pressing Alt moves the focus to the main menu, allowing you to navigate it with the keyboard. This behavior was previously missing from JetBrains IDEs, and screen readers, such as NVDA, would sometimes announce the system menu instead.

Now, the main menu behaves in a way that feels familiar and predictable for keyboard-only and screen-reader users, and the bright focus indicator helps low-vision users identify the selected item.  

Navigating between major parts of the IDE

Another focus area is the experience of moving between different parts of the IDE interface, such as toolbars, panels, and the editor. We’re working on a more structured model for navigating through the big component groups:

This reduces the effort required to reach essential controls and makes the overall layout easier to navigate. For the current iteration, we made it possible to bring the main toolbar and status bar into focus, and we fixed the Project and Git toolbar widgets, which were not selectable by screen readers, even though other elements already were. 

As the next step, we’ll polish specific controls and include tool window bars on both sides of the IDE frame in the navigation flow.

Exploring richer non-visual feedback with audio cues

Accessibility is not only about reaching controls, but also about understanding what’s happening while you work. We’re exploring ways to provide richer audio feedback in the IDE. Two directions we’re currently investigating:

The goal is to reduce the need to rely on visual indicators or switch contexts just to understand what changed. Instead, we want the IDE to provide that information more directly.

Accessibility as an ongoing effort

We’re improving accessibility in JetBrains IDEs across multiple areas at once, including by providing compatibility with assistive technologies like screen readers and magnifiers, as well as by offering more consistent keyboard navigation and clearer feedback for events that are otherwise mostly visual.

These improvements build on earlier updates, such as support for VoiceOver and NVDA, a high-contrast UI theme, and color schemes for red-green vision deficiency. There’s still more to do, and we’ll continue working in this direction.

We’d love to hear from you

We’re eager to hear from developers who rely on accessibility features, as well as from anyone interested in improving the experience of using them.

If you have ideas or feedback about accessibility in JetBrains IDEs, you can reach us directly at accessibility@jetbrains.com. You can also report issues through YouTrack or the support request form.

If you’d like to stay informed about accessibility improvements, you can subscribe to updates here.

May 21, 2026 10:40 AM UTC

Improving Accessibility in JetBrains IDEs: What’s New and What’s Next in 2026

May 21, 2026 06:45 AM UTC

May 20, 2026


Talk Python Blog

Audit Your Python App Like Mozilla Audited Firefox

Earlier this year, Mozilla announced that they had pointed Claude at the Firefox JavaScript runtime. The agent surfaced more than 100 bugs, 14 of them serious enough to become CVEs. That is the kind of result you used to only get from an expensive pen-testing engagement, and even then it would take weeks. Reading that announcement, I kept circling back to one question: could a working Python web developer pull off the same kind of audit on their own app, without a security firm on retainer and without spending pen-testing-firm money? I built a course to answer that, and the short answer is yes.

May 20, 2026 10:02 PM UTC


Paolo Melchiorre

My PyCon US 2026

A timeline of my PyCon US 2026 journey, in Long Beach (US), told through the Mastodon posts I shared along the way.

May 20, 2026 10:00 PM UTC


Django Weblog

Django 6.1 alpha 1 released

Django 6.1 alpha 1 is now available. It represents the first stage in the 6.1 release cycle and is an opportunity to try out the changes coming in Django 6.1.

Django 6.1 offers a harmonious mélange of new features and usability improvements, which you can read about in the in-development 6.1 release notes.

This alpha milestone marks the feature freeze. The current release schedule calls for a beta release in about a month and a release candidate roughly a month after that. We'll only be able to keep this schedule with early and frequent testing from the community. Updates on the release schedule are available on the Django forum.

As with all alpha and beta packages, this release is not for production use. However, if you'd like to take some of the new features for a spin, or help find and fix bugs (which should be reported to the issue tracker), you can grab a copy of the alpha package from our downloads page or on PyPI.

The PGP key ID used for this release is Jacob Walls: 131403F4D16D8DC7

May 20, 2026 07:40 PM UTC


death and gravity

reader 3.24 released – help, multi-user updates

Hi there!

I'm happy to announce version 3.24 of reader, a Python feed reader library.

What's new? #

Here are the highlights since reader 3.23.

Context-sensitive help #

In lieu of a tutorial mode, the web app now offers guidance to new users, and has a basic context-sensitive help system. Here's some screenshots:

new user / empty state new user / empty state
context-sensitive help context-sensitive help
also help also help

Structured logging #

reader now uses structured logging internally, through structlog.

By default, output goes to stdlib logging, but you can opt into structlog-native logging:

import reader, structlog
reader.enable_structlog()
structlog.configure(...)

This was relatively challenging to do, since as a library, you cannot configure logging, nor change any global state. I hope I can contribute a variant of the solution upstream, but meanwhile here's a recipe you can use in your library (warning: brittle code).

Make update_feeds() parallel again #

It turns out the "extensive rework of the parser internal API" from 3.15 caused update_feeds() to retrieve feeds in the main thread regardless of the worker count.

Protip

If you have a parallel map() that returns @contextmanagers, make sure the work you need to do in parallel doesn't happen in __enter__. 😅

New contributors #

Thank you to the new contributors that submitted pull requests to this release!

Want to contribute? Check out the docs and the roadmap.

Hosted reader status update #

As I said last time, I'm working on a hosted version of reader. Background: Why another feed reader web app?, Why not just self-host it?.

Multi-user feed updates #

One of the bigger changes for hosted reader was handling multi-user feed updates.

For intentional but questionable reasons, users have their own dedicated databases, with the web app routing to the appropriate one based on session information.

However, updating feeds should happen in a single, shared database; this allows:

This is now done, complete with a design document (to be published). As a teaser, here's a neat architecture / data flow diagram:

... user@2.sqlite user nginx Flask auth app auth.sqlite user@1.sqlite public shared.sqlite feeds public private email yes, it's web scale ಠ_ಠ

OK, so what now? #

Since I'm rapidly running out of technical things to do, a launch is imminent.

This is what is finished so far:

Remaining work to an MVP:

Meanwhile, if this sounds like something you'd like to use, get in touch.


That's it for now. For more details, see the full changelog.

Learned something new today? Share it with others, it really helps!

What is reader? #

reader takes care of the core functionality required by a feed reader, so you can focus on what makes yours different.

reader in action reader allows you to:

...all these with:

To find out more, check out the GitHub repo and the docs, or give the tutorial a try.

Why use a feed reader library? #

Have you been unhappy with existing feed readers and wanted to make your own, but:

Are you already working with feedparser, but:

... while still supporting all the feed types feedparser does?

If you answered yes to any of the above, reader can help.

The reader philosophy #

May 20, 2026 04:44 PM UTC