skip to navigation
skip to content

Planet Python

Last update: September 04, 2026 07:48 PM UTC

September 04, 2026


Python Morsels

Creating temporary files in Python

How to create temporary files and directories in Python using the tempfile module's NamedTemporaryFile and TemporaryDirectory.

Table of contents

  1. Making a temporary file
  2. Leaving temporary files open
  3. Making a temporary directory
  4. You probably want a named file
  5. Create temporary files with tempfile

Making a temporary file

To make a temporary file in Python, you can use the NamedTemporaryFile context manager from the tempfile module in Python's standard library:

from tempfile import NamedTemporaryFile

with NamedTemporaryFile(mode="wt") as file:
    file.write("Temporary text.\n")
    print(f"The filename is {file.name}")

Note this context manager will delete the file as soon as it exits, which may be a problem if we actually want to use the file after the context manager has exited:

>>> from tempfile import NamedTemporaryFile
>>> with NamedTemporaryFile(mode="wt") as file:
...     file.write("Temporary text.\n")
...     filename = file.name
...
16
>>> open(filename).read()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
    open(filename).read()
    ~~~~^^^^^^^^^^
FileNotFoundError: [Errno 2] No such file or directory: '/tmp/tmph52sw7ra'

Pretty much every time I make a temporary file, I need to close the file without actually deleting it so that I can then pass the filename off to other code that actually uses the file.

Leaving temporary files open

If you'd like to make …

Read the full article: https://www.pythonmorsels.com/temporary-files/

September 04, 2026 02:30 PM UTC


Python Anywhere

Annual plans, PostgreSQL 15, and easier database management

We deployed our latest system update to our EU-based system on 23 June 2026 and to our US-based system on 28 July 2026.

People creating an account or upgrading from a free account can now choose an annual plan. PostgreSQL 15 is available, and the Databases page now makes it easier to restart PostgreSQL servers and keep track of MySQL storage limits.

September 04, 2026 10:00 AM UTC


Talk Python to Me

#561: TonIO, a Multi-threaded Async Runtime for Python

How many cores does your machine have, 10, 18? Your async Python code uses just one of them. That isn't a bug in asyncio. That's the design, and optimizing event loops to be faster by 20% doesn't change it. So Giovanni Barillari started over. Joe is the creator of Granian, the Rust-based server that powers Talk Python. His new project is TonIO, an async runtime written from scratch for free-threaded Python. Real threads, a handful of primitives instead of asyncio's pile of them, and it flat out refuses to start if the GIL is on.<br/> <br/> <strong>Episode sponsors</strong><br/> <br/> <a href='https://talkpython.fm/sentry'>Sentry Error Monitoring, Code talkpython26</a><br> <a href='https://talkpython.fm/devopsbook'>Python in Production</a><br> <a href='https://talkpython.fm/training'>Talk Python Courses</a><br/> <br/> <h2 class="links-heading mb-4">Links from the show</h2> <div><strong>Guest</strong><br/> <strong>Giovanni Barillari</strong>: <a href="https://github.com/gi0baro?featured_on=talkpython" target="_blank" >github.com</a><br/> <br/> <strong>Granian</strong>: <a href="https://github.com/emmett-framework/granian?featured_on=talkpython" target="_blank" >github.com</a><br/> <strong>Hyper</strong>: <a href="https://github.com/hyperium/hyper?featured_on=talkpython" target="_blank" >github.com</a><br/> <strong>Free threaded Python</strong>: <a href="https://docs.python.org/3/howto/free-threading-python.html#freethreading-python-howto" target="_blank" >docs.python.org</a><br/> <strong>Sort of</strong>: <a href="https://labs.quansight.org/blog/free-threaded-one-year-recap?featured_on=talkpython" target="_blank" >labs.quansight.org</a><br/> <strong>did a whole course</strong>: <a href="https://training.talkpython.fm/courses/python-concurrency-deep-dive" target="_blank" >training.talkpython.fm</a><br/> <strong>uvloop</strong>: <a href="https://github.com/magicstack/uvloop?featured_on=talkpython" target="_blank" >github.com</a><br/> <strong>rloop</strong>: <a href="https://github.com/gi0baro/rloop?featured_on=talkpython" target="_blank" >github.com</a><br/> <strong>TonIO</strong>: <a href="https://github.com/gi0baro/tonio?featured_on=talkpython" target="_blank" >github.com</a><br/> <strong>your EuroPython 2026 talk</strong>: <a href="https://www.youtube.com/watch?v=3GwyadhJZBQ&amp;list=PLQHk3YPV3Dgk&amp;index=4&amp;t=753s" target="_blank" >www.youtube.com</a><br/> <strong>Michael's Cutting Python Web App Memory Over 31% Article</strong>: <a href="https://mkennedy.codes/posts/cutting-python-web-app-memory-over-31-percent/?featured_on=talkpython" target="_blank" >mkennedy.codes</a><br/> <br/> <strong>Watch this episode on YouTube</strong>: <a href="https://www.youtube.com/watch?v=lmG0ocDTsZo" target="_blank" >youtube.com</a><br/> <strong>Episode #561 deep-dive</strong>: <a href="https://talkpython.fm/episodes/show/561/tonio-a-multi-threaded-async-runtime-for-python#takeaways-anchor" target="_blank" >talkpython.fm/561</a><br/> <strong>Episode transcripts</strong>: <a href="https://talkpython.fm/episodes/transcript/561/tonio-a-multi-threaded-async-runtime-for-python" target="_blank" >talkpython.fm</a><br/> <br/> <strong>Theme Song: Developer Rap</strong><br/> <strong>🥁 Served in a Flask 🎸</strong>: <a href="https://talkpython.fm/flasksong" target="_blank" >talkpython.fm/flasksong</a><br/> <br/> <strong>---== Don't be a stranger ==---</strong><br/> <strong>YouTube</strong>: <a href="https://talkpython.fm/youtube" target="_blank" ><i class="fa-brands fa-youtube"></i> youtube.com/@talkpython</a><br/> <br/> <strong>Bluesky</strong>: <a href="https://bsky.app/profile/talkpython.fm" target="_blank" >@talkpython.fm</a><br/> <strong>Mastodon</strong>: <a href="https://fosstodon.org/web/@talkpython" target="_blank" ><i class="fa-brands fa-mastodon"></i> @talkpython@fosstodon.org</a><br/> <strong>X.com</strong>: <a href="https://x.com/talkpython" target="_blank" ><i class="fa-brands fa-twitter"></i> @talkpython</a><br/> <br/> <strong>Michael on Bluesky</strong>: <a href="https://bsky.app/profile/mkennedy.codes?featured_on=talkpython" target="_blank" >@mkennedy.codes</a><br/> <strong>Michael on Mastodon</strong>: <a href="https://fosstodon.org/web/@mkennedy" target="_blank" ><i class="fa-brands fa-mastodon"></i> @mkennedy@fosstodon.org</a><br/> <strong>Michael on X.com</strong>: <a href="https://x.com/mkennedy?featured_on=talkpython" target="_blank" ><i class="fa-brands fa-twitter"></i> @mkennedy</a><br/></div>

September 04, 2026 07:33 AM UTC


Trey Hunner

Python Morsels now has spaced repetition

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

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

We learn by recalling, not by reading

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

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

But not everything worth remembering warrants an entire Python exercise.

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

Spaced repetition beats the forgetting curve

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

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

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

What Daily Recall does

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

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

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

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

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

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

You can use Daily Recall for free

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

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

September 04, 2026 12:30 AM UTC


Bob Belderbos

Why does Alembic need an import you never use?

A coaching session this week surfaced a good question: Alembic autogenerate only works if you import your models first, even though you never reference them.

Ruff flags the import as unused.

So why is it there?

Instead of just adding # noqa: F401, I stopped and followed the call path into SQLModel and SQLAlchemy. The answer is a nice example of Python metaprogramming: importing the module executes the class definition, and the class definition registers its table with SQLAlchemy's metadata.

The unused import that isn't unused

Here's the line every SQLModel + Alembic setup has in env.py:

from tips.models import Tip, User  # noqa: F401

target_metadata = SQLModel.metadata

(That's a real line from this project's env.py.)

SQLModel.metadata is a single shared MetaData object. A table only gets registered in its tables collection as a side effect of the class being defined.

If tips.models never gets imported, the class Tip(...) statement never executes, so no table is registered. Alembic then sees empty model metadata when it runs autogenerate. Existing database tables aren't present in that metadata, so Alembic can propose dropping them.

So the import isn't there for the names Tip and User. It's there to run the module so the class definitions fire. That's why # noqa: F401 is justified.

This is a common confusion for people new to Python, importing an object actually reads and executes the module, and the side effects of that execution are often more important than the names it exports. (For a related trap, where when an object gets created changes its behavior, see Two Python Scoping Bugs.)

A better way to write it so we can include any future models without having to list them all is:

import tips.models  # noqa: F401

The interesting question is what "the class definition fires" actually means. Nothing in your model class calls metadata.add_table(). So who does?

Following table=True into the source

When you write class Tip(SQLModel, table=True), the table=True is a class keyword argument. It gets routed to SQLModelMetaclass. In sqlmodel 0.0.39, main.py reads it back out and stashes it on the model config:

# sqlmodel/main.py — SQLModelMetaclass.__new__
config_table = get_config("table")
if config_table is True:
    new_cls.model_config["table"] = config_table
    ...

Then __init__ on the same metaclass checks that flag and, only when it's set, hands the class over to SQLAlchemy's declarative machinery:

# sqlmodel/main.py — SQLModelMetaclass.__init__
base_is_table = any(is_table_model_class(base) for base in bases)
if is_table_model_class(cls) and not base_is_table:
    ...  # build columns and relationships from the model's fields
    DeclarativeMeta.__init__(cls, classname, bases, dict_, **kw)
else:
    ModelMetaclass.__init__(cls, classname, bases, dict_, **kw)

(is_table_model_class is just the check that model_config["table"] is set.)

That if/else is the key fork created by table=True. With it, SQLModel takes the class down SQLAlchemy's declarative path. Without it, you get a SQLModel/Pydantic model rather than a mapped table model, so no SQLAlchemy Table is registered in SQLModel.metadata.

From DeclarativeMeta.__init__ the trail runs straight down into SQLAlchemy:

# sqlalchemy/orm/decl_api.py
if not cls.__dict__.get("__abstract__", False):
    _as_declarative(reg, cls, dict_)

_as_declarative scans the class, builds a Table object from the columns, and that Table registers itself:

# sqlalchemy/sql/schema.py — Table.__new__
metadata._add_table(name, schema, table)
# sqlalchemy/sql/schema.py — MetaData._add_table
def _add_table(self, name, schema, table):
    key = _get_table_key(name, schema)
    self.tables._insert_item(key, table)

There it is. self.tables._insert_item(...) is the exact moment your model becomes an entry in SQLModel.metadata.tables, and it runs during class definition, triggered by importing the module. That's the side effect Alembic depends on.

Seeing it happen

You don't have to trust the call path. Registration is a side effect of the class statement, so a plain REPL lets you watch the registry grow in real time:

>>> from sqlmodel import SQLModel, Field
>>> print(SQLModel.metadata.tables)
FacadeDict({})
>>> class Tip(SQLModel, table=True):
...     id: int | None = Field(default=None, primary_key=True)
...     text: str
...
>>> print(SQLModel.metadata.tables)
FacadeDict({'tip': Table('tip', MetaData(), Column('id', Integer(), table=<tip>, primary_key=True, nullable=False), Column('text', AutoString(), table=<tip>, nullable=False), schema=None)})

No create_engine, no create_all, no import of your models module. Just defining the class populated the shared metadata. Drop the table=True and run it again: the dict stays empty, because now Tip is a SQLModel/Pydantic model rather than a mapped table model.

That proves the class statement does the registering. To tie it back to the opening question, put the same class in a module and let the import fire it:

# models.py
from sqlmodel import SQLModel, Field

class Tip(SQLModel, table=True):
    id: int | None = Field(default=None, primary_key=True)
    text: str
>>> from sqlmodel import SQLModel
>>> print(SQLModel.metadata.tables)
FacadeDict({})
>>> import models  # the "unused" import
>>> print(SQLModel.metadata.tables)
FacadeDict({'tip': Table('tip', ...)})

You never touch models.Tip after importing it, yet the registry filled up. That's the exact env.py situation: the import runs the module, the module runs the class definition, and the class definition registers the table.

Is this metaprogramming?

SQLModelMetaclass inherits from both Pydantic's ModelMetaclass and SQLAlchemy's DeclarativeMeta.

This is the same mechanism behind a lot of Python you use daily. If you've ever wondered how a class declaration can acquire behavior you never explicitly wrote, the answer is often a metaclass or __init_subclass__ doing work at definition time.

A metaclass is code that runs when a class is defined. Here it inspects a keyword, processes the model's fields, and delegates table-model construction to SQLAlchemy.

Your field annotations get read at that moment and turned into Column objects.

The practical payoff: next time autogenerate produces an empty migration or wants to drop all your tables, you'll know the cause is registration order, not Alembic being broken. Something imported the models too late, or not at all, and metadata.tables was empty when Alembic read it.

Looking at libraries

Reading a library's source to answer "why is this import here" is a great way to learn the library and Python itself. You don't have to understand every line. Follow the call path of a single feature and you'll often see how the pieces fit together.

It's also about not taking things at face value. A noqa means you're deliberately silencing something a linter flagged. Here it was warranted, but only after understanding why the import exists.

AI can follow this call path for you in seconds. That's useful.

But the valuable skill isn't memorizing that Table.__new__ calls _add_table. It's having the instinct to stop when something looks strange and ask: why is this here?

As AI writes more of the code, this habit matters now more than ever I think. If you don't understand the code, you can't maintain / improve it over time.

Keep reading

September 04, 2026 12:00 AM UTC

September 03, 2026


Graham Dumpleton

Beyond callables in wrapture

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

Attribute bindings

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

class Model:
    status = "draft"

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

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

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

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

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

    print(tape.tree())

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

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

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

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

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

status.on_set.decorates(guard)

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

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

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

    def load(self):
        return 42

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

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

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

Module attributes

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

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

Value bindings

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Mapping bindings

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

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

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

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

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

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

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

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

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

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

A callable held in a mapping

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

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

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

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

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

Generators and iteration

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

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

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


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


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

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

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

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

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

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

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

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

cursors = []
outcomes = []

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

pages.on_call.transforms_result(watch)

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

cursors.clear(); outcomes.clear()

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

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

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

def fail_at(position, exc):
    seen = 0

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

    return check

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

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

One lifecycle for all of it

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

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

September 03, 2026 09:39 PM UTC

Phased behaviour in wrapture

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

The code under test

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

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


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

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

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

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

Phases

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

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

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

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

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

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

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

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

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

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

Ending a phase on a condition

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

class CircuitOpen(Exception):
    pass


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


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

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

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

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

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

Sequences

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

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


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


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

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

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

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

Advancing from outside

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

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

with remote:
    client = Client()

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

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

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

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


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

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

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


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


health.on_call.validates_result(note_recovery)

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

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

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

Where phases fit in a test

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

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

What's next

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

September 03, 2026 09:39 PM UTC


Jaime Buelta

The Many Challenges in Integrating Information for AI Agents

Recently I’ve been thinking quite a lot about information availability for agents, and the fact that this is a very difficult and potentially irresoluble problem. Let me try to explain myself. I talked before about a mental model on differentiating between the LLM models and the tools that access those models. I think that now that’s clearer as we are using more and more agents. We understand that we can use Claude Code with different models (like Sonnet or Opus) that change the capacity of the agent, but not its capabilities. The... Read More

September 03, 2026 07:12 AM UTC


Graham Dumpleton

Recording calls with wrapture

In unit testing with wrapture the tests leaned on a timeline and a tape to assert on what happened, and I skipped over what those actually are. This post is about the recording side of wrapture: what gets recorded, what one event holds, how a test reads the record back, and the whole-tape views that answer questions about the flow between calls rather than about any one of them.

The example is a resource leak, because it is the kind of bug the recording model was made for. Code that acquires a connection has to release it on every path out: the normal return, the early return, and the exception. The path that forgets is the one nobody looks at, and it does not fail. Nothing raises, nothing returns the wrong value, the test passes, and the pool runs dry a week later in production. The failure is an absence, and asserting on an absence needs a record of what did happen, on the real objects, including objects minted mid-call that the test never held.

The code under test

A stand-in for any pooled resource. Database.connect() mints a Connection, and a connection answers queries until close() sets its closed flag:

class Connection:
    def __init__(self, number):
        self.number = number
        self.closed = False

    def execute(self, sql):
        if self.closed:
            raise RuntimeError("connection is closed")
        return [(1, "widget")] if "id = 1" in sql else []

    def close(self):
        self.closed = True

    def __repr__(self):
        return f"<Connection {self.number}>"


class Database:
    def __init__(self):
        self.issued = 0

    def connect(self):
        self.issued += 1
        return Connection(self.issued)

The repository is where the bug lives. count() releases in a finally, so it is safe on every path. find() releases only when a row was found; the not-found early return leaks its connection:

class Repository:
    def __init__(self, database):
        self.database = database

    def count(self, table):
        connection = self.database.connect()
        try:
            return len(connection.execute(f"SELECT * FROM {table}"))
        finally:
            connection.close()

    def find(self, table, key):
        connection = self.database.connect()
        rows = connection.execute(f"SELECT * FROM {table} WHERE id = {key}")
        if not rows:
            return None
        connection.close()
        return rows[0]


def report(repository, keys):
    found = [repository.find("products", key) for key in keys]
    return repository.count("products"), [row for row in found if row]

Running report(Repository(Database()), [1, 2]) returns (0, [(1, 'widget')]), which is correct. Nothing about that result says a connection was left open.

The usual way to test this is a hand-written fake Database whose connect() appends to a list, with connections that flip a flag, and a test that walks the list. It works, but it tests a substitute. The real classes never run, the fake has to be kept in step with them, and every acquiring class in the codebase needs its own. The record you want is of the real calls.

The timeline and the tape

Bind connect on Database and close on Connection, and record both onto one tape. Neither binding has any behaviour configured, so they observe and nothing else:

connect = wrapture.binding(Database, "connect")
close = wrapture.binding(Connection, "close")

with wrapture.timeline(connect, close) as tape:
    report(Repository(Database()), [1, 2])

print(tape.tree())
__main__:Database.connect()  -> <Connection 1>
__main__:Connection.close()  -> None
__main__:Database.connect()  -> <Connection 2>
__main__:Database.connect()  -> <Connection 3>
__main__:Connection.close()  -> None

Three acquisitions, two releases, and reading down the tape you can already see which one has no partner.

The two words are two views of one thing. The timeline is the scope: with wrapture.timeline(...) opens it, the bindings handed to it are applied on entry and removed on exit, and while it is open every call through every applied binding records an event. The tape is what the scope holds. Bindings applied by other means, a fixture or an outer with, record onto an open tape as well, and a binding applied with no timeline open records nothing and costs almost nothing beyond wrapt's own dispatch, so leaving bindings applied and only occasionally recording is a supported pattern rather than a mistake.

Notice that close is bound on the Connection class, not on any connection object. The connections do not exist when the test starts; connect() mints them mid-call. A binding on the class wraps the method for every instance, present and future, which is exactly what covers objects a factory hands out. A mock injected through a seam cannot see those objects at all.

What one event holds

Each call through a binding inside the scope records one event, and an event is a good deal richer than a mock's call record. The fields a test typically reads are path, the fully qualified location in module:qualname form; instance, the object the method was called on; arguments, the call normalised against the real signature with defaults applied, so charge(500) and charge(amount=500) record identically; result, the real return value, or exception when the call raised instead; and seq, parent_id and depth, which place the event in the call tree. There are timings too, started and duration, with recording's own bookkeeping excluded from the figure.

Because the values are real, they can be compared across events. A connect event's result is the connection it minted, and a close event's instance is the connection it was called on, so the leaked connections are the difference between the two sets:

with wrapture.timeline(connect, close):
    report(Repository(Database()), [1, 2])

    acquired = {event.result for event in connect.events}
    released = {event.instance for event in close.events}

    print(acquired - released)
{<Connection 2>}

That is the whole question answered, and it needed nothing from the repository. Events record what actually flowed, behaviour included: a call stubbed with returns() records the stubbed result, a failure injected with raises() records that exception, and when transforms_args() rewrote the arguments the event keeps both the arguments as the caller sent them and the ones the real method received, which no substitution-based tool can record because replacing a function discards what it would have been called with.

Filters narrow, assertions conclude

A binding's events property is a filterable view over the tape for that one binding, and it works inside the with block after the code under test has run. One naming rule holds across the whole package: a method whose name starts with assert_ raises immediately, one starting with expect_ declares and is checked when the scope closes, and everything else returns data. A mistyped assertion name is therefore an AttributeError rather than the silent pass mock's assert_calld_once was famous for.

Filters chain and never raise. with_args(amount=500) keeps calls whose normalised arguments include the given values, with_instance(obj) keeps calls made on exactly that object by identity, raising(TimeoutError) keeps calls that raised, returning(value) keeps calls that returned it, and matching(predicate) is the escape hatch. Assertions then conclude: assert_never(), assert_once(), assert_times(n), assert_at_least(n) and assert_at_most(n). Each returns the log on success so a passing assertion can keep chaining, and each prints the events it looked at on failure. Asserting three closes when there were two gives:

AssertionError: expected exactly 3 event(s), got 2
<EventLog __main__:Connection.close: 2 event(s)>
    __main__:Connection.close()
    __main__:Connection.close()

An assertion is written where it runs. An expectation is the same claim declared on the binding up front, before the run, and verified when the timeline exits:

close = wrapture.binding(Connection, "close").expect_times(3)

with wrapture.timeline(connect, close):
    report(Repository(Database()), [1, 2])
ExpectationNotMetError: declared expectation on __main__:Connection.close not met: expected exactly 3 event(s), got 2
<EventLog __main__:Connection.close: 2 event(s)>
    __main__:Connection.close()
    __main__:Connection.close()

ExpectationNotMetError derives from AssertionError, so test frameworks report it as a failure. Expectations read as a contract at the top of the test with the body free of bookkeeping, and an expectation with nothing recording is an error rather than a pass. Verification is skipped when the block itself raised, since the in-flight failure is the real cause and a verification error on top would bury it.

The tree names the culprit

Counting says something leaked, and pairing says what. To say who, add the repository methods to the timeline. The tape then nests each acquire and release under the method that made it, and tape.children_of() walks the tree, so a root whose children include a connect but no close names itself:

find = wrapture.binding(Repository, "find")
count = wrapture.binding(Repository, "count")

with wrapture.timeline(find, count, connect, close) as tape:
    report(Repository(Database()), [1, 2])

    print(tape.tree())

    for caller in tape.roots():
        paths = [child.path for child in tape.children_of(caller)]
        if "__main__:Connection.close" not in paths:
            print("leaked by", caller)
__main__:Repository.find(table='products', key=1)  -> (1, 'widget')
  __main__:Database.connect()  -> <Connection 1>
  __main__:Connection.close()  -> None
__main__:Repository.find(table='products', key=2)  -> None
  __main__:Database.connect()  -> <Connection 2>
__main__:Repository.count(table='products')  -> 0
  __main__:Database.connect()  -> <Connection 3>
  __main__:Connection.close()  -> None
leaked by __main__:Repository.find(table='products', key=2)

The tree shows the bug as it happened. find() with a key that matched released its connection, find() with a key that did not match never called close(), and count() released on the way out of its finally.

When the method is long, or acquires in several places, you want the line rather than the method. Stack capture on the acquire binding records the calling frame with each event, priced per binding so only the acquire pays for it:

connect = wrapture.binding(Database, "connect", stack="caller")

with wrapture.timeline(connect, close):
    report(Repository(Database()), [1, 2])

    released = {event.instance for event in close.events}
    for event in connect.events:
        if event.result not in released:
            frame = wrapture.stack_frames(event.stack)[0]
            print(f"{event.result} acquired at line {frame.lineno} in {frame.function}, never released")
<Connection 2> acquired at line 40 in Repository.find, never released

Order across bindings

Per-binding logs answer questions about one call site; the tape answers questions about the flow between them. tape.assert_order(connect, close) is a subsequence check across any bindings: other events may appear before, between and after, and only the relative order of the named bindings' events matters. A step can also be a filtered log, which is how to say which call, so tape.assert_order(charge.events.raising(TimeoutError), refund) reads as "the refund came after the charge that timed out". consecutive=True requires the steps to match a consecutive run with nothing of those bindings' in between, and exact=True requires those bindings' events to be exactly the steps, which are mock's assert_has_calls and mock_calls == respectively, except that they work across bindings instead of within one mock.

On failure the message names where the walk stalled and prints the actual timeline, which reads far better than a list diff. Asserting a close before a connect on a run that only leaked:

AssertionError: expected order not satisfied; stalled waiting for __main__:Database.connect (position 2 of 2)
  actual timeline:
    __main__:Repository.find(table='products', key=2)
    __main__:Database.connect()
    __main__:Repository.count(table='products')
    __main__:Database.connect()
    __main__:Connection.close()

Scoping instead of resetting

A tape is never cleared. Where a mock suite reaches for reset_mock() to discard setup calls before the act step, wrapture opens the timeline around the part that counts. Timelines nest, and an inner timeline() with no arguments records only what happens inside it while the outer one keeps the whole run:

with wrapture.timeline(connect, close) as whole:
    repository = Repository(Database())
    repository.count("products")                # lands on `whole` only

    with wrapture.timeline() as act:
        repository.find("products", 1)
        connect.events.assert_once()            # the act step alone

Inside the inner block connect.events reads the innermost tape, so the count is one even though the outer tape holds four events. The same scoping is how a phased test keeps each phase's counts separate, one timeline per phase, with the same bindings applied on entry and removed on exit each time. The second phase can then state assert_never() outright, where one cumulative tape could only say the count is still one.

Messages and phases as events

Calls are not the only thing that records. An attribute binding records reads and writes of an attribute as get and set events on the same tape, which for this example means the closed flag can be watched directly rather than inferred from close() being called. That is a subject for a later post. Two other event producers are worth knowing about now, because they change what a test can pin an assertion to.

The first is log capture. capture_logs() records standard library logging onto the tape as events of kind "log", selected by logger name pattern and level, and it applies like a binding so timeline() accepts it alongside them. Give the repository a warning when nothing is found, and the message lands inside the call that logged it:

logs = wrapture.capture_logs("myapp.*")

with wrapture.timeline(find, connect, close, logs) as tape:
    report(Repository(Database()), [1, 2])

    print(tape.tree())

    warning = logs.events.at_level("WARNING").with_message("*no row*").assert_once().first
    assert tape.parent_of(warning) is find.events.with_args(key=2).first
__main__:Repository.find(table='products', key=1)  -> (1, 'widget')
  __main__:Database.connect()  -> <Connection 1>
  __main__:Connection.close()  -> None
__main__:Repository.find(table='products', key=2)  -> None
  __main__:Database.connect()  -> <Connection 2>
  log myapp.repo WARNING 'no row in products with id 2'
__main__:Database.connect()  -> <Connection 3>
__main__:Connection.close()  -> None

That last assertion is the one pytest's caplog has no words for: the warning was logged by this call, not merely somewhere during the test. Capture sits at Logger.handle, so it hears each record once on the logger that emitted it, before propagation and regardless of handler configuration, and nothing the application configured is touched.

The second is a block. wrapture.block(name) is a context manager the code, or the test, uses to declare a stretch of code as one event, with everything recorded inside it nested underneath. In a test body it names the phases of an integration test so that "the events during the second request" stops being an exercise in parent-chasing:

with wrapture.timeline(connect, close) as tape:
    repository = Repository(Database())

    with wrapture.block("lookups"):
        repository.find("products", 1)
        repository.find("products", 2)

    with wrapture.block("summary"):
        repository.count("products")

    lookups = tape.blocks("lookups").assert_once().first
    tape.within(lookups).for_binding(close).assert_once()
block: lookups
  __main__:Database.connect()  -> <Connection 1>
  __main__:Connection.close()  -> None
  __main__:Database.connect()  -> <Connection 2>
block: summary
  __main__:Database.connect()  -> <Connection 3>
  __main__:Connection.close()  -> None

tape.within(event) scopes the whole query surface to one block's contents, so an ordering assertion on the view never sees an event outside it. In application code the same marker is inert when nothing is listening, so it can stay in production code permanently, which is what makes the same block a span when the events are going to a tracing backend rather than a test.

As a pytest test

In a test the pairing becomes the assertion, and the failure message carries the leaked connections and where each was acquired. close is given a declared expectation of at least one call, so a path that acquires nothing at all cannot pass by accident:

def test_find_releases_its_connection():
    connect = wrapture.binding(Database, "connect", stack="caller")
    close = wrapture.binding(Connection, "close").expect_at_least(1)

    with wrapture.timeline(connect, close):
        report(Repository(Database()), [1, 2])

        released = {event.instance for event in close.events}
        leaked = [
            (event.result, wrapture.stack_frames(event.stack)[0])
            for event in connect.events
            if event.result not in released
        ]

        assert not leaked, f"connections left open: {leaked}"

The test fails today, naming <Connection 2> and the frame inside find(). Fix the early return with a finally and it passes. With the pytest plugin enabled the tape's tree is attached to the failure report as well, so the output shows what ran rather than only the assertion that tripped.

What's next

Everything in this post recorded real calls with the bindings doing nothing but watch. The next post is about the other direction, changing what a call does, and specifically about behaviour that changes over time as the code under test keeps calling, which is what retry logic and circuit breakers need from a test.

September 03, 2026 12:00 AM UTC

September 02, 2026


Django Weblog

Django bugfix release issued: 6.1.1

Today we've issued the 6.1.1 bugfix release.

The release package and checksums are available from our downloads page, as well as from the Python Package Index.

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

September 02, 2026 05:30 PM UTC


Tryton News

Tryton News September 2026

September brings a balance of trytond internals and business-module refinements. The server now retries queued tasks that were dropped because a worker died, enforces request timeouts for the whole request, and exposes routes so RPC endpoints can be registered declaratively. On the user side, European VAT numbers are now validated in the background, stock periods close themselves, and the IBAN editor formats the number as it is typed-in. All of this builds on our last LTS release 8.0.

For an in depth overview of all the Tryton issues please take a look at our issue tracker or see the issues and merge requests filtered by label.

Changes for the User

Accounting, Invoicing and Payments

The automatic VIES check replaces the previous wizard for European VAT numbers. A background task validates new and modified EU VAT identifiers and refreshes them once the configured validity period has expired. The validity of the identifier is also checked when posting an invoice, so a stale or invalid VAT number is caught before the invoice is sent. The last validation-state and validation-date is displayed in the identifier view.

The party required setting can no longer be changed on an account that already has account moves. This avoids mixing moves with and without a party on the same account, which used to confuse later reports.

The redundant prefix is dropped from the statement and payment journals actions. So the menus for accounting, statements and payments no longer repeat the “statement” or “payment” word.

The IBAN of a bank account on a party is now formatted with spaces between groups of four characters on changing the field. This makes it easier to check the number was entered correctly.

The version of the Stripe API used by the payment gateway is updated to the latest one.

Stock, Production and Shipments

Stock periods can now be created and closed automatically by two scheduled tasks, automating the manual steps at the start and end of each period.

On a stock move that is linked to a shipment but opened outside the shipment form, the from and to locations are now read-only. This prevents the location domain inherited from the shipment from being bypassed. The domain is also enforced on stock moves, so each move matches at least one of the shipment’s two move fields: incoming moves or inventory moves.

User Interface

In the SAO client, tabs now scroll horizontally when they overflow the tab list. The scrolling is smooth for a nicer effect when adding a new tab.

New Releases

We released bug fixes for the currently maintained long term support series 8.0, 7.8, and 7.0.

Changes for Implementers and Developers

A mixin can now be added to the Database and TableHandler from the configuration. This is used by the gis module to register PostGIS as a backend mixin.

The Pool now exposes routes, so RPC endpoints can be registered declaratively using a Router.

The trytond request timeout is now enforced for the whole request, not just for individual queries. A threading timer injects an exception into the running thread when the timeout expires.

Queued tasks that were dequeued but never finished, because the worker was killed, are now retried by a scheduled task. The retry uses the finished_at timestamp and the task lock to know which tasks are still outstanding.

Initial draft powered by Minimax-M3. Curated and finalised by human hands.

1 post - 1 participant

Read full topic

September 02, 2026 06:00 AM UTC


Python GUIs

Are there any built-in QIcons? — Using built-in icons for your apps.

In the tutorials on this site and in my books I recommend using the fugue icons set. This is a free set of icons from Yusuke Kamiyamane, a freelance designer from Tokyo. The set contains 3,570 icons and is a great way to add some nice visual touches to your application without much hassle.

But this isn't the only icon set available, and there's another option you may not know about. Read on for details.

Veronica asked:

Are there any built-in icons with PyQt5? I have searched the web and it seems like there are some but I can't find any examples of them being used. Does it depend on the situation? If so, then in which cases can I use an icon without downloading it first?

First we need to clarify what is meant by built-in icons — it can mean two different things depending on context — either Qt built-in, or system built-in (Linux only). I'll start with the Qt built-ins as that's cross-platform (they're available on Windows, macOS and Linux).

Qt Standard Icons (QStyle StandardPixmap)

Qt ships with a small set of standard icons you can use in any of your applications for common actions. These built-in icons are accessed through the QStyle.StandardPixmap enum and retrieved using style().standardIcon(). They're available on all platforms — Windows, macOS, and Linux — making them a convenient choice when you need common UI icons without bundling external assets.

The following Python script displays all the built-in Qt standard icons in a grid layout:

python
import sys

from PyQt5.QtWidgets import QApplication, QGridLayout, QPushButton, QStyle, QWidget


class Window(QWidget):
    def __init__(self):
        super().__init__()

        icons = sorted([attr for attr in dir(QStyle) if attr.startswith("SP_")])
        layout = QGridLayout()

        for n, name in enumerate(icons):
            btn = QPushButton(name)

            pixmapi = getattr(QStyle, name)
            icon = self.style().standardIcon(pixmapi)
            btn.setIcon(icon)
            layout.addWidget(btn, n // 4, n % 4)

        self.setLayout(layout)


app = QApplication(sys.argv)

w = Window()
w.show()

app.exec_()


python
import sys

from PyQt6.QtWidgets import (QApplication, QGridLayout, QPushButton, QStyle,
                             QWidget)


class Window(QWidget):
    def __init__(self):
        super().__init__()

        icons = sorted([attr for attr in dir(QStyle.StandardPixmap) if attr.startswith("SP_")])
        layout = QGridLayout()

        for n, name in enumerate(icons):
            btn = QPushButton(name)

            pixmapi = getattr(QStyle.StandardPixmap, name)
            icon = self.style().standardIcon(pixmapi)
            btn.setIcon(icon)
            layout.addWidget(btn, int(n/4), int(n%4))

        self.setLayout(layout)


app = QApplication(sys.argv)

w = Window()
w.show()

app.exec()

python
import sys

from PySide2.QtWidgets import QApplication, QGridLayout, QPushButton, QStyle, QWidget


class Window(QWidget):
    def __init__(self):
        super().__init__()

        icons = sorted([attr for attr in dir(QStyle) if attr.startswith("SP_")])
        layout = QGridLayout()

        for n, name in enumerate(icons):
            btn = QPushButton(name)

            pixmapi = getattr(QStyle, name)
            icon = self.style().standardIcon(pixmapi)
            btn.setIcon(icon)
            layout.addWidget(btn, n // 4, n % 4)

        self.setLayout(layout)


app = QApplication(sys.argv)

w = Window()
w.show()

app.exec_()

python
import sys

from PySide6.QtWidgets import QApplication, QGridLayout, QPushButton, QStyle, QWidget


class Window(QWidget):
    def __init__(self):
        super().__init__()

        icons = sorted(
            [attr for attr in dir(QStyle.StandardPixmap) if attr.startswith("SP_")]
        )
        layout = QGridLayout()

        for n, name in enumerate(icons):
            btn = QPushButton(name)

            pixmapi = getattr(QStyle, name)
            icon = self.style().standardIcon(pixmapi)
            btn.setIcon(icon)
            layout.addWidget(btn, n // 4, n % 4)

        self.setLayout(layout)


app = QApplication(sys.argv)

w = Window()
w.show()

app.exec()

If you run this script you'll see the following window, listing all the available built-in Qt icons.

Qt's Built-in Standard Icons displayed in a grid using QStyle StandardPixmap Qt's Built-in Icons — all QStyle.StandardPixmap icons shown with their names

Complete List of Qt Built-in Standard Icons (QStyle.StandardPixmap)

The full table of all QStyle.StandardPixmap icon names is below. You can use any of these in PyQt5, PyQt6, PySide2, or PySide6 applications.

.. .. ..
SP_ArrowBack SP_DirIcon SP_MediaSkipBackward
SP_ArrowDown SP_DirLinkIcon SP_MediaSkipForward
SP_ArrowForward SP_DirOpenIcon SP_MediaStop
SP_ArrowLeft SP_DockWidgetCloseButton SP_MediaVolume
SP_ArrowRight SP_DriveCDIcon SP_MediaVolumeMuted
SP_ArrowUp SP_DriveDVDIcon SP_MessageBoxCritical
SP_BrowserReload SP_DriveFDIcon SP_MessageBoxInformation
SP_BrowserStop SP_DriveHDIcon SP_MessageBoxQuestion
SP_CommandLink SP_DriveNetIcon SP_MessageBoxWarning
SP_ComputerIcon SP_FileDialogBack SP_TitleBarCloseButton
SP_CustomBase SP_FileDialogContentsView SP_TitleBarContextHelpButton
SP_DesktopIcon SP_FileDialogDetailedView SP_TitleBarMaxButton
SP_DialogApplyButton SP_FileDialogEnd SP_TitleBarMenuButton
SP_DialogCancelButton SP_FileDialogInfoView SP_TitleBarMinButton
SP_DialogCloseButton SP_FileDialogListView SP_TitleBarNormalButton
SP_DialogDiscardButton SP_FileDialogNewFolder SP_TitleBarShadeButton
SP_DialogHelpButton SP_FileDialogStart SP_TitleBarUnshadeButton
SP_DialogNoButton SP_FileDialogToParent SP_ToolBarHorizontalExtensionButton
SP_DialogOkButton SP_FileIcon SP_ToolBarVerticalExtensionButton
SP_DialogResetButton SP_FileLinkIcon SP_TrashIcon
SP_DialogSaveButton SP_MediaPause SP_VistaShield
SP_DialogYesButton SP_MediaPlay
SP_DirClosedIcon SP_MediaSeekBackward
SP_DirHomeIcon SP_MediaSeekForward

How to Use a Specific Built-in QIcon in Your Application

In our script above to get the icons we're looking them up by name on the QStyle object, using getattr — but this is only necessary so we can iterate over the list of names and display the icon next to their name. If you want a specific icon you can access it directly. For example, to use the critical message box icon:

python
pixmapi = QStyle.SP_MessageBoxCritical
icon = self.style().standardIcon(pixmapi)
python
pixmapi = QStyle.StandardPixmap.SP_MessageBoxCritical
icon = self.style().standardIcon(pixmapi)

In PyQt6 the flags must be accessed via QStyle.StandardPixmap. In other versions, they are available on QStyle itself.

Once you have the QIcon object, you can use it anywhere Qt expects an icon — on buttons, toolbars, menus, window titles, and more.

Free Desktop Theme Icons (Linux)

On Linux desktops there is something called the Free Desktop Specification which defines standard names for icons for specific actions.

If your application uses these specific icon names (and loads the icon from a "theme") then on Linux your application will use the current icon set which is enabled on the desktop. The idea is to make all applications have the same look & feel while remaining user configurable.

Setting Theme Icons in Qt Designer

To use Free Desktop theme icons within Qt Designer you would select the drop-down and choose "Set Icon From Theme..."

Setting an icon from theme in Qt Designer

You then enter the name of the icon you want to use, e.g. document-new (the full list of valid names).

Entering icon theme name in Qt Designer

Setting Theme Icons in Python Code with QIcon.fromTheme()

If you're not using Qt Designer, you can set icons from a theme in your Python code using QIcon.fromTheme():

python
        icon = QtGui.QIcon.fromTheme("document-new")
        self.pushButton_n6.setIcon(icon)

If you're developing a cross-platform Python GUI application you'll still need your own icons for Windows & macOS, but by using these theme names you can ensure that your app looks native when run on Linux.

Does the QIcon.fromTheme() method only work on Linux?

Qt themes work on all platforms, it's just that on Linux you get the theme for free. On non-Linux platforms you have to define your own icon theme from scratch. However, this is only really worth doing if you want to have a Linux-native look — for other use cases the QResource system is simpler.

Summary

There are two ways to use built-in icons in your PyQt or PySide applications without downloading external icon sets:

  1. Qt Standard Icons (QStyle.StandardPixmap) — A cross-platform set of common UI icons built into Qt itself, accessible via style().standardIcon(). These work on Windows, macOS, and Linux.
  2. Free Desktop Theme Icons — Linux-specific system icons accessed via QIcon.fromTheme() that match the user's current desktop theme for a native look and feel.

For most cross-platform PyQt6 or PySide6 projects, bundling a dedicated icon set like Fugue gives you the most control over your app's appearance. But for quick prototypes or platform-specific tools, Qt's built-in icons are a convenient and dependency-free option.

For an in-depth guide to building Python GUIs with PySide6 see my book, Create GUI Applications with Python & Qt6.

September 02, 2026 06:00 AM UTC

Understanding QPainter Coordinates in PyQt6 — How the coordinate system works for drawing on canvases in PyQt6

I really having trouble understanding the coordinate system used in QPainter. Can you explain how this works?

If you've started drawing with QPainter in PyQt6, you might have been surprised the first time you drew a line. You pass in coordinates like (10, 10, 300, 200) and the result doesn't look quite like what you'd expect from a math class. That's because QPainter uses a coordinate system where the origin (0, 0) is in the top-left corner of the canvas, not the bottom-left.

This catches a lot of people off guard, so in this tutorial we'll walk through exactly how QPainter coordinates work, how to visualize them, and how to convert between screen coordinates and the mathematical coordinate system you might be more familiar with.

The QPainter coordinate system

In most math courses, you learn to plot points on a Cartesian plane where (0, 0) is at the bottom-left. The x-axis increases to the right, and the y-axis increases upward.

QPainter (and most screen-based graphics systems) does things differently:

This means that as your y value gets larger, you move down the screen, not up. Here's a simple diagram to illustrate:

python
(0,0) &boxh&boxh&boxh&boxh&boxh&boxh&boxh&boxh&boxh&boxh&boxh&boxh&boxh&boxh&boxh► x increases
  &boxv
  &boxv
  &boxv
  &boxv
  ▼
  y increases

So when you call painter.drawLine(10, 10, 300, 200), you're drawing a line from a point near the top-left corner down to a point further right and further down the canvas.

Seeing it in action

Let's draw a line and annotate the start and end points so you can see exactly where the coordinates land. This complete example creates a small window with a QLabel displaying a QPixmap that we draw onto.

python
import sys

from PyQt6.QtCore import Qt
from PyQt6.QtGui import QPixmap, QPainter, QPen, QFont
from PyQt6.QtWidgets import QApplication, QLabel, QMainWindow


class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("QPainter Coordinates")

        canvas = QPixmap(400, 300)
        canvas.fill(Qt.white)

        painter = QPainter(canvas)

        # Draw the line.
        pen = QPen(Qt.blue, 2)
        painter.setPen(pen)
        painter.drawLine(10, 10, 300, 200)

        # Annotate the start point.
        pen = QPen(Qt.red, 6)
        painter.setPen(pen)
        painter.drawPoint(10, 10)

        painter.setPen(QPen(Qt.black))
        painter.setFont(QFont("Arial", 10))
        painter.drawText(20, 15, "(10, 10)")

        # Annotate the end point.
        pen = QPen(Qt.red, 6)
        painter.setPen(pen)
        painter.drawPoint(300, 200)

        painter.setPen(QPen(Qt.black))
        painter.drawText(220, 220, "(300, 200)")

        painter.end()

        label = QLabel()
        label.setPixmap(canvas)
        self.setCentralWidget(label)


app = QApplication(sys.argv)
window = MainWindow()
window.show()
app.exec()

Run this and you'll see a blue line drawn from near the top-left corner of the canvas down to a point lower and to the right. The red dots and labels mark each endpoint, making it clear that (10, 10) is near the top-left and (300, 200) is toward the bottom-right.

QPainter coordinates with annotated points

This is the expected behavior — the y-axis points downward.

Why does it work this way?

Screen coordinate systems with the origin at the top-left are a convention inherited from early computer displays, where the electron beam in a CRT monitor scanned from the top-left of the screen, line by line, downward. This convention carried forward into virtually all modern windowing and graphics systems, including Qt.

Converting from mathematical coordinates

If you're working with data that uses standard mathematical coordinates (origin at the bottom-left, y increasing upward), you'll need to convert the y values before drawing. The formula is straightforward:

python
y_screen = height - 1 - y_math

Where:

The - 1 is there because pixel coordinates are zero-indexed. A QPixmap with a height of 300 has valid y coordinates from 0 to 299.

Let's say you have a canvas that's 300 pixels tall, and you want to draw a line from the mathematical point (10, 10) to (300, 200) as if the origin were at the bottom-left. You'd convert each y coordinate:

python
height = 300

# Mathematical coordinates.
x1, y1_math = 10, 10
x2, y2_math = 300, 200

# Convert y values for screen drawing.
y1_screen = height - 1 - y1_math  # 300 - 1 - 10 = 289
y2_screen = height - 1 - y2_math  # 300 - 1 - 200 = 99

painter.drawLine(x1, y1_screen, x2, y2_screen)
# Equivalent to: painter.drawLine(10, 289, 300, 99)

Now the line will go from near the bottom-left upward to the right — just like you'd expect on a math plot.

A helper function for coordinate conversion

If you're doing a lot of drawing with mathematical coordinates, a small helper function keeps things tidy:

python
def math_to_screen(x, y, height):
    """Convert mathematical (bottom-left origin) coordinates
    to screen (top-left origin) coordinates."""
    return x, height - 1 - y

You can then use it like this:

python
x1, y1 = math_to_screen(10, 10, canvas_height)
x2, y2 = math_to_screen(300, 200, canvas_height)
painter.drawLine(x1, y1, x2, y2)

Comparing both coordinate systems side by side

This complete example draws the same line using both coordinate systems, so you can see the difference clearly. The left canvas uses QPainter's native coordinates (origin top-left), and the right canvas converts from mathematical coordinates (origin bottom-left).

python
import sys

from PyQt6.QtCore import Qt
from PyQt6.QtGui import QPixmap, QPainter, QPen, QFont
from PyQt6.QtWidgets import (
    QApplication, QLabel, QMainWindow, QHBoxLayout, QVBoxLayout, QWidget,
)


def math_to_screen(x, y, height):
    """Convert mathematical (bottom-left origin) coordinates
    to screen (top-left origin) coordinates."""
    return x, height - 1 - y


def draw_annotated_line(canvas, x1, y1, x2, y2, label_start, label_end):
    """Draw a line on a QPixmap with annotated endpoints."""
    painter = QPainter(canvas)

    # Draw the line.
    pen = QPen(Qt.blue, 2)
    painter.setPen(pen)
    painter.drawLine(x1, y1, x2, y2)

    # Draw and label the start point.
    painter.setPen(QPen(Qt.red, 6))
    painter.drawPoint(x1, y1)
    painter.setPen(QPen(Qt.black))
    painter.setFont(QFont("Arial", 9))
    painter.drawText(x1 + 8, y1 + 5, label_start)

    # Draw and label the end point.
    painter.setPen(QPen(Qt.red, 6))
    painter.drawPoint(x2, y2)
    painter.setPen(QPen(Qt.black))
    painter.drawText(x2 - 80, y2 + 20, label_end)

    painter.end()


class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("Coordinate System Comparison")

        canvas_width = 350
        canvas_height = 300

        # --- Left canvas: native QPainter coordinates ---
        canvas_native = QPixmap(canvas_width, canvas_height)
        canvas_native.fill(Qt.white)
        draw_annotated_line(
            canvas_native,
            10, 10, 300, 200,
            "(10, 10)", "(300, 200)",
        )

        label_native = QLabel()
        label_native.setPixmap(canvas_native)

        title_native = QLabel("Screen coordinates\n(origin top-left)")
        title_native.setAlignment(Qt.AlignCenter)
        title_native.setStyleSheet("font-weight: bold;")

        left_layout = QVBoxLayout()
        left_layout.addWidget(title_native)
        left_layout.addWidget(label_native)

        # --- Right canvas: mathematical coordinates converted ---
        canvas_math = QPixmap(canvas_width, canvas_height)
        canvas_math.fill(Qt.white)

        sx1, sy1 = math_to_screen(10, 10, canvas_height)
        sx2, sy2 = math_to_screen(300, 200, canvas_height)
        draw_annotated_line(
            canvas_math,
            sx1, sy1, sx2, sy2,
            f"math(10,10) → screen({sx1},{sy1})",
            f"math(300,200) → screen({sx2},{sy2})",
        )

        label_math = QLabel()
        label_math.setPixmap(canvas_math)

        title_math = QLabel("Math coordinates converted\n(origin bottom-left)")
        title_math.setAlignment(Qt.AlignCenter)
        title_math.setStyleSheet("font-weight: bold;")

        right_layout = QVBoxLayout()
        right_layout.addWidget(title_math)
        right_layout.addWidget(label_math)

        # --- Combine both sides ---
        main_layout = QHBoxLayout()
        main_layout.addLayout(left_layout)
        main_layout.addLayout(right_layout)

        container = QWidget()
        container.setLayout(main_layout)
        self.setCentralWidget(container)


app = QApplication(sys.argv)
window = MainWindow()
window.show()
app.exec()

When you run this, you'll see two canvases side by side. On the left, the line slopes downward from the top-left, which is what QPainter naturally produces. On the right, the same mathematical coordinates have been converted, so the line slopes upward from the bottom-left — matching what you'd see on a standard math plot.

Drawing axes to orient yourself

When you're experimenting with coordinates, it can help to draw a simple set of axes on your canvas. Here's a quick helper that draws x and y axes with the origin marked:

python
import sys

from PyQt6.QtCore import Qt
from PyQt6.QtGui import QPixmap, QPainter, QPen, QFont
from PyQt6.QtWidgets import QApplication, QLabel, QMainWindow


def draw_axes(painter, width, height):
    """Draw simple x and y axes with labels."""
    painter.setPen(QPen(Qt.gray, 1, Qt.DashLine))

    # X-axis along the top (y=0).
    painter.drawLine(0, 0, width - 1, 0)

    # Y-axis along the left (x=0).
    painter.drawLine(0, 0, 0, height - 1)

    # Label the origin.
    painter.setPen(QPen(Qt.darkGray))
    painter.setFont(QFont("Arial", 8))
    painter.drawText(5, 15, "(0, 0)")

    # Label the x direction.
    painter.drawText(width - 60, 15, f"x → ({width - 1})")

    # Label the y direction.
    painter.save()
    painter.translate(15, height - 10)
    painter.drawText(0, 0, f"y ↓ ({height - 1})")
    painter.restore()


class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("QPainter Axes")

        canvas_width = 400
        canvas_height = 300

        canvas = QPixmap(canvas_width, canvas_height)
        canvas.fill(Qt.white)

        painter = QPainter(canvas)
        draw_axes(painter, canvas_width, canvas_height)

        # Draw some points to see where they land.
        points = [
            (50, 50),
            (200, 150),
            (350, 250),
            (350, 50),
            (50, 250),
        ]

        painter.setPen(QPen(Qt.red, 6))
        for x, y in points:
            painter.drawPoint(x, y)

        painter.setPen(QPen(Qt.black))
        painter.setFont(QFont("Arial", 9))
        for x, y in points:
            painter.drawText(x + 6, y - 6, f"({x}, {y})")

        painter.end()

        label = QLabel()
        label.setPixmap(canvas)
        self.setCentralWidget(label)


app = QApplication(sys.argv)
window = MainWindow()
window.show()
app.exec()

This draws the axes along the top and left edges of the canvas and plots several points with their coordinates labeled. It's a great way to build intuition about where things will appear.

Valid coordinate ranges

One more thing to keep in mind: pixel coordinates on a QPixmap are zero-indexed. If you create a pixmap with:

python
canvas = QPixmap(400, 300)

Then the valid coordinate ranges are:

Drawing outside these ranges won't cause an error, but anything beyond the edges simply won't be visible.

Summary

The QPainter coordinate system places (0, 0) at the top-left of the drawing surface, with x increasing to the right and y increasing downward. This is standard across virtually all screen-based graphics systems.

If you need to work with mathematical coordinates where (0, 0) is at the bottom-left and y increases upward, you can convert using the formula:

python
y_screen = height - 1 - y_math

Once you've internalized this, drawing with QPainter becomes predictable. When in doubt, drop some annotated points on your canvas — seeing the coordinates labeled right next to the dots is the fastest way to confirm everything is landing where you expect.

For more details on Qt's coordinate system, take a look at the official Qt coordinate system documentation.

For an in-depth guide to building Python GUIs with PyQt6 see my book, Create GUI Applications with Python & Qt6.

September 02, 2026 06:00 AM UTC


Mark Dufour

Shed Skin restricted-Python-to-C++ compiler v0.9.13 released

I have just released version 0.9.13 of Shed Skin, a restricted-python to C++ transpiler. Shed Skin allows one to effectively convert (or transpile) pure Python code to highly optimized machine code. This comes at the cost though of having to conform to a seriously restricted subset of Python features/libraries. Programs currently also cannot be too large, although it is possible to generate extension modules, that can be used in larger programs.

The following screenshot is of a DOOM engine, that becomes more than 30 times faster after transpilation. The engine is compiled with Shed Skin, then imported in a larger program that uses Pygame for the UI (see also a before-after video).

Version 0.9.13 is the first version that was heavily improved by the use of AI (more specifically, Claude - thanks to Shakeeb for starting this). It was able to spot (and fix) many bugs, especially in the C++ backend, but also test gaps. It also helped to add support for many missing features in Python 3.15. We are now actually pretty close to full compatibility there, at least with regards to the supported modules and everything that is compatible with Shed Skin (no os.walk yet, for example, as it uses heterogenous tuples of length > 2, something we may fix for 0.9.14).

The use of AI should make it much easier to contribute as well. For example, if your program doesn't work, AI can minimize it and produce a useful bug report to submit to the project. It can even start to try and fix the problem, create new tests and so on. Or otherwise look for gaps/bugs to work on.

September 02, 2026 12:16 AM UTC


Graham Dumpleton

Unit testing with wrapture

In introducing wrapture I said the one idea everything sits on is to wrap rather than replace. That is easy to say and harder to see the point of, so this post takes a small piece of code and writes tests for it twice, once with unittest.mock and once with wrapture. I am not going to walk through every mock idiom and show its wrapture spelling, since a good part of the time the two are doing the same thing with different syntax, and the comparison page in the documentation already maps one onto the other. What I want to show is the handful of cases where the difference is structural, where wrapping the real code lets a test say something that substitution cannot.

The code under test

An order service which takes a payment through a gateway, records it in a ledger, and sends a notification. If the ledger write fails, the payment is refunded and the error propagates. The collaborators can be injected through the constructor, so there is a seam for a mock to sit behind, and the payment step goes through a private method on the service itself.

class Gateway:
    def charge(self, amount, currency="USD"):
        return {"id": f"ch_{amount}", "amount": amount}

    def refund(self, charge_id):
        return {"id": f"re_{charge_id}"}


class Ledger:
    def record(self, entry):
        return f"led_{entry['id']}"


class Notifier:
    def send(self, message):
        return True


class OrderService:
    def __init__(self, gateway=None, ledger=None, notifier=None):
        self.gateway = Gateway() if gateway is None else gateway
        self.ledger = Ledger() if ledger is None else ledger
        self.notifier = Notifier() if notifier is None else notifier

    def place(self, amount):
        charge = self._take_payment(amount)
        try:
            self.ledger.record(charge)
        except Exception:
            self.gateway.refund(charge["id"])
            raise
        self.notifier.send(f"order {charge['id']} placed")
        return charge

    def _take_payment(self, amount):
        return self.gateway.charge(amount)

Where the two look the same

Stubbing a return value is the bread and butter of both tools, and on the surface there is nothing to choose between them:

from unittest.mock import patch

def test_stub_with_mock():
    with patch.object(Gateway, "charge", return_value={"id": "stub", "amount": 0}):
        assert OrderService().place(500)["id"] == "stub"
import wrapture

def test_stub_with_wrapture():
    with wrapture.binding(Gateway, "charge").on_call.returns({"id": "stub", "amount": 0}):
        assert OrderService().place(500)["id"] == "stub"

Even here there is a difference under the surface. A mock only checks a stubbed call against the real signature if you asked for autospec=True, so without it a call that has drifted from the method's signature returns the stub happily:

def test_drifted_call_with_mock():
    with patch.object(Gateway, "charge", return_value={"id": "stub"}):
        assert Gateway().charge(500, bogus=True) == {"id": "stub"}

That test passes. A wrapture binding is strict by default, so a call the real method would have rejected is rejected by the stub too:

def test_drifted_call_with_wrapture():
    with wrapture.binding(Gateway, "charge").on_call.returns({"id": "stub"}):
        with pytest.raises(TypeError):
            Gateway().charge(500, bogus=True)

The error names the site and the problem:

TypeError: orders:Gateway.charge (stubbed): got an unexpected keyword argument 'bogus'

There is a strict=False option for the rare patch which genuinely means to accept a different shape, but the default is the direction I care about. A test which passes because the stub was more forgiving than the real code is a test that will be wrong in production.

Calls an object makes to itself

The mock approach to testing OrderService is to inject a MagicMock as the gateway and assert on what it recorded:

from unittest.mock import MagicMock

def test_self_call_with_mock():
    gateway = MagicMock()
    service = OrderService(gateway=gateway)
    service.place(500)
    gateway.charge.assert_called_once_with(500)

Look at what the mock actually saw, by printing gateway.mock_calls after the call:

[call.charge(500),
 call.charge().__getitem__('id'),
 call.charge().__getitem__().__str__(),
 call.charge().__getitem__('id'),
 call.charge().__getitem__().__str__()]

The charge() call is there, followed by a trail of fabricated chains as the service reached into a return value that was never a real dictionary. What is not there, and cannot be, is _take_payment(). The call from place() to _take_payment() never crosses the seam the mock sits behind, so as far as the test can tell the private method does not exist. If you instead reach for patch.object(OrderService, "_take_payment") to get at it, you have replaced it, and now the real payment logic does not run and the gateway is never charged. Either the method is invisible or it is gone.

With wrapture the binding is on the class, so a call the object makes to itself passes through the wrapper like any other:

def test_self_call_with_wrapture():
    take_payment = wrapture.binding(OrderService, "_take_payment")
    charge = wrapture.binding(Gateway, "charge")

    with wrapture.timeline(take_payment, charge) as tape:
        OrderService().place(500)

        take_payment.events.with_args(amount=500).assert_once()
        assert tape.parent_of(charge.events.first) is take_payment.events.first

The second assertion says the charge happened inside the payment step, and tape.tree() shows the same thing:

orders:OrderService._take_payment(amount=500)  -> {'id': 'ch_500', 'amount': 500}
  orders:Gateway.charge(amount=500, currency='USD')  -> {'id': 'ch_500', 'amount': 500}

Real arguments, normalised against the real signature so the currency default appears even though the caller never passed it, and real return values, nested the way the calls actually nested.

Running the real code while changing one thing

This is where substitution runs out of road entirely. Mock(wraps=real) will forward calls to the real method, but it cannot change the arguments the real method receives, and it cannot touch the result on the way back. The standard library has no way to say "run the real method, but change one thing about it".

For wrapture that is the ordinary case. Here the real charge() runs and only the id in its result is rewritten, which is useful when a real id would be unstable across runs but everything else about the result matters:

def test_pinned_result_with_wrapture():
    charge = wrapture.binding(Gateway, "charge")
    charge.on_call.transforms_result(lambda r: {**r, "id": "ch_TEST"})

    with charge:
        assert OrderService().place(500) == {"id": "ch_TEST", "amount": 500}

transforms_args() does the same on the way in, and validates_args() and validates_result() check without changing. These are stages, and they compose, so a binding can rewrite one argument and check the result at the same time while the real code does the actual work in between.

Asserting on what did not happen

The tests that matter most are usually on the error paths, and the interesting fact on an error path is often an absence. When the ledger write fails, the refund must be issued and the notification must not be sent. Here it is with mock, and it takes three doubles to write:

def test_error_path_with_mock():
    gateway = MagicMock()
    ledger = MagicMock()
    ledger.record.side_effect = OSError("disk full")
    notifier = MagicMock()

    service = OrderService(gateway, ledger, notifier)

    with pytest.raises(OSError):
        service.place(500)

    gateway.refund.assert_called_once_with(gateway.charge.return_value["id"])
    notifier.send.assert_not_called()

The refund assertion is the awkward part. Because the gateway is a mock, the charge id is a fabricated MagicMock rather than "ch_500", so the only way to assert on it is to ask the mock what it invented. The test cannot say "the refund was for the charge that was taken", only "the refund was passed whatever charge() returned", which is the same thing only if you trust the code you are testing. (With a plain Mock rather than MagicMock the test does not even get that far, since charge["id"] fails with a TypeError about subscripting.)

With wrapture the failure is injected at the ledger and nothing else is touched:

def test_error_path_with_wrapture():
    charge = wrapture.binding(Gateway, "charge")
    refund = wrapture.binding(Gateway, "refund")
    record = wrapture.binding(Ledger, "record")
    send = wrapture.binding(Notifier, "send")

    record.on_call.raises(OSError("disk full"))

    with wrapture.timeline(charge, refund, record, send) as tape:
        with pytest.raises(OSError):
            OrderService().place(500)

        refund.events.with_args(charge_id="ch_500").assert_once()
        send.events.assert_never()
        tape.assert_order(charge, record, refund)

The real gateway was charged, so the refund is asserted against the real charge id. The notifier is real and was never called. And assert_order() says the refund came after the failed ledger write, across three different bindings. The tape shows exactly that:

orders:Gateway.charge(amount=500, currency='USD')  -> {'id': 'ch_500', 'amount': 500}
orders:Ledger.record(entry={'id': 'ch_500', 'amount': 500})  !! OSError (injected)
orders:Gateway.refund(charge_id='ch_500')  -> {'id': 're_ch_500'}

When an assertion fails, the message shows what was recorded rather than just the count that was wrong. Asserting on a refund for the wrong id gives:

AssertionError: expected exactly 1 event(s), got 0
<EventLog orders:Gateway.refund[charge_id='ch_999']: 0 event(s)>
    (no events)
  filtered from:
    <EventLog orders:Gateway.refund: 1 event(s)>
        orders:Gateway.refund(charge_id='ch_500')

The "filtered from" section is there because an over-narrowed filter producing an empty log is the easiest way to get a wrong assertion, and showing what the filter discarded is the fastest way to see it.

How the tests are shaped

Beyond what the assertions can say, the shape of the test code changes in a few ways that are worth pointing out.

A binding declares a target without touching it. binding() never patches, so bindings can be created at module scope, given behaviour, and shared, with each test applying and removing them. Nothing happens until apply(), a with block, or a timeline. That separation of declaration from effect is what lets the four bindings in the error path test be declared up front and read like a cast list.

The with block is the primitive, and for a test that binds one or two targets around its whole body there is a decorator form that says the same thing without the nesting. The bindings arrive as keyword arguments, and expectations can be declared on the decorator and are verified when the test finishes:

@wrapture.taped()
@wrapture.bound(Ledger, "record").on_call.raises(OSError("disk full"))
@wrapture.bound(Gateway, "refund").expect_once()
@wrapture.bound(Notifier, "send").expect_never()
def test_error_path_with_decorators(tape, record, refund, send):
    with pytest.raises(OSError):
        OrderService().place(500)

That is the same test as before with the assertions moved to the top as a contract, and a body that only performs the action. An expectation with nothing recording is an error rather than a silent pass.

Fixtures work the way you would expect, and because a fixture yields the binding a test can reconfigure it mid-flight, which is how a test walks a collaborator through failing and then recovering:

@pytest.fixture
def stub_charge():
    with wrapture.binding(Gateway, "charge").on_call.returns({"id": "stub", "amount": 0}) as charge:
        yield charge


def test_gateway_recovers(stub_charge):
    stub_charge.on_call.raises(TimeoutError("down"))
    with pytest.raises(TimeoutError):
        OrderService().place(500)

    stub_charge.on_call.returns({"id": "retry", "amount": 0})
    assert OrderService().place(500)["id"] == "retry"

Finally there is an opt-in pytest plugin, enabled with one line in conftest.py, which fails any test that leaves a binding applied and attaches the tape's tree to the failure report of any test that recorded one. The leak sweep is the thing I would turn on first. A patch that leaks changes the behaviour of every test that runs after it, and I suspect plenty of people have lost hours to that without ever knowing which test was the culprit.

Where mock still fits

Everything above follows one rule, which is to wrap the real code and record what actually flowed, with one deliberate opt-out. When the test itself must supply the thing being called, because the code under test receives a callback or a collaborator rather than importing one, stub() supplies a single callable and mock(Spec) a whole object. Both are strict, in that signatures are checked and a name the spec does not have raises, and both record onto the same tape as everything else.

What wrapture does not provide is a spec-less MagicMock() whose attributes exist on first touch and whose call chains all answer. That is the thing which would let a misspelled gateway.chargee.assert_not_called() pass silently in the tests above, and it is left out on purpose. If a test wants an object invented as it is touched, unittest.mock is the tool for that, and it has the other advantage of being in the standard library, on every team's common ground. The two coexist in one suite without difficulty, and the comparison page is there for translating between them.

What's next

The error path test leaned on the timeline and the tape without much explanation of what they are or what an event holds. That is the subject of the next post, where the example is a resource leak, which is a bug nothing in a return value will ever tell you about.

September 02, 2026 12:00 AM UTC

September 01, 2026


PyCoder’s Weekly

Issue #750: State of Django, PSF Elections, Python 3.15, and More (2026-09-01)

#750 – SEPTEMBER 1, 2026
View in Browser »

The PyCoder’s Weekly Logo


The State of Django 2026: Boring Is So Back

A summary of this year’s State of Django report which draws on responses from nearly 3,500 developers across more than 40 countries: from students in their first year to veterans with decades of experience.
WILL VINCENT

2026 PSF Board Election Interviews

This is a collection of interviews of the various candidates running for the Python Software Foundation Board. Many of the posts also include links to AMA sessions.
PYTHON SOFTWARE FOUNDATION

Python 3.15 Preview: Sampling Profiler

Explore Python 3.15’s new sampling profiler and learn low-overhead profiling of scripts, threads, and live production processes.
REAL PYTHON

Quiz: Python 3.15 Preview: Sampling Profiler

REAL PYTHON

RISC-V Is Now Officially Supported by CPython

PYTHON.ORG

PEP 843: Export Statement for DRY Re-Exports (Draft)

PYTHON.ORG

Articles & Tutorials

The Python Community’s Institutional Response to the Astral Acquisition Has Begun

Brett Cannon posted on discuss.python.org (March 23): a PEP is coming, the python/prebuilt-cpython repo already exists, and the PSF has been building an official prebuilt relocatable CPython distribution since October 2025. Covers what’s actually being built, what it means for uv/ruff/python-build-standalone, and why the Astral upstream patches and PSF alternative aren’t in conflict.
DEV.TO • Shared by Anonymous

Unsubscribe Links Without a Login: Django Signing

Django has a signing module that makes it easy to build an unsubscribe link that works with no login and no session: token = signing.dumps(recipient.pk, salt=UNSUBSCRIBE_SALT). The token itself is the credential, and it ships with Django out of the box.
BOB BELDERBOS • Shared by Bob

AI Coding Tools for Python Developers: What’s Actually Worth Using Right Now

alt

Join the one-day live course this Saturday, September 12. Get live demos of every category of AI coding tool, a verdict on each, and a 60-second test you can run on whatever launches next. Examples are in Python, and no prior AI experience is required. Reserve your seat here →
REAL PYTHON

CMS With AI, Not AI CMS: Wagtail 8.0’s New API

Wagtail 8 includes a new API built on Django Ninja and Pydantic, to automate common admin tasks with and without agents. 50+ operations derived from projects’ existing Python/Django code, mimicking the admin panel but via endpoints and an official CLI.
WAGTAIL.ORG • Shared by Thibaud Colas

Fuzzy String Matching in Django and PostgreSQL

Fuzzy string matching allows you to find values that are similar but not exact. This is particularly useful with the spelling of names (Smith, Smyth, Smythe). This post shows you how to use fuzzy matching in Postgres with Django.
MCNULTY & CARLTON

Learn Vectorized Thinking in Python Through Examples

Vectorization allows you to perform a mathematical operation on multiple values at the same time. NumPy supports this and it is one of the reasons it is far faster than equivalent looping operations.
JASON BROWNLEE

Moving Into the Future: Upgrading to Python 3

A dev blog about the challenges and rewards of upgrading Carbon, the engine upon which EVE Frontier is built, from Stackless Python to Python 3.
EVEFRONTIER.COM

The Python print() Function: Go Beyond the Basics

Learn about Python’s print() function, discover its lesser-known features, avoid common mistakes, and know when to use a better alternative.
REAL PYTHON course

Quiz: The Python print() Function: Go Beyond the Basics

REAL PYTHON

12 Things You Should (And Shouldn’t) Do in AWS

Talk Python interviews Matt Lea and they discuss all sorts of things that can go wrong in your infrastructure and what to do about it.
TALK PYTHON podcast

How to Write an AGENTS.md File for a Python Project

Learn how to write an AGENTS.md file so your AI coding agent produces idiomatic Python code that fits your project on the first try.
REAL PYTHON

Quiz: How to Write an AGENTS.md File for a Python Project

REAL PYTHON

Projects & Code

Arid: Fast Python Duplicate-Code Detection

GITHUB.COM/SPONGE-B0B • Shared by Bob Taylor

pydantic-pint: Pydantic Pint Quantities

GITHUB.COM/TYLERH111

django-fastmig: Experimental Drop-in That Makes Django migrate Faster

GITHUB.COM/VIKTOR2097

django-danceschool: Django CMS for Running a Dance School

GITHUB.COM/DJANGO-DANCESCHOOL

vcrpy: Automatically Mock HTTP Interactions

GITHUB.COM/KEVIN1024

Events

Weekly Real Python Office Hours Q&A (Virtual)

September 2, 2026
REALPYTHON.COM

Canberra Python Meetup

September 3, 2026
MEETUP.COM

Sydney Python User Group (SyPy)

September 3, 2026
SYPY.ORG

PyDelhi User Group Meetup

September 5, 2026
MEETUP.COM

Melbourne Python Users Group, Australia

September 7, 2026
J.MP

PyBodensee Monthly Meetup

September 7, 2026
PYBODENSEE.COM


Happy Pythoning!
This was PyCoder’s Weekly Issue #750.
View in Browser »

alt

[ Subscribe to 🐍 PyCoder’s Weekly 💌 – Get the best Python news, articles, and tutorials delivered to your inbox once a week >> Click here to learn more ]

September 01, 2026 07:30 PM UTC


Django Weblog

Djangonaut Space - Session 7 Accepting Applications

We are thrilled to announce that Djangonaut Space, a mentorship program for contributing to Django, is open for applicants for our next cohort! 🚀

Session 7 launches October 12th, 2026. We are currently accepting applications until September 6th, 2026 Anywhere on Earth. More details can be found in the website.

Djangonaut Space is a free, 8-week group mentoring program where individuals will work self-paced in a semi-structured learning environment. It seeks to help members of the community who wish to level up their current Django code contributions and potentially take on leadership roles in Django in the future.

Rodrigo Vieira, Djangonaut Session 6

“Participating as a mentee in the Djangonaut Space was a highly valuable experience. The program provided clear, step-by-step guidance on how to navigate the core framework, find the right issues to tackle, and confidently send my PRs.

Beyond the code, the community on Discord was very active, sharing experiences, wins and support. We had access to regular talks, Django Fellows and long-time maintainers who generously shared their time and expertise.

It was a welcoming environment to connect with other newcomers from all over the world, allowing us to share our journeys and learn together. The highlight of the program for me was seeing my first pull request finally merged and mentioned in Django News. I'm looking forward to keep involved as an alumni and help others now.“

Eliana, Djangonaut Session 1

“I'm so grateful to have been a part of the Djangonaut Space program. It's a wonderfully warm, diverse, and welcoming space, and the perfect place to get started with Django contributions. The community is full of bright, talented individuals who are making time to help and guide others, which is truly a joy to experience. Before Djangonaut Space, I felt as though I wasn't the kind of person who could become a Django contributor; now I feel like I found a place where I belong.”

Enthusiastic about contributing to Django but wondering what we have in store for you? No worries, we have got you covered! 🤝

✏️ Mission Briefing

📷 AMA Recap

📡 Have questions? Email sessions@djangonaut.space

September 01, 2026 05:13 PM UTC


James Bennett

Boring Python: dependency management

This post has been significantly updated since it was originally written. The most recent update was in September 2026; a summary of what changed is at the end.

This is the first in hopefully a series of posts I intend to write about how to build/manage/deploy/etc. Python applications in as boring a way as possible.

So before I go any further, I want to be absolutely clear on what I mean by “boring”: I don’t mean “reliable” or “bug-free” or “no incidents”. While there is some overlap, and some of the things I’ll be recommending can help to reduce bugs, I also want to be clear: there will be bugs. There will be incidents where a feature or maybe an entire service is down. “Boring”, to me, is about the sources of those incidents. It’s difficult enough to manage your own code and the bugs and other problems that will inevitably pop up in it from time to time; you don’t want to compound that by having bugs or other surprises coming from the tools and processes you use to build, manage, and deploy it. So when I call something “boring”, I mean it’s unlikely to add another source of bugs and nasty surprises that cause a pager to go off at 2AM; the pager will, of course, eventually go off at 2AM, but when it does you’ll be able to feel reasonably confident (if that’s the right word for such a situation) that the source of it was something in your own code that you can diagnose and fix.

And so I’m planning several posts exploring different aspects of making Python development boring (in this sense). But for this first installment, I’ll be talking about one of the internet’s favorite topics: managing dependencies.

If you’re just interested in the end result, the process I recommend is pretty simple and can be found in the section titled “Putting it all together”. The rest of this post exists to explain, in as much detail as I can manage, all the things going on behind those simple-looking recommendations, and why I make those specific recommendations.

Python packaging: a brief overview

I’ve written about this before, but to make sure it’s clear I want to do a quick refresher.

As I see it, there are three basic aspects to “packaging”, and one of the difficult things about “Python packaging” discussions is lack of clarity around which are being discussed. They are:

  1. Given some working code, define and produce from it a distributable artifact.
  2. Given that someone has produced a distributable artifact, use it to cause the corresponding code to appear, in working form, somewhere else.
  3. Given the existence of potentially many separate projects or versions of projects each with their own independent and potentially-conflicting sets of dependencies, make it possible to work on and run more than one at a time, on the same system.

Most of the actual remaining complexity in “Python packaging” today is concentrated in item (1), and even then only really kicks in if you’re packaging extensions written in non-Python languages.

A lot of the perceived complexity of “Python packaging” is due to its long history, evolving expectations over the course of that history, and the resulting pile of strata of different things people have come up with to try to suit the expectations of different eras.

Much of the public grumbling and complaining about “Python packaging” these days is due to the lack of a single, standard first-party, all-in-one high-level tool to handle all aspects of packaging, and the proliferation of third-party tools to try to fill that niche.

Finally, most of the meaningful work in Python packaging, for quite a while now, has been the unglamorous, underappreciated slog of people on mailing lists and discussion forums patiently working out what Python packaging really should look like and thoroughly specifying/standardizing it, but with the added thrill of all that having to occur in a way that doesn’t break things for the vast number of people relying on Python and its huge package ecosystem for their day-to-day work.

To the extent that we now have a bunch of shiny fancy flashy third-party all-in-one packaging tools, it’s because of that heroic unsung work that’s been quietly going on for years. The fancy new tools get to be so fancy and flashy in large part because modern Python packaging has such strong foundations (and also because, unlike the pre-existing tools, the new ones don’t have to maintain compatibility with literal decades of historical features and configuration options, all of which have been relied on, at some point, by someone).

If I went into detail on everything I’d wind up with several posts’ worth of material, so let me just pick out a few things that I think are the most crucial underpinnings of modern Python packaging:

Boring Python packaging: a philosophy

When I wrote the first version of this post, four years ago, I focused on defining a workflow that relied as much as possible on standard first-party (as in, included with Python itself) tooling. I recommended exactly one third-party tool, and only as an optional convenience; the functionality it provided could also be obtained from chaining together the right invocations of first-party tooling.

That focus on first-party tooling was deliberate. As I wrote at the time:

[The] core default tools I’ve mentioned have all been around and stable for a long time, in software terms, at least — they’re all over a decade old, and their end-user interfaces evolve incredibly slowly, when they evolve at all. They’re reliable. They’re well-understood. That’s exactly what I want and exactly what I recommend.

This is in line with what I said at the start of this post about wanting things to be boring. And my stance has not changed, but advances in the foundational standards of Python packaging do let me be a bit more flexible. So although I’m still going to be focused on first-party tools I’ll now point out a couple of places where I think it’s safe to use some of the hip and trendy new third-party tools if you want the added conveniences they offer.

Also, I should note here that my use case for Python is deploying networked (primarily web) services, on servers. If you primarily use Python for, say, machine learning or data science, you are likely to already be a happy user of a completely different world of tools optimized for those use cases (in particular, for the much greater amount of non-Python code wrapped by/accessed through Python in those fields and for their different patterns of code reuse and sharing). Please continue using them! They suit your use case really well, and replacing them with my preferred tools and workflow would probably be a regression for you.

Now, let’s get started.

Lock it up

The standard Python package manager is pip. And my eventual goal is to invoke pip install to, well, do what it says: install a bunch of Python packages. But how to tell pip which packages to install? There are a couple of options:

Historically, the second option was referred to as a “requirements file”, and by convention was generally named requirements.txt. Now that PEP 751 has defined a standard Python lock file format, and now that pip supports producing and consuming that standard lock file format, that’s the format I recommend.

This prompts a question, though: since dependencies could already be specified as part of the metadata in pyproject.toml, why did we need another file format to record them? By way of an answer, consider a web application written using the Django web framework. You might think it’s simple enough to just declare Django as a dependency in your pyproject.toml file, like so:

[project]
dependencies = ["Django"]

But if I create a new virtual environment and run pip install Django inside it, three packages get installed:

$ pip install Django
Collecting Django
  Using cached django-6.1-py3-none-any.whl.metadata (3.9 kB)
Collecting asgiref>=3.9.1 (from Django)
  Using cached asgiref-3.12.1-py3-none-any.whl.metadata (9.4 kB)
Collecting sqlparse>=0.5.0 (from Django)
  Using cached sqlparse-0.6.0-py3-none-any.whl.metadata (6.0 kB)
Using cached django-6.1-py3-none-any.whl (8.4 MB)
Using cached asgiref-3.12.1-py3-none-any.whl (25 kB)
Using cached sqlparse-0.6.0-py3-none-any.whl (50 kB)
Installing collected packages: sqlparse, asgiref, Django
Successfully installed Django-6.1 asgiref-3.12.1 sqlparse-0.6.0

And which versions of those packages I get will depend on when I run pip install. Right now I get Django 6.1, but Django does frequent bug fix releases, so if I re-run that pip install in the future I’ll get Django 6.1.1, and further in the future I’ll get Django 6.1.2, and so on. If I test my application locally with the set of packages I installed today, and then deploy it to a server later using pip install to fetch dependencies, the deployment will very likely get a different set of packages than I used during development, which is a potential source of problems.

So the key difference is that the pyproject.toml file typically specifies a list of direct dependencies, often with broad version constraints or none at all, while a lock file specifies an entire environment, with all direct and transitive dependencies resolved and pinned to exact versions along with the expected checksums of the packages, to be reproduced as exactly as possible.

There are two main approaches to generating a lock file:

Managing your local environment and producing your lock file is one of the areas where I think it’s generally OK to use a third-party tool. If something goes wrong with it, it might temporarily mess with your ability to get work done on your own computer, but that won’t break your actual deployed application.

The hip and trendy third-party all-in-one tool these days is uv, which has its own tool-specific lock file format (uv.lock), but also has a uv export command which can export to the standard pylock.toml format.

My own personal preference, and the tool I use on my own projects, is PDM, which has a similar feature set to uv and can export to pylock.toml from its own lock format, but also can just manage a project directly in a pylock.toml file.

As long as the tool you choose is one you and your team are all happy with, I think you can pick anything that can produce the standard lock file format and be OK. Also, you should probably automate the process of exporting the lock file(s), to ensure developers don’t have to remember how to manually do it. I personally like to include a Makefile in most projects, with a target specifically to refresh the lock file(s) (make lock, for example), but the idea is portable to a lot of task-automation tools, so it’s easy to make it work with whatever your team likes to use.

I also want to point out here that if you’re deploying services written in Python, you can build them as distributable packages and then install those packages to automatically pull their dependencies, rather than using a lock file. I just don’t recommend this, for a few reasons:

Getting testy

In addition to direct dependencies to run an application, it’s fairly common to have additional dependencies needed to run a test suite, linters, or other development and quality-control tasks. But there’s no need for a production deployment to have all those extra dependencies installed, and in general the less stuff you put in your production environment the fewer things you have that can go wrong, so I generally like to install those only when the test suite (or other task requiring extra dependencies) is actually going to be run.

The ideal solution for this is dependency groups, which were first defined for library-style package dependencies but are also part of the lock file specification. Unfortunately, as far as I can tell the current release of pip (26.2.1 as I’m writing this) does not support selecting dependency groups when installing from a lock file.

So for the moment (at least until pip gains support for selecting dependency groups from a lock file), my recommendation for handling different sets of dependencies is just to have multiple lock files. The exact syntax for this will vary depending on what packaging frontend tool you decide to use. But suppose you have a FastAPI application and want to use pytest to test it. Here’s an example of how that would look with uv:

uv add fastapi       # Regular dependency
uv add --dev pytest  # Used only for tests
uv export --no-dev --format pylock.toml -o pylock.deploy.toml
uv export --format pylock.toml -o pylock.tests.toml

Or with PDM:

pdm add fastapi
pdm add --dev pytest
pdm export --prod --format pylock -o pylock.deploy.toml
pdm export --dev --format pylock -o pylock.tests.toml

In either case, the pylock.deploy.toml file will contain only the dependencies for FastAPI, and is what you’d use for deployment. Meanwhile, pylock.tests.toml would also contain pytest, and is what you’d use for a development or test/CI environment. You can split this up further if you want—I’ve done finer-grained splits of dependencies before—but at the very least I think you should be keeping test-only dependencies separate from the rest.

Using the right invocations

Before putting all this together, it’s worth covering one more detail: how to invoke the tools. This may seem a bit silly, since they all provide executable entry points. Just run pip install, right?

But there’s a potential issue here: in a moment I’m going to recommend creating a Python virtual environment, which opens up the possibility of multiple Python environments coexisting on the same machine. This is certainly a useful feature, but it comes with a new concern, which is how to ensure you’re using and running things in the environment you expect to be using.

For example, one way to run into trouble with multiple Python environments is to have one environment’s package directory be first on your $PYTHONPATH (which controls Python import locations) while a different one’s bin/ directory is first on your general $PATH (which is where executable scripts will be found). If you just run pip install you’ll get the second one’s instance of pip, which may not be at all what you want.

This is why the official Python packaging guides, and official documentation for tools like pip, always use a different approach: they tell you to run python -m pip instead of pip, and python -m venv instead of a standalone script like virtualenv. The -m flag allows a Python module to be run directly like a script (as long as it’s been written to provide an entry point for this, which pip and venv both have), and can prevent a lot of potential hard-to-debug issues that can accidentally result from things like manually hacking around with paths, by ensuring you’re getting the version of pip or venv that actually goes with the Python environment you invoked.

And to guarantee that you get the Python environment you actually want, you can specify the full path. For example, many base Python container images will put the Python interpreter in /usr/local/bin, so invoking /usr/local/bin/python instead of just python ensures you get that interpreter.

There’s also one more thing I like to do that isn’t (currently) in some of the popular guides, and that’s invoking Python with the -I flag. This runs Python in “isolated” mode, which removes some of the automatic implicit directories from the import path and also ignores environment variables like PYTHONPATH. Once again, this reduces the number of things that can go wrong (for example, with -I the current working directory won’t be implicitly added to the import path, so you can’t accidentally depend on it being importable). So whenever you can invoke Python in isolated mode, I generally recommend that you do.

Putting it all together

That was a lot of explanation for what ends up being, ultimately, a pretty simple process to actually use. So now let’s finally take a look at it.

First things first: always work in, and always deploy in, a virtual environment. Even if you think you don’t need one. In fact, especially if you don’t think you need one. This may seem like strange advice if you’re already using a container or other virtual machine, since you’re probably thinking that provides all the isolation you’ll need. But virtual environments don’t cost you anything to create, and if you ever do end up with multiple Python interpreters—which is easy to accidentally do, if you use a base system with a purpose-built Python and then install a system package that turns out to depend on the distro’s own Python, for example—using one from the start will help to save you from potentially having a pager go off one night when suddenly the wrong Python is being invoked.

Virtual environments also provide a useful “Python environment” artifact that can be copied between stages of a container build, and many tools automatically recognize and can work with them. And ever since PEP 668 started to be adopted by operating-system vendors, many “system” Python installations will require you to use a virtual environment in order to install packages with pip (and only allow you to interact with the “system” Python environment through the system’s own package manager). So use a virtual environment, even if you’re working in a container or other VM.

When you’re working in a local directory on your own computer, you can invoke the correct Python version with -m venv to create a virtual environment and install things into it, but again this is an area where third-party package tools can be useful, because they’ll manage this for you automatically. If you do create your local virtual environment manually, you should almost certainly put it inside your project in a subdirectory named .venv, because that’s already the common unofficial convention supported by a lot of IDEs and other tools, and likely to become the official standard convention once PEP 832 finalizes.

In a container, I think the choice that’s most in line with Linux filesystem hierarchy standards is to put a Python virtual environment somewhere under /opt. Generally I like to create an /opt/venvs in a base “build” stage, which can populate multiple virtual environments with different package sets to be copied into later stages for tasks like CI or deployment.

So, taking as an example current Debian stable (as I write this, Debian 13 “trixie”) and the most recent Python (as I write this, Python 3.14), let’s see what this actually looks like. This example assumes you’re producing standard Python lock files as described above, and naming them according to their purposes as I did (otherwise, adjust the names before using this snippet):

# syntax=docker/dockerfile:1

ARG DISTRO="slim-trixie"
ARG PYTHON_VERSION="3.14"

FROM python:${PYTHON_VERSION}-${DISTRO} AS dependencies

RUN <<END
mkdir -p /opt/venvs/deploy
mkdir -p /opt/venvs/tests
# This is a cache directory for package downloads, to speed up
# rebuilds when the package set doesn't change (or doesn't
# change very much).
mkdir -p /var/cache/pip
/usr/local/bin/python -Im venv /opt/venvs/deploy
/usr/local/bin/python -Im venv /opt/venvs/tests
END

COPY pylock.deploy.toml  /opt/venvs/deploy/
COPY pylock.tests.toml /opt/venvs/tests/

The next step is to ensure pip is present at the latest version in each virtual environment, and invoke it to install packages.

At this point you might be wondering: if I was willing to recommend using a third-party packaging frontend earlier for local development use and producing lock files, why am I insisting on pip here? And the answer is that this is one of the places where I think using boring standard default tools really matters. As I said above, if something goes wrong with your fancy third-party package tool in a local development setup, it just causes a problem for that local development setup. If it goes wrong in your production build/deploy process, it breaks all your builds and deployments until you resolve the issue. I want to avoid that, so I stick to pip here, because even if it’s not as fancy as some of the newer third-party tools, in my experience it’s much less likely to be a source of unexpected issues than the newer third-party alternatives.

Also, I’m not going to go into a full explanation of an ideal Python application Dockerfile here, but the example below does at least use a Docker cache mount to store the downloaded packages, so that subsequent rebuilds only have to download new or changed packages, and can pull everything else from cache.

The pip invocation adds a few flags, mostly to ensure maximum safety and reproducibility:

If you’re using GitHub Actions as your CI/CD, Python core developer Brett Cannon has written a reusable action which automatically invokes pip with these flags.

But continuing with the example of a Dockerfile containing a “dependencies” stage, here’s what it looks like:

# Turn off pip upgrade reminders, since we're about to upgrade
# it anyway, and also specify the directory pip should use as
# its package cache location.
ENV PIP_DISABLE_VERSION_CHECK=1 \
    XDG_CACHE_HOME=/var/cache/pip

# Install test dependencies first since they're likely to be a
# superset of the deployment dependencies and will populate the
# package cache.
RUN --mount=type=cache,sharing=locked,target=/var/cache/pip,id=pip <<END
/opt/venvs/tests/bin/python -Im pip install --upgrade pip
/opt/venvs/tests/bin/python -Im pip install \
    --no-deps \
    --only-binary :all: \
    --require-hashes \
    -r /opt/venvs/tests/pylock.tests.toml
END

RUN --mount=type=cache,sharing=locked,target=/var/cache/pip,id=pip <<END
/opt/venvs/deploy/bin/python -Im pip install --upgrade pip
/opt/venvs/deploy/bin/python -Im pip install \
    --no-deps \
    --only-binary :all: \
    --require-hashes \
    -r /opt/venvs/deploy/pylock.deploy.toml
END

Now you can pull the installed packages into later stages of your build by copying the virtual environment. For example, in a deployment stage:

FROM python:${PYTHON_VERSION}-${DISTRO} AS deploy

COPY --from=dependencies /opt/venvs/deploy /opt/venvs/deploy

# Do the rest of your deployment stage setup here: copying in
# your application source code, setting the entry point, etc.

Once again, I’m not going to go into a ton of detail here on other things that make a good Python Dockerfile. If you want to learn more about that, I’d recommend reading Itamar Turner-Trauring’s articles on Python and Docker or Hynek Schlawack’s guide to Python and Docker. Both are regularly updated, and they’ll both give you a solid education in how to containerize Python.

Be up-to-date, but stay cool

The only thing still missing here is how to handle updates as new versions of your dependencies are released. For security updates this is crucial, but it’s also important as a general practice. If you make dependency updates a regular, routine part of your development process that’s easy for developers to do, then it’ll also be routine and easy to apply critical updates when they appear. And by applying updates as they come, a couple dependencies at a time, you avoid building up a huge backlog of deferred updates that will make a critical issue even more difficult to address when one inevitably occurs.

And luckily, all of the popular third-party package management tools have straightforward commands you can run to identify and update outdated packages. But there is one wrinkle: you probably don’t want to always eagerly accept most routine dependency updates as soon as they’re released, since that can expose you to potential security issues if a dependency has been compromised but nobody’s noticed it yet. The solution to this is dependency cooldowns: when upgrading, only consider packages which have had at least a bit of time for security researchers and scanners to take a look. The typical recommended window is three days; anything newer than that should be excluded from your package updates unless it’s a critical security patch.

At this point, most popular tools in the Python ecosystem support dependency cooldowns. For example:

You also can configure third-party dependency update bots to apply updates for you as they appear. GitHub’s Dependabot automatically applies a three-day cooldown for everything except critical security updates, and so does the Renovate update bot.

Whether to have a dependency update be a periodic manual task for a developer or something to automate via a tool like Dependabot is up to you (though I personally prefer automation). The important thing is you do it and commit to it, on a cadence that lets you take in updates only one or a few at a time, rather than falling behind and needing to update dozens of packages all in one go.

And that’s a wrap

As promised, that was a lot of words for what’s really a pretty simple set of recommendations. Unfortunately there’s a lot of complexity—necessary complexity, of a sort that pops up in any software packaging ecosystem—lurking in this topic, and explaining all that is what drives up the word count.

But hopefully you now understand how to do “boring” Python dependency management, relying primarily on standard first-party tooling. Even if you don’t want to adopt my recommendations, I’d like to think that learning what they are and why I make them is helpful to you. For me, these recommendations are the result of over a decade of work, across multiple employers, to develop a dependency management workflow that keeps things up-to-date with minimal risk of causing pagers to go off.

Meanwhile I’ve got some ideas for further “boring Python” articles, but those will have to wait for another day.

Changelog

The version of this post you’re looking at right now was written mostly during late August 2026, and published in early September 2026. It’s a major revision of the original, which was written and published in May 2022.

The key changes between the two versions are:

September 01, 2026 04:37 PM UTC


Python Bytes

#494 Python Wrapture

<strong>Topics covered in this episode:</strong><br> <ul> <li><strong><a href="https://github.com/openai/openai-python/blob/main/httpx2.md?featured_on=pythonbytes">OpenAI's Python SDK has migrated to HTTPX2</a></strong></li> <li><strong><a href="https://tmog.org?featured_on=pythonbytes">TMOG - Native Task Manager for macOS, Windows, and Linux</a></strong></li> <li><strong><a href="http://grahamdumpleton.me/posts/2026/08/introducing-wrapture/?featured_on=pythonbytes">wrapture - one wrapper for mocking, tracing, and observability</a></strong></li> <li><strong><a href="https://github.com/juanmanueldaza/linkedin2md?featured_on=pythonbytes">linkedin2md</a>: turn your LinkedIn export into 40+ Markdown files</strong></li> <li><strong>Extras</strong></li> <li><strong>Joke</strong></li> </ul><a href='https://www.youtube.com/watch?v=4AUE4ewgN38' style='font-weight: bold;'data-umami-event="Livestream-Past" data-umami-event-episode="494">Watch on YouTube</a><br> <p><strong>About the show</strong></p> <p>Sponsored by us!</p> <p>Support our work through:</p> <ul> <li>Our <strong><a href="https://training.talkpython.fm/?featured_on=pythonbytes">courses at Talk Python</a></strong></li> <li>Consulting from <strong><a href="https://sixfeetup.com/?featured_on=pythonbytes">Six Feet Up</a></strong> </li> </ul> <p><strong>Connect with the hosts</strong></p> <ul> <li>Michael: <a href="https://fosstodon.org/@mkennedy">Mastodon</a> / <a href="https://bsky.app/profile/mkennedy.codes?featured_on=pythonbytes">BlueSky</a> / <a href="https://x.com/mkennedy?featured_on=pythonbytes">X</a> / <a href="https://www.linkedin.com/in/mkennedy/?featured_on=pythonbytes">LinkedIn</a></li> <li>Calvin: <a href="https://sixfeetup.social/@calvin?featured_on=pythonbytes">Mastodon</a> / <a href="https://bsky.app/profile/calvinhp.com?featured_on=pythonbytes">BlueSky</a> / <a href="https://x.com/calvinhp?featured_on=pythonbytes">X</a> / <a href="https://www.linkedin.com/in/calvinhp/?featured_on=pythonbytes">LinkedIn</a></li> <li>Show: <a href="https://fosstodon.org/@pythonbytes">Mastodon</a> / <a href="https://bsky.app/profile/pythonbytes.fm">BlueSky</a> / <a href="https://x.com/PythonBytes?featured_on=pythonbytes">X</a> </li> </ul> <p>Join us on YouTube at <strong><a href="https://pythonbytes.fm/stream/live">pythonbytes.fm/live</a></strong> to be part of the audience. Usually <strong>Tuesday at 7am PT</strong>. Older video versions available there too.</p> <p>Finally, if you want an artisanal digest of every week of the show notes in email form? Add your name and email to <a href="https://pythonbytes.fm/friends-of-the-show">our friends of the show list</a>, we'll never share it.</p> <p><strong>Calvin #1: <a href="https://github.com/openai/openai-python/blob/main/httpx2.md?featured_on=pythonbytes">OpenAI's Python SDK has migrated to HTTPX2</a></strong></p> <ul> <li>The OpenAI Python SDK has migrated to HTTPX2, the Pydantic-stewarded fork of httpx. Pydantic picked it up citing "limited activity recently" in the original project, promising "a reliably maintained path forward."</li> <li>If you just use the default client, nothing to do. No code changes.</li> <li>The catch is TLS. Quoting the guide: HTTPX "previously verified certificates against the CA bundle provided by certifi. HTTPX2 instead uses the operating-system trust store, and the SDK no longer installs certifi."</li> <li>That "can break certificate verification in minimal container images without system CA certificates, environments using corporate TLS-inspecting proxies, and deployments that relied on a custom or modified certifi bundle."</li> <li>The fix is SSL_CERT_FILE or SSL_CERT_DIR, or pass your own ssl.SSLContext via verify.</li> <li>Deeper integrations need real edits: custom clients, auth handlers, hooks, and request mocking all take HTTPX2 objects now, and plain httpx is no longer pulled in transitively. So import httpx in your own code means declaring it yourself or moving over.</li> <li><strong>Temporary escape hatch: a legacy HTTPX client</strong></li> </ul> <p><strong>Michael #2: <a href="https://tmog.org?featured_on=pythonbytes">TMOG - Native Task Manager for macOS, Windows, and Linux</a></strong></p> <p>A native, deeply instrumented system monitor for macOS, Windows, and Linux, now in public beta - from Plummers' Software, i.e. Dave Plummer, who wrote the original Windows Task Manager and donated it to Microsoft in 1995. <a href="https://en.wikipedia.org/wiki/Dave_Plummer?featured_on=pythonbytes">Wikipedia</a></p> <ul> <li><strong>Three real native apps</strong>: Swift/AppKit on macOS, Win32 on Windows, C++/Qt 6 on Linux, with a shared C++ core keeping metric semantics aligned - no browser shell anywhere.</li> <li><strong>One dense summary</strong>: CPU, clocks, thermals, GPU, memory, storage, network, energy, and the processes responsible for the load, all click-through.</li> <li><strong>Per-core honesty</strong>: logical processor and NUMA views, P and E cores color-coded, optional kernel time, 60 FPS live meters.</li> <li><strong>Memory with context</strong>: pressure, wired, compressed, cached, committed, available, and swap, plus configurable scrolling history.</li> <li><strong>Processes that act like processes</strong>: tree view, filtering, sorting, follow mode, and native verbs including service and launchd control.</li> <li><strong>Phosphor themes</strong>: light, dark, green, amber, blue, or mono, with color and saturation you tune yourself.</li> </ul> <p><strong>Calvin #3: <a href="http://grahamdumpleton.me/posts/2026/08/introducing-wrapture/?featured_on=pythonbytes">wrapture - one wrapper for mocking, tracing, and observability</a></strong></p> <ul> <li>Graham Dumpleton, author of wrapt and the original New Relic Python agent, has released wrapture. The name is wrapt plus capture. The core idea: wrap real code instead of replacing it, so the real code still runs while you watch every call.</li> <li>Name a method with wrapture.binding(Class, "method"), open a timeline(), and you get a tape of what actually happened. Real return values, real nesting, arguments normalised against real signatures. tape.tree() prints the call graph as it ran.</li> <li>One mechanism, three jobs: monkey patching with a real lifecycle (apply, remove, suspend, plus returns, raises, transforms_args), unit testing that asserts on real call flow instead of a flat MagicMock call list, and ad-hoc tracing of a running app.</li> <li>The testing pitch is error paths. Inject TimeoutError at the payment gateway, then assert the ledger was never written. Stubs and mocks are strict and spec-required, and there is deliberately no bare Mock().</li> <li>Tracing needs no code at all. A wrapture.toml naming targets and a sink, run with python -m wrapture <a href="http://main.py/?featured_on=pythonbytes">main.py</a>, and you get a live call tree with timings. It captures ordinary logging calls as nested events, and with the otel extra it exports spans, metrics and correlated logs with W3C trace ids that join across services.</li> <li>Every line of code and docs was AI-written under their direction, and they say so up front. Two weeks from first commit, eleventh alpha, over 1000 tests, 150+ pages of docs. Alpha on PyPI, needs Python 3.12+ and wrapt 2.4.0+.</li> </ul> <p><strong>Michael #4: <a href="https://github.com/juanmanueldaza/linkedin2md?featured_on=pythonbytes">linkedin2md</a>: turn your LinkedIn export into 40+ Markdown files</strong></p> <p>Via Juan Manuel Daza - a Python CLI that unpacks LinkedIn's data-export ZIP into clean, per-category Markdown you can drop straight into an LLM.</p> <ul> <li><strong>One command</strong>: <code>linkedin2md Complete_LinkedInDataExport.zip</code>, plus <code>o</code> for output dir, <code>-lang en|es</code>, and <code>-pdf</code>.</li> <li><strong>40+ output files</strong>: profile, experience, education, skills, connections, posts, comments, reactions, recommendations, endorsements, job applications, even ad targeting and LinkedIn's inferences about you.</li> <li><strong>Built for LLM analysis</strong>: the README pitches NotebookLM, Claude Projects, Obsidian, and Ollama, with example prompts like "what patterns do you see in my career transitions?"</li> <li><strong>PDF resume mode</strong>: <code>-pdf</code> renders an A4 CV via weasyprint, and degrades gracefully to Markdown-only if it isn't installed.</li> <li><strong>Dependency note</strong>: "pure Python / zero-dep" holds for the Markdown path only - the PDF path needs weasyprint and markdown installed.</li> <li><strong>Install</strong>: <code>pipx install linkedin2md</code> recommended, <code>pip</code> in a venv otherwise - 86% Python, 10 releases, v0.3.1 in May.</li> <li><strong>Agentic dev angle</strong>: repo ships opencode config and an N3RV subagent pipeline, including a "judgment day" dual-model adversarial PR review.</li> </ul> <p><strong>Extras</strong></p> <p>Calvin:</p> <ul> <li><a href="https://www.eveonline.com/news/view/the-move-to-python-3-begins?featured_on=pythonbytes">EVE Online Migrates to Python 3</a></li> </ul> <p>Michael:</p> <ul> <li><a href="https://dinkus.textualize.io?featured_on=pythonbytes">Dinkus</a> by Will McGugan</li> </ul> <p><strong>Joke: <a href="https://www.talisman.org/tao/?featured_on=pythonbytes">Tao of Programming: Book 5 Maintenance</a></strong></p>

September 01, 2026 03:39 PM UTC


Hugo van Kemenade

Help test Python 3.15!

Calling all Python library maintainers! 🐍

The second and final Python 3.15 release candidate is out! 🎉

It’s your last chance to join the cool testers’ club and say that YOU helped test 3.15 before the big day on 1st October! 😎

PEP 790 defines the release schedule for Python 3.15.0:

In his announcement, Hugo van Kemenade (👋 hi, that’s me!), release manager for Python 3.14 and 3.15, gave a call to action:

We strongly encourage maintainers of third-party Python projects to prepare their projects for 3.15 during this phase, and publish Python 3.15 wheels on PyPI to be ready for the final release of 3.15.0, and to help other projects do their own testing. Any binary wheels built against Python 3.15.0 release candidates will work with future versions of Python 3.15. As always, report any issues to the Python bug tracker.

Test with 3.15 #

It’s now time for us library maintainers to start testing our projects with 3.15. There’s two big benefits:

  1. There have been removals and changes in Python 3.15. Testing now helps us make our code compatible and avoid any big surprises (for us and our users) at the big launch in October.

  2. We might find bugs in Python itself! Reporting those will help get them fixed and help everyone.

How #

To test the latest alpha, beta or release candidate on GitHub Actions with actions/setup-python, add 3.15 and allow-prereleases: true to your workflow matrix.

For example:

jobs:
 test:
 runs-on: ubuntu-latest
 strategy:
 fail-fast: false
 matrix:
 python-version: ["3.11", "3.12", "3.13", "3.14", "3.15"]

 steps:
 - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

 - name: Set up Python ${{ matrix.python-version }}
 uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
 with:
 python-version: ${{ matrix.python-version }}
 allow-prereleases: true

(We can instead use 3.15-dev and omit allow-prereleases: true, but I find the above a bit neater, and when 3.15.0 final is released in October, it will continue testing with full release versions.)

When to support 3.15? #

Now is also a good time to declare support and add the Programming Language :: Python :: 3.15 Trove classifier. Many projects already have!

Especially if you have extension modules and other projects depend on yours, a release with binary wheels will help them test and prepare.

ABI breaks? #

From the announcement:

There will be no ABI changes from this point forward in the 3.15 series, and the goal is that there will be as few code changes as possible.

Let’s start testing 3.15 now! 🚀

See also #


Header photo: A woodcut of a crowned snake in the Museo di Palazzo Poggi, Bologna, used for Ulisse Aldrovandi’s Serpentum, et draconum historiæ libri duo (1640).

September 01, 2026 02:33 PM UTC


Python Software Foundation

The 2026 PSF Board Election is Open!

It’s time to cast your vote! Voting for the 2026 PSF Board Election is open starting today Tuesday, September 1st, 2:00 pm UTC, through Tuesday, September 15th, 2:00 pm UTC. 

How to Vote

If you are a voting member of the PSF who affirmed your intention to participate in this year’s election or you voted in last year’s election, you will receive an email today from “OpaVote Voting Link <noreply@opavote.com>” with a link to your ballot. The subject line will read “Python Software Foundation Board of Directors Election 2026”. If you haven’t seen your ballot by Wednesday, please first check your spam folder for a message from “noreply@opavote.com”. If you can’t find the ballot email from OpaVote please get in touch by emailing psf-elections@pyfound.org so we can look into your account and make sure we have the most up-to-date email for you.

Four seats on the board are open, but you can vote to approve as many of the 17 candidates as you like. We’re delighted by how many of you are willing to contribute to the Python community by serving on the PSF Board! Make sure you take some time to look at all the nominee statements and choose your candidates carefully. 

ATTN: Choose carefully before you press the big green vote button. Once your vote is cast, it cannot be changed.

An Informed Vote is a Great Vote

Nomination and supporting statements are the clearest way to see how each candidate actually thinks: their priorities, experience, and vision for the PSF. Rather than just voting on name recognition, we encourage you to read each candidate's nomination and supporting statements. PSF Board members shape real decisions about budget, programs, and community direction. Reading these statements helps ensure your vote reflects what the candidates would actually do in the role. 

This year, the PSF election team has taken a few more steps to ensure informed voting. We invited this year’s candidates to participate in written interviews. You can find the candidate interviews on the PSF blog. We also encouraged this year’s candidates to open AMA (ask me anything) threads on discuss.python.org. You can find those threads under the PSF Board Election category on Discuss. Some great Q&A has already been taking place, so make sure to check them out!

Who can vote? 

You need to be a Contributing, Supporting, or Fellow member and have affirmed your voting intention by August 25th, 2:00 pm UTC, to vote in this election. Per last year’s Bylaw change that allows for simplifying the voter affirmation process by treating past voting activity as intent to continue voting, if you voted last year, you have been automatically added to the 2026 voter roll. Please note: If you removed or changed your email on psfmember.org, you may not automatically be added to this year's voter roll. 

Reminder that if you were formerly a Managing member, your membership has been updated to Contributing as of June 25th, 2025, per a recent Bylaw change that merged Managing and Contributing memberships

If you’d like to learn more or sign up as a PSF Member, check out our membership types. You can check your membership status on your User Information page on psfmember.org (you will need to be logged in). If you have questions about your membership or the election please email psf-elections@pyfound.org

September 01, 2026 10:02 AM UTC

Inaugural Python Packaging Council Election: Voting is now open!

It’s time to cast your vote! Voting for the inaugural 2026 Python Packaging Council (PPC) Election is open now through September 15 at 2:00 pm UTC.

Find Your Ballot

If you are a voting member of the PSF who affirmed your intention to participate in this year’s election, you will receive an email today from “OpaVote Voting Link <noreply@opavote.com>” containing a link to your ballot. The subject line will be “Python Packaging Council Election 2026”.

If you haven’t received your ballot by September 2, first check your spam folder for a message from noreply@opavote.com. If you still can’t find the OpaVote email, please contact pc-elections@python.org. We can look into your account and confirm that we have the most up-to-date email address for you.

Review the Nominees

Please take some time to read all of the nominee statements before casting your ballot. Consider what each nominee brings to the Council, the priorities and perspectives they describe, and how you would like to see Python packaging evolve. You can also ask candidates questions directly in the PPC Nominations AMA threads on discuss.python.org.

Important: Review your selections carefully before pressing the big green vote button. Once your ballot has been cast, your vote cannot be changed.

Who Can Vote?

To vote in this election, you must be a Contributing, Supporting, or Fellow member of the PSF and have affirmed your intention to vote by August 25 at 2:00 pm UTC.

If you’d like to learn more about PSF membership or become a member, see the PSF Membership page. You can check your current membership status on your User Information page at psfmember.org while logged in.

If you have questions about your membership or the election, please contact pc-elections@python.org.

September 01, 2026 09:59 AM UTC


Python Insider

Python 3.15.0 candidate 2 is here!

Last chance to test before the big day!

September 01, 2026 12:00 AM UTC


Graham Dumpleton

Introducing wrapture

For the best part of two decades, through wrapt, I have been dealing with the mechanics of monkey patching in Python. Anyone who has followed wrapt will know I am quite pedantic about correctness, to the point of caring whether a wrapper preserves every last introspectable detail of the thing it wraps. For much of that time I have wanted the same standard from the tools I use when testing code, and unittest.mock, which does what it does well enough, was never designed to give it. A fabricated Mock answers every method call and verifies nothing. A patched call records a flat list of calls, with no return values and no sense of what was called from what. The calls an object makes to itself are invisible, because the substitute never runs the real code at all. What I want a test to be sure of, that the right calls happened, in the right order, with the real code actually running, sits just outside what substitution can express.

Seeing the real calls as they happen, with the real code still doing the work, was also something I had already spent years on for a quite different purpose. I was the original author of the New Relic Python agent, written while I worked there, and that left me with a lasting interest in instrumenting Python programs. Attaching observation to code you do not control, recording what flows through it, and doing so without disturbing the program being watched, is a problem I have never really stopped thinking about.

Testing and tracing look like different problems, but from where I sat they wanted the same thing, and I had long believed that wrapt's approach of wrapping real code rather than replacing it could serve both. wrapture (the name being wrapt plus capture) is me finally getting around to finding out whether that belief held up.

Wrap anything, capture everything

The one idea everything in wrapture sits on is to wrap rather than replace. A binding names a location in code, a method of a class or a function in a module, and when applied installs a wrapt wrapper around the real callable. Unless you tell it otherwise the wrapper is transparent. The real code runs, with wrapture in a position to watch the call, change it, or answer it instead.

Take a small call graph where an order service charges a payment gateway and then records the result in a ledger:

import wrapture

class Gateway:
    def charge(self, amount, currency="USD"):
        return {"id": f"ch_{amount}", "amount": amount}

class Ledger:
    def record(self, entry):
        return f"led_{entry['id']}"

class OrderService:
    def __init__(self):
        self.gateway = Gateway()
        self.ledger = Ledger()

    def place(self, amount):
        result = self.gateway.charge(amount)
        self.ledger.record(result)
        return result

None of these classes import wrapture or know they are about to be observed. Bindings are created by naming the methods, and a timeline opens a recording scope in which every call through them lands on a tape:

place = wrapture.binding(OrderService, "place")
charge = wrapture.binding(Gateway, "charge")
record = wrapture.binding(Ledger, "record")

with wrapture.timeline(place, charge, record) as tape:
    OrderService().place(500)

print(tape.tree())

Running this the output is:

__main__:OrderService.place(amount=500)  -> {'id': 'ch_500', 'amount': 500}
  __main__:Gateway.charge(amount=500, currency='USD')  -> {'id': 'ch_500', 'amount': 500}
  __main__:Ledger.record(entry={'id': 'ch_500', 'amount': 500})  -> 'led_ch_500'

That is the call graph as it actually ran. The arguments are normalised against the real signatures (so charge(500) and charge(amount=500) look the same), the return values are the real ones, and the nesting comes from what really called what. The tape.tree() call is a convenience for debugging and for demonstrations like this one; in a test you would query the tape instead, and outside of a test the events would be going to a sink, which I will come to.

The same bindings intervene as well as observe. The real method can be stubbed out, made to fail, or left running while one thing about the call is changed on the way in or out:

gateway = Gateway()

with wrapture.binding(Gateway, "charge").on_call.raises(TimeoutError("down")):
    gateway.charge(500)

Inside the block the call raises TimeoutError, and after the block exits the original method is back exactly as it was.

Three uses of one mechanism

That is the whole mechanism. What makes it interesting is that it serves three purposes which are usually handled by three different tools.

The first is plain monkey patching. wrapt's wrap_object() has always been able to patch a target, but it leaves the bookkeeping to you. wrapture adds a lifecycle and a vocabulary over the top of it. A binding declares a target without touching it, apply() installs the patch, remove() restores the original, suspend() makes it inert in place, and a group of bindings applies and removes as one unit. Behaviour is configured on the binding, with returns(), raises(), transforms_args(), transforms_result() and a few others, and can be scripted to change over time, so "succeed twice, then time out" is three lines rather than a hand-written counter. This layer is useful on its own with nothing else switched on.

The second is unit testing, which is where the recording comes in. Because the real code runs, a test can assert on how calls actually flowed through it, and the interesting cases are the error paths. Inject a failure at the gateway, then check that the ledger was never written:

with wrapture.timeline(place, charge, record) as tape:
    charge.on_call.raises(TimeoutError("down"))

    try:
        OrderService().place(500)
    except TimeoutError:
        pass

    record.events.assert_never()

    print(tape.tree())

With the assertion passing, the tree shows where the failure was injected and how it propagated:

__main__:OrderService.place(amount=500)  !! TimeoutError
  __main__:Gateway.charge(amount=500, currency='USD')  !! TimeoutError (injected)

When a test must supply a stand-in, because the code under test receives a collaborator rather than importing it, wrapture provides stub() for a callable and mock(Spec) for a whole object, and these record onto the same tape as everything else. Both are strict: signatures are checked and nothing is invented on first touch. There is deliberately no spec-less Mock() equivalent, and the comparison with unittest.mock in the documentation explains why, alongside a mapping of each mock idiom to its wrapture counterpart. An opt-in pytest plugin sweeps each test for patches left applied and attaches recordings to failure reports.

The third use is ad-hoc tracing of a running application, including one you cannot modify or redeploy. Take the bindings, drop the test around them, and the only remaining question is where the events go. A sink answers that. In practice this is done with a wrapture.toml file naming the targets and the sink, and no code at all. Here is one for a slightly bigger version of the shop above, where the gateway declines some cards and the order service logs a warning when it does:

[[observe]]
target = "shop:OrderService"
name = "place"

[[observe]]
target = "shop:PaymentGateway"
match = "*"
exclude = "_*"

[[observe]]
target = "shop:Ledger"
name = "record"

[[log]]
name = "shop.*"

[[sink]]
type = "printer"

Running the program as python -m wrapture main.py applies the config before the program starts, so the patches are in place before the application imports anything, and the printer sink writes the trace to stderr as it happens:

shop:OrderService.place(order_id='order-1', amount=30, card='5100-0010')
  shop:PaymentGateway.charge(amount=30, card='5100-0010')
  shop:PaymentGateway.charge -> 'ch_30' [7us]
  shop:Ledger.record(order_id='order-1', amount=30)
  shop:Ledger.record -> 'ledger:order-1:30' [5us]
shop:OrderService.place -> 'ch_30' [234us]
shop:OrderService.place(order_id='order-2', amount=240, card='5100-0020')
  shop:PaymentGateway.charge(amount=240, card='5100-0020')
  shop:PaymentGateway.charge -> 'ch_240' [4us]
  shop:Ledger.record(order_id='order-2', amount=240)
  shop:Ledger.record -> 'ledger:order-2:240' [3us]
shop:OrderService.place -> 'ch_240' [105us]
shop:OrderService.place(order_id='order-3', amount=75, card='4000-0030')
  shop:PaymentGateway.charge(amount=75, card='4000-0030')
  shop:PaymentGateway.charge !! PaymentDeclinedError [4us]
  log shop.orders WARNING 'order order-3 declined'
shop:OrderService.place !! PaymentDeclinedError [260us]

Unlike the tidy reconstruction from tape.tree(), this is the live view, with an opening line as each call begins and a closing line with the outcome and how long it took. The [[log]] entry captures the application's ordinary logging calls as events too, so the warning appears nested inside the call that logged it rather than somewhere in a separate log file. With autowrapt installed, even the launcher is unnecessary: AUTOWRAPT_BOOTSTRAP=wrapture in the environment applies the same config at interpreter startup, so the program runs with plain python. That covers the case where something else owns the command line, like a container entry point or a WSGI server.

The printer is the simplest sink. Others stream events to disk as JSON lines, count without retaining, and compose with fan-out, sampling and filtering. Sitting on top of the tracing layer is OpenTelemetry export: with the wrapture[otel] extra installed, one [otel] table in the config sends the same events to any OTLP backend as spans, metrics and correlated logs. Every tree of events carries a W3C trace id, and the id arrives and leaves in traceparent headers, so two services both observed by wrapture join up as one distributed trace without either of them calling an OpenTelemetry API.

The point I want to land is that these are layers of one mechanism and not separate products. The binding vocabulary that stubs a method in a test is the same one that traces it in production, and the config that names methods for a printed call tree is the config that exports spans. What starts as a monkey patch or a test assertion can grow into observability without the code being rewritten along the way.

Where it sits beside what already exists

Part of why I built this is that nothing I could find did all of it. unittest.mock records a flat call list with no nesting and no return values, and a patched call returns a fabricated MagicMock rather than running the real code. Span assertion tools such as OpenTelemetry's InMemorySpanExporter require the code to already be instrumented. Tools built on sys.settrace give you a firehose with no assertion API. APM agents are all-or-nothing products rather than toolkits, and their auto-instrumentation only covers the frameworks they already know about. wrapture needs none of that. You point at your own methods by name and a trace appears, and the same pointing is how it lands in a test, a terminal, or a backend.

Just as important is what it is not. It is not a fabrication tool, and unittest.mock remains the right thing for invented objects. It is not a production APM, although it is a toolkit that APM-like things could be built on. And it is not an OpenTelemetry competitor; it emits to OpenTelemetry rather than trying to replace it.

Pre-built instrumentation

Pointing at your own methods is the core of wrapture, but for common third-party packages the pointing has already been done. The companion wrapture-instrumentation package provides ready-made instrumentation, with Flask and Jinja2 covered so far. Each records a request or a template render as one structured tree, and enabling one is an [[instrument]] entry in wrapture.toml naming the target. Installing the package brings in wrapture and nothing else; the instrumentation for a package you do not have is inert. It is being built target by target, and the instrumentation packages guide describes how to write one for a package not yet covered.

Built with AI, on purpose

Every line of code and documentation in wrapture was written by an AI assistant working under my direction. I want to be upfront about that, and equally upfront about what it was not. This was not vibe coding, where a one-shot prompt produces a pile of generated code and the person driving hopes for the best because they lack the knowledge to judge what came back. Vibe coding has earned its bad reputation. I engineered wrapture carefully from the start. I have spent a long time in this particular corner of Python and knew exactly what the result needed to be, and the AI was the means of producing it rather than the source of the design.

The experiment had two halves. The first was whether an idea I had carried around for years actually held up once built. The second, and just as much the point, was whether a library of this kind could be produced this way, with an AI doing the writing and me doing the directing, to a standard I would be happy to put my name to.

The process is what makes the result worth trusting or not, so it deserves describing. The work started well before any code, with days spent on design documents setting out the goals, the scope, the shape of the API and the layers it would be built in, which the AI and I argued over before implementation began. From there it proceeded in layers, each one specified, discussed, implemented, tested and documented before the next began. Documentation grew with the code rather than after it, and every example in the docs runs as a doctest, so the docs are continually proven against the implementation. Writing them repeatedly exposed designs that read worse than they demoed. The test suite runs against every supported Python version, including the free-threaded builds, on every change. The overhead of Python instrumentation is the usual objection to it, so the recording path was also put through a performance pass, with the cost of a call observed and exported through wrapture measured against the same call instrumented directly with the OpenTelemetry SDK and in the style of its instrumentation packages. The result was comparable per call, with the figures in the OpenTelemetry export guide.

The step I would most recommend to anyone attempting something similar came late. I took the unit test suites of well-known Python packages that lean heavily on unittest.mock and had the AI replicate their tests using wrapture instead, side by side with the originals. Every point of friction became a decision: sometimes a documented position on why wrapture deliberately differs, and sometimes a missing feature that got specified, built and documented like everything else. Several pieces of wrapture exist only because a real test suite could not be expressed cleanly without them.

Throughout, the division of labour was consistent. The AI wrote the code, the tests and the prose. I set the direction, made the design calls, reviewed what came back, and sent plenty of it back. My experience with wrapt and with Python's darker corners is all through the result, in what was asked for as much as in what was refused.

The first commit was in the middle of August and the current release is the eleventh alpha, so this all happened in a bit over two weeks. In that time it accumulated over 1000 tests and over 150 pages of documentation. The documentation is admittedly quite dense in places and needs some work still, but it is complete in the sense that every part of the package is covered. One thing a brand new library has over an old one is coherence. Mature packages accrete features one release at a time, and there is never a moment when the whole API can be redesigned to match what was learned along the way. Because wrapture arrived in a compressed period with the whole design still in view, when validation showed a design could be better it was redesigned rather than worked around.

I know some people are firmly opposed to using AI-written software. If that is you, I understand, and I am not going to argue with your position. It is a reasonable one to hold, and this post exists so you can make the call with the facts in hand rather than discover them later. If AI involvement rules wrapture out for you then wrapture is not for you, and that does not worry me one bit. This has been about finding out whether the process works, and I now have my answer to that. The longer version of all this is on the how wrapture was built page in the documentation.

What's next

wrapture is in alpha, with pre-releases on PyPI. Until 1.0.0 is final a plain pip install wrapture picks up the latest pre-release, so there is no need to pin a version. It requires Python 3.12 or later and wrapt 2.4.0 or later. The API is complete for the three uses described above and I am not expecting it to break, so code written against it today should carry forward to 1.0.0.

What it needs now is use. unittest.mock and OpenTelemetry's own instrumentation are the established tools for the two halves of what wrapture does, and the open question is whether an alternative that does both from one mechanism is something people want. Reports of it working, or not, on real code, and of what confused or was missing, are what will decide whether anything changes before a beta. They go to the issue tracker.

To be clear, wrapture was never premised on anyone else using it. I built it because I wanted to see it exist, not because I had identified a gap in the market. If people find it useful and pick it up, that is great, and I will aim to support it. If there is no interest, I will keep treating it as an experiment and work on it for my own purposes. Either way the questions got answered, and getting them answered was the point.

There is a lot more in wrapture than fits in an introduction, and I expect to write about specific parts of it in follow-up posts, starting with how it can be used for unit testing, and then tracing a Flask application through to an OpenTelemetry backend without touching the application code. For now the getting started page is the place to begin.

September 01, 2026 12:00 AM UTC


Seth Michael Larson

Buying GameCube games from other regions

This blog post was inspired by discovering that Pokémon Box: Ruby & Sapphire, a legendarily rare and expensive GameCube game in the United States (>$2,500 USD), could be purchased complete-in-box (CIB) in Japan for $50 USD. That's a savings of $2450, or ~98% of the US price. This is a somewhat extreme example: in the US market Pokémon Box was only sold online and at a single store: the Pokémon Center in New York City.

This big price difference between regions got me thinking: what other games are cheaper in other regions and by how much? Please note that the below data was taken from Pricecharting on August 30th, 2026. Shipping costs aren't included in the price, shipping from Japan to the US is typically $15-$20 USD on eBay, but many games are already imported to the US.

GameDiff.Price (US)Price (PAL)Price (JP)
Pokémon BoxCIB-$2348$2400$204$52
Loose-$1553$1574$137$21
CubivoreCIB-$612$640$28
Loose-$318$341$23
Gotcha ForceCIB-$405$474$137$69
Loose-$204$249$121$45
Resident Evil: Code Veronica XCIB-$239$299$80$60
Loose-$151$213$62
Chibi RoboCIB-$189$210$223$34
Loose-$136$144$161$25
Pokémon XD: Gale of DarknessCIB-$170$209$232$62
Loose-$129$150$183$54
Fire Emblem: Path of RadianceCIB-$166$214$187$48
Loose-$101$145$145$44
Resident Evil 2CIB-$90$161$78$71
Loose-$77$106$29$44
Pokémon ColosseumCIB-$98$142$64$44
Loose-$83$109$49$26
Legend of Zelda: Twilight PrincessCIB-$108$196$115$88
Loose-$66$146$107$80
Resident Evil 3: NemesisCIB-$97$156$59$74
Loose-$52$99$47$69
Legend of Zelda: Four Swords AdventuresCIB-$92$77$130$38
Loose-$58$56$79$21
Eternal DarknessCIB-$82$114$32$34
Loose-$60$85$25$33
Billy Hatcher and the Giant EggCIB-$66$89$23$34
Loose-$55$65$10$34
Wario WorldCIB-$55$80$60$25
Loose-$44$62$45$18

Even more excitingly, there is a mod patch for Pokémon Box enabling the Japanese version to communicate and trade Pokémon between US, JP, and PAL Game Boy Advance games. This means if you wanted to use Pokémon Box functionality you could buy a Japanese copy for $50, patch the software, and use the software as if it were from any region.

This fact carries beyond GameCube games, as you'd imagine. Legend of Zelda: Minish Cap is $90 for a loose US copy and only $40 for a Japanese copy. Surprisingly it's not always the case that the US release is cheaper than the Japanese release. For example, the Sonic Gems Collection for GameCube costs $55 CIB for a US release compared to $180 CIB for JP.

I suspect this is because the soundtrack for Sonic CD is different for each region, JP Sonic Gems Collection carrying the far-superior and sought after JP Sonic CD soundtrack. The US Sonic CD soundtrack was included in the US and PAL Sonic Gems Collection releases. The Japanese Sonic CD soundtrack was released for the first time outside Japan in Sonic Origins.

Why shouldn't you buy other regions' games?

The primary reason is if you don't speak the language. Many PAL GameCube titles include English as one of the many supported languages, but this can't be said for NTSC-J. Most Japanese titles are exclusively in Japanese, and I don't speak the language. For some games this is fine, not knowing Japanese won't hamper games that aren't text-heavy, but RPGs or story-based adventures will impact the fun-factor. There are some modifications you can apply to translate the Japanese text back into English.

Many consoles are region-locked, and thus require either purchasing a matching region console, modifying a console to support all regions, or dumping ROMs and using emulators (usually with a dedicated device or console modification). So you can't use any region's software without at least some extra work or hardware. The Nintendo GameCube has three main regions: NTSC-J (Japan), NTSC-M (Americas), and PAL (and maybe NTSC-K (Korea)? I cannot tell if this is actually its own region).

I've gone the way of modifying my GameCube hardware with a solderless mod-chip called the FlippyDrive, but other open source options for a similar device are now available. This modification allows running any regions games on any region hardware.

If you are using unmodified hardware you may run into issues with frame rate if you're mixing software and hardware from an NTSC region (US, JP, KR) and the PAL region. I am not super knowledgeable in this area, look around for solutions to this issue before buying.



Thanks for reading ♥ I would love to hear your thoughts! Contact me via Mastodon, Bluesky, or email. Browse the blog archive. Check out my blogroll.



September 01, 2026 12:00 AM UTC