Planet Python
Last update: September 18, 2026 01:48 PM UTC
September 18, 2026
Graham Dumpleton
Writing a workshop for JupyterLab
Yesterday's post introduced jupyterlab-workshop and showed one page of a workshop. This one writes a whole workshop from an empty directory. It is small enough to fit in a post, but it has everything a real one has: a manifest, four pages, actions that drive the JupyterLab session, checks that run by themselves, a quiz, and the tooling that proves the workshop works before a learner ever sees it.
The subject is pytest. The workshop teaches the loop pytest is built around, which is write a test, watch one fail, fix the code, and it does that in four pages. Because pytest has to be installed, it is a chance to show how a workshop gets a Python environment of its own. It also lets me put the three content lessons from my PyCon talk to work, since a workshop format is only useful if it makes it easier to do those things well.
Plan the steps first
Before writing anything, decide the steps, and for each one decide what proves it was done. That second part is what distinguishes a workshop from a tutorial, and it also happens to be the constraint that keeps the steps small, because a step that ends in something checkable is rarely a big one.
For this workshop there are four. Set things up, which is done when pytest can be run. Write a first test, done when it passes. Add a second test that exposes a bug, done when the learner has predicted what pytest will say and then seen one test fail. Fix the bug, done when both tests pass. The prediction in the third step is the "change one thing and say what you think will happen" pattern from the talk, and the check on every page is the "verify after anything that can silently go wrong" one.
Scaffolding
The jupyter workshop command that comes with the package scaffolds a workshop directory:
jupyter workshop init pytest-first-steps --title "Testing with pytest"
That writes a manifest, two example pages to replace, a README, a .gitignore and an empty files/ directory. I threw the example pages away and made the manifest read:
apiVersion: jupyterlab-workshop/v1alpha1
name: pytest-first-steps
title: Testing with pytest
version: 0.1.0
description: Write a first test with pytest, watch a second one fail, and fix the code it caught.
tags: [python, pytest, testing]
duration: 20m
platforms: [linux, macos]
capabilities:
- terminal
- write-files
- kernel-exec
- install-packages
environment:
requirements: requirements.txt
env:
PYTHONDONTWRITEBYTECODE: "1"
layout: default
gating: soft
pages:
- pages/01-set-up.md
- pages/02-first-test.md
- pages/03-a-failing-test.md
- pages/04-fix-the-bug.md
The capabilities list is what the learner will be asked to trust when the workshop opens. This one runs commands in a terminal, writes files in the learner's workspace, runs code in a kernel, which the checks need, and installs packages. An action whose capability is not declared never runs, and the linter checks the list against what the pages actually use, in both directions.
The environment field is how the workshop gets pytest. The requirements.txt beside the manifest has one line in it, pytest, and the first page has an action that creates a virtual environment from it. The environment lives inside the workshop directory, so nothing else on the learner's machine changes, and once it exists it is first on the path in the workshop's terminals and in the checks, with no activation step anywhere on a page. A kernel is registered for it too, which a notebook workshop would use. That is far better than walking the learner through python -m venv and pip install, since the workshop is there to teach pytest, not how to create virtual environments.
The env entry guards against a problem that can arise when a file changes in quick succession, which in a workshop happens whenever a learner clicks rapidly through the steps. Python reuses cached bytecode whenever a source file's size and its modification time in whole seconds are unchanged, so an edit that swaps text for text of the same length, made within a second of the previous run, runs the old code. The self-test, which runs actions back to back, hits it even more readily. Turning bytecode caching off makes it go away. The other fields are less interesting. layout: default opens a terminal beneath the main area, and gating: soft means a learner can move past a page whose check has not passed, but is told so, and the page is not marked as done.
The one file the workshop ships to the learner is files/orders.py. Anything in files/ is copied into a work/ directory when the workshop first opens, and that is where the learner works, where the terminals start, and what restarting the workshop empties and refills. The function has a bug in it, on purpose:
"""Order totals for a small shop."""
def total(prices, discount=0):
"""Add up the prices and take off a percentage discount."""
subtotal = sum(prices)
return subtotal - discount
Page one: set up
A page is a Markdown file with YAML front matter, and the actions are fenced blocks with the action name in braces. Options are :name: value lines at the top of the block and the rest is the body.
---
title: Set up
requires: [verify:pytest-available]
---
# Set up
pytest is the test runner most Python projects use. In this workshop
you write a test for a small function, add a second test and watch it
fail, then fix the code the test caught.
The function is in `orders.py`, which is already in your workspace.
Open it and read it before going on.
```{file-open}
:path: orders.py
```
pytest is not part of Python, so the workshop needs an environment
with it installed. This creates one inside the workshop directory, so
nothing else on your machine changes.
```{environment-create}
:title: Create the workshop environment
```
Once it exists the workshop terminal picks it up. Confirm pytest is
there.
```{execute}
:id: pytest-version
:wait: prompt
python -m pytest --version
```
```{verify}
:id: pytest-available
:label: pytest is installed
:substrate: shell
:trigger: after:pytest-version
python -m pytest --version
```
The requires line in the front matter says the page is not done until the pytest-available check has passed. The file-open action opens the shipped file in the editor beside the panel, so the learner has read the function before being asked to test it. The environment-create action creates the virtual environment from the requirements file, with the text before it saying why that is needed. The check at the end of the page then confirms that pytest was installed into it, so progress past the page is gated on the environment actually working.
The execute action runs its body in the workshop terminal, and :wait: prompt makes it wait until the shell is back at its prompt before reporting that it is done, rather than the moment the command has been typed. The verify after it is triggered by that completion, so the check runs by itself once the learner has taken the step. The check runs its own command rather than relying on what the terminal did, so here it would pass at any point after the environment exists, but tying it to the step keeps it from running before the learner has got there. The check uses the shell substrate, meaning its body is run as a shell command and exit code zero passes. Its output becomes the message the learner sees with the check, which here is the pytest version.
Page two: the first test
---
title: The first test
requires: [verify:first-test-passes]
---
# The first test
pytest finds tests on its own: any file named `test_*.py`, and in it
any function named `test_*`. A test is a plain function that calls
the code and uses `assert` to say what it expects. This writes one for
`total()` and opens it in the editor.
```{file-write}
:id: write-test
:path: test_orders.py
:open: true
from orders import total
def test_total_adds_the_prices():
assert total([10, 20]) == 30
```
Run it. The `-q` keeps the output to one character per test and a
summary line.
```{execute}
:id: run-first-test
:wait: prompt
python -m pytest -q
```
A dot is a pass. The check below runs the tests itself, so it passes
whether you clicked the command or typed it.
```{verify}
:id: first-test-passes
:label: The first test passes
:substrate: shell
:trigger: after:run-first-test
out=$(python -m pytest -q --color=no test_orders.py 2>&1) && { printf '%s\n' "$out" | tail -n 1; exit 0; }; printf '%s\n' "$out"; exit 1
```
file-write writes its body to a file in the workspace, and with :open: true shows it in the editor, so the learner sees the test appear rather than being asked to type it. Typing it yourself is more work, and work is where learning happens, but a typo in a file you were told to copy teaches nothing except frustration. Writing the file and opening it in front of them, then having them type the command that runs it, is a reasonable compromise if you want the learner doing some things by hand rather than clicking an action for everything.
The check is worth a closer look, because it is a pattern that repeats through the workshop. A shell check's whole output is its message, so the command shapes it. On success it prints only the last line of pytest's output, the 1 passed in 0.00s summary, and exits zero. On failure it prints everything pytest said and exits one, so the learner sees the failing assertion in the panel. The --color=no is there because escape codes in a check message are not helpful. And the check runs pytest itself rather than looking at what the terminal printed, which is the rule I would give anyone writing checks: check the outcome, not the keystrokes. It means the check passes whether the learner clicked the action, typed the command, or ran it some third way.
Page three: a failing test
---
title: A failing test
requires: [quiz:predict, verify:one-fails]
---
# A failing test
`total()` takes a `discount`, which its docstring says is a
percentage. Add a test that holds it to that: ten percent off thirty
should be twenty-seven.
```{editor-insert}
:id: add-discount-test
:path: test_orders.py
def test_discount_is_a_percentage():
assert total([10, 20], discount=10) == 27
```
Before running it, say what you think pytest will report.
```{quiz}
:id: predict
:title: Predict the result
question: What happens when you run pytest now?
options:
- { text: One test passes and one fails, correct: true }
- text: Both tests fail, because orders.py is wrong
explanation: The first test never uses the discount, so the bug never reaches it. A test only checks what it asks about.
- text: pytest stops at the first failure
explanation: pytest runs every test it finds and reports them all, unless you ask it to stop with -x.
explanation: Each test runs on its own and is reported on its own, so the bug shows up in exactly the test that exercises it.
```
Now run it.
```{execute}
:id: run-second-test
:wait: prompt
python -m pytest -q
```
Read the failure from the bottom up. The last line counts what passed
and what failed, and above it pytest shows the assert that failed with
the value on each side, so you can see the function returned 20 where
27 was expected.
```{verify}
:id: one-fails
:label: One test fails and one passes
:substrate: shell
:trigger: after:run-second-test
out=$(python -m pytest -q --color=no test_orders.py 2>&1); case "$out" in *"1 failed, 1 passed"*) echo "One failed, one passed, as expected"; exit 0;; esac; printf '%s\n' "$out"; exit 1
```
editor-insert adds its body to the end of the file that is already open in the editor, and the learner watches it appear.
The quiz is the point of the page. It comes before the command, and the page is gated on it, so the learner has to commit to an answer before they can find out. The wrong options are not filler either. Each one has an explanation that teaches something a learner who picked it did not know, which is the only reason to have a wrong option at all. That question is the whole trick I described in the talk. If they are wrong they find out in about two seconds, and now they understand that pytest runs each test on its own.
The check then confirms the learner is in the state the next page assumes, one test failing and one passing, and it does that by looking for pytest's summary line. The execute action before it reports a pass even though the command exits with status one, since the action's job was to type the command, and it is the check's job to say what the result should have been.
Page four: fix the bug
---
title: Fix the bug
requires: [verify:all-pass]
---
# Fix the bug
The function subtracts the discount as an amount. The docstring, and
now the test, say it is a percentage. Change the last line so the
discount comes off as a fraction of the subtotal.
```{editor-replace}
:id: fix-discount
:path: orders.py
:match: return subtotal - discount
return subtotal * (100 - discount) / 100
```
Run the tests again.
```{execute}
:id: run-again
:wait: prompt
python -m pytest -q
```
Two dots. The test that caught the bug now guards against it coming
back, which is what a test is for.
```{verify}
:id: all-pass
:label: Both tests pass
:substrate: shell
:trigger: after:run-again
out=$(python -m pytest -q --color=no test_orders.py 2>&1) && { printf '%s\n' "$out" | tail -n 1; exit 0; }; printf '%s\n' "$out"; exit 1
```
That is the loop pytest is built around: write a test that says what
the code should do, watch it fail, make it pass. From here, the
[pytest documentation](https://docs.pytest.org/en/stable/getting-started.html)
covers fixtures and parametrised tests, which are the next two things
worth knowing.
editor-replace finds the text named by :match: in the file and swaps it for the body, leaving the new text selected in the editor so the learner can see exactly what changed. For a one line fix that is a much better experience than telling someone to edit line nine, and it also means the check can be certain about what the file now contains. The page ends by saying what was learned and where to go next, which is where every last page should end.
Lint
With the four pages written, the linter reads the manifest and every page and reports what is wrong:
jupyter workshop lint pytest-first-steps
For the workshop above it prints 0 error(s), 0 warning(s), which is not much of a demonstration, so I broke a copy of it. I removed kernel-exec from the manifest, which the shell checks need, misspelt the :session: option on one of the commands, and made a typo in the action id that the last page's check is triggered by:
pages/02-first-test.md:27: warning: Option "sesion" is not used by the execute directive
workshop.yaml: error: Pages use the "kernel-exec" capability (4 actions) but the manifest does not declare it
pages/04-fix-the-bug.md:30: error: "all-pass" is triggered by unknown action "run-agian"
2 error(s), 1 warning(s)
The last of those is the one I would least like to ship. A check whose trigger names an action that does not exist still works when the learner runs it by hand, so nothing looks broken, it just never runs by itself the way the page says it will. That is exactly the kind of silent mistake a linter is for. The linter is about the mechanics: capabilities, options, checks that are malformed, ids that name nothing, variables used before the form that sets them, a few danger heuristics such as piping a download into a shell. It cannot tell though whether the workshop makes sense, and it cannot tell whether the commands work.
Self-test
That second thing is what the self-test is for:
jupyter workshop test pytest-first-steps
It copies the workshop to a temporary directory, starts a JupyterLab of its own on a free port, opens the workshop in a headless browser with trust settled, and then does what a learner would do, page by page. It runs every action in order, waits for each terminal command to finish, answers the quiz correctly, and runs every check. The output for this workshop is one line per action:
PASS 01-set-up/01-set-up-1 (file-open, 0.1s)
PASS 01-set-up/01-set-up-2 (environment-create, 14.9s) Environment ready with kernel "workshop-pytest-first-steps-fb5f5348"
PASS 01-set-up/pytest-version (execute, 0.2s)
PASS 01-set-up/pytest-available (verify, 1.5s) pytest 9.1.1
PASS 02-first-test/write-test (file-write, 0.1s)
PASS 02-first-test/run-first-test (execute, 0.3s)
PASS 02-first-test/first-test-passes (verify, 0.3s) 1 passed in 0.00s
PASS 03-a-failing-test/add-discount-test (editor-insert, 0.0s)
PASS 03-a-failing-test/predict (quiz, 0.0s) Each test runs on its own and is reported on its own, so the bug shows up in exactly the test that exercises it.
PASS 03-a-failing-test/run-second-test (execute, 0.4s) The command exited with status 1
PASS 03-a-failing-test/one-fails (verify, 0.3s) One failed, one passed, as expected
PASS 04-fix-the-bug/fix-discount (editor-replace, 0.0s)
PASS 04-fix-the-bug/run-again (execute, 0.3s)
PASS 04-fix-the-bug/all-pass (verify, 0.3s) 2 passed in 0.01s
14 passed, 0 failed, 0 skipped
The fifteen seconds on the second line is pip installing pytest into the new environment. Everything else is near enough instant. What the self-test gives you is that a workshop which drifts, because a tool changed its output or a package changed its behaviour, is caught before a learner finds it, and jupyter workshop init --ci writes a GitHub Actions workflow that runs the same thing on every push.
One warning that the documentation makes at some length and I will repeat. The temporary copy protects the workshop's own files and nothing else. Every command runs as you, on your machine, with your home directory and your environment. This workshop stays inside its own directory, so running it locally is fine. A workshop that changes global git configuration or installs things into your project's environment is better tested in CI, where every run gets a fresh machine.
Author mode in JupyterLab
Everything above was done with a text editor and a terminal, which is how I prefer to work and how an AI agent works too. The pages can equally be written from inside JupyterLab, with the workshop open in the panel. Author mode is turned on from the panel header, the pencil icon in the screenshot below, and adds a toolbar and marks the workshop as your own, so saving a page never asks again how far the workshop is to be trusted.

Edit page opens the page source beside the panel and saving re-renders it. Insert is a form for an action: pick the type, fill in its options, write the body, and the block lands at the cursor. Capture turns what you just did in the session, the last terminal commands, the files you saved and the cells you ran, into actions on the page, which is a much better starting point than an empty file. Run actions and Run checks do for the current page what the self-test does for the whole workshop, in the session in front of you. Lint lists the findings for the workshop and can apply the fix for the mechanical ones itself, such as declaring a capability that is missing. Record goes further than Capture and records a whole session into draft pages, one action per step with a placeholder paragraph to fill in.
Files remain the source of truth throughout. Author mode reads and writes the same workshop.yaml and pages/*.md that the command line does, so you can move between the panel, an external editor and git without anything getting out of step. The same tools are also available to an AI agent over MCP, and the wrapture workshops were written that way, but that is a post of its own.
Publishing
Once the self-test is green, jupyter workshop publish builds an archive:
jupyter workshop publish pytest-first-steps
wrote dist/pytest-first-steps-0.1.0.tar.gz
sha256 c7fa2db6c260130926478a779eabb4efb94149f7f2178f03a57983b694ddf745
wrote dist/pytest-first-steps-0.1.0.collection.json
The archive holds the manifest, the pages, the shipped files and the requirements, and leaves out the workspace and the workshop's runtime state. It is built with fixed ownership and timestamps, so the hash is the same on every machine and can be checked by whoever installs it. The collection entry is a JSON snippet describing the workshop for a collection index, which is the list of workshops a learner is offered to choose from. Where the archive and the index go, and the alternative of pointing an index straight at a git repository so there are no archives at all, is the subject of the next post.
What's to be learned
The workshop is seven text files. A manifest, four pages and a function to test, plus a requirements file with one line in it. The tooling checks the mechanics, that the capabilities match, that the options are spelt right, that every command runs and every check passes, and it does that in about a minute without a person involved.
What the tooling does not do is decide that the workshop should have four pages rather than two, that the quiz should come before the command rather than after it, or that each page should end with a check that runs by itself. Those are the three content lessons from the talk, and they still fall to whoever writes the workshop. What the format does is make them cheap. A check is a few lines under the command it confirms. A quiz is a few lines of YAML. A page is a file, so splitting a step in two is a matter of where you put the front matter. When the right thing is that easy to do, it is more likely to get done, and that is about all a tool can offer.
September 18, 2026 04:39 AM UTC
Bob Belderbos
Protocol or ABC for a pluggable interface?
I was designing the provider boundary with a student for a CLI tool that talks to two different image-generation backends.
Same inputs from the user, two different SDKs underneath. We were figuring out how best to define the shared contract: an abstract base class, or a typing.Protocol? This article has the answer.
The setup: one contract, two backends
The CLI should not know which provider it's talking to. It calls something like submit(request) and gets a typed result back. Each provider translates that into its own SDK calls. This is the classic case for a shared interface.
My reflex is to reach for abstract base classes (ABCs) here. You write a base class with @abstractmethods, every provider inherits from it, and Python refuses to instantiate a subclass that does not implement one or more abstract methods at runtime.
It works. I've used it for Pybites search and as part of the repository pattern.
However, there is a downside: required inheritance. When the backends are pluggable, I don't own every implementation. A third-party provider shouldn't have to import my base class just to count as a valid provider. Enter protocols.
Why Protocol fits better here
typing.Protocol uses structural typing. A class satisfies the interface by having the right methods with the right signatures, not by inheriting from anything.
Quick example:
from typing import Protocol
from pixgen.models import GenRequest, GenResult # your Pydantic models
class Provider(Protocol):
def submit(self, request: GenRequest) -> GenResult: ...
class OpenAIProvider: # note: no inheritance
def submit(self, request: GenRequest) -> GenResult:
...
def run(provider: Provider, request: GenRequest) -> GenResult:
return provider.submit(request)
OpenAIProvider does not inherit from Provider (like an ABC would require), yet the type checker (ty, mypy, pyrefly) will accept it wherever a Provider is expected.
If you miss a method, get the signature wrong, or return the wrong type, a static type checker flags it before you run the code. Python itself won't enforce the protocol at runtime, so the checker is doing the work here. No forced inheritance, and you catch mistakes before you ship.
Three things this buys me:
- No coupling for outside implementers. A plugin author writes a class with a
submitmethod. They don't import my package to prove conformance. The contract lives in the shape, not the family tree. - Providers evolve independently. As long as a class keeps the right method shape, its internals can change freely, and it never inherits methods it doesn't use. An ABC with several abstract methods forces every provider to implement all of them, even as empty stubs.
- It reads as composition, not hierarchy. The provider is a value I pass into functions.
Related articles: How an AI expense agent is actually structured and Why Rust makes you import a trait to use its methods.
When I'd still go with ABCs
If the Protocol is only describing the provider contract, it's a great fit. The moment providers need to share concrete behavior, an ABC is the better tool:
from abc import ABC, abstractmethod
class BaseProvider(ABC):
def submit(self, request: GenRequest) -> GenResult:
self._validate(request) # shared, concrete
return self._call_api(request) # provider-specific
@abstractmethod
def _call_api(self, request: GenRequest) -> GenResult: ...
If every provider runs similar logic and only differs in the actual API call, inheritance is the right tool for shared implementation. Protocol only describes shape, not shared behavior.
Why not use both?
In practice ABCs and protocols aren't mutually exclusive. You can use Protocol for the public plugin boundary and an internal ABC that providers share.
A plugin ecosystem doesn't force everyone to depend on your base class. The contract lives in the method shape, not the class hierarchy. A Protocol defines the contract, an ABC on the other hand, is just an implementation detail my providers happen to reuse.
This is also the split we've architected in our agentic AI cohort. The expense-tracker app defines an Assistant Protocol for LLM providers, so OpenAIAssistant and GroqAssistant conform by shape without inheriting from anything. Swapping in a new SDK never touches the base. Storage on the other hand uses an ABC and the repository pattern (ExpenseRepository), because here we want the in-memory and database versions to use the exact same contract.
How to decide which pattern to use
Default to Protocol: it's the lighter contract and it doesn't force inheritance on anyone. Switch to ABC when providers need shared implementation, or when you want Python itself to enforce the abstract methods at instantiation time.
For a plugin "conform by shape" is often a better match than "conform by inheritance". I wrote up the fuller typing.Protocol walkthrough a while back on Pybites' blog. There you can read more in detail how the type checker enforces protocols, how to enhance them with @typing.runtime_checkable, and a bit more on Python's duck typing philosophy.
September 18, 2026 12:00 AM UTC
Graham Dumpleton
Introducing jupyterlab-workshop
When I released the 24 wrapture workshops last week, they ran in JupyterLab on mybinder with the instructions in a side panel, and I said at the end of that post that the panel deserved a post of its own. The panel is jupyterlab-workshop, a JupyterLab extension I wrote the week before. The workshops were the reason it exists, and the first thing built with it. If you know I have spent years working on Educates and are wondering why I did not simply use that, there is a reason, and I come to it near the end.
The extension has documentation on ReadTheDocs and is on PyPI. The short description is that it separates the instructions for a workshop from the work. The instructions live in a sidebar panel, one page at a time. Each step on a page is a clickable action that does something real in the JupyterLab session beside it, whether that is running a command in a terminal, writing a file, creating a notebook and running its cells, executing code in a kernel, or arranging the window. The workshop can check what the learner has done, ask them questions, and hold a page until the checks pass. A workshop is a directory of Markdown files and a manifest, and it runs wherever JupyterLab runs.
Why a notebook isn't a workshop
JupyterLab is already a natural place to teach, and the usual way to do it is a notebook. You write paragraphs of explanation with code cells between them and hand it to the learner to run. That works up to a point, and then it doesn't.
The first problem is that the learner reads down the page pressing Shift+Enter, or picks Run All, and finishes having done nothing. The notebook did the work and they watched. That is the passive walkthrough I argued in Hands-on learning in the age of AI is exactly the kind of content that no longer needs a person to write it.
The second is that everything has to be a cell, in the notebook's one language. A lesson cannot ask for a shell command to be run, a file to be edited by hand, a second notebook to be created, or for anything JupyterLab itself does. If the thing being taught is git, or a command line tool, or a Python package that has to be installed into a virtual environment, or JupyterLab, a notebook can only describe it. It cannot be the place it happens.
The third is that the instructions and the work are the same document. What the learner ends up with is neither a clean set of notes nor a clean piece of work, and there is no way to tell, from either side, whether a step was done, done right, or skipped. A mistake early on that does not fail outright goes unnoticed until something much later fails for no obvious reason, and by then there is nothing pointing back to the cause.
Instructions beside the work
The extension adds a Workshop panel as a sidebar tab. It shows one page of the workshop at a time, with Previous and Next buttons, a progress bar, and a drop-down for jumping between pages. The rest of the window is an ordinary JupyterLab session with terminals, the file browser, the editor, notebooks and kernels, all of which the learner would be using anyway.

The screenshot shows the kinds of things a page can do. The first action is a run in terminal block, marked done, with the two commands it ran. Clicking it opened the terminal in the main area, in the right directory, and typed the commands in, so the learner did not have to find the terminal or type anything. Below it is a check, which ran on its own the moment the terminal showed the command and turned green when the file appeared. This one is also on a timer, so it keeps watching. If the learner deletes the file it goes red, and if they make it again by hand it recovers. The two actions after that open the file the command wrote in the editor and reveal it in the file browser. The badge at the top of the panel shows the workshop was opened as trusted, which I come to below.
None of it is simulated. When a page says run this command, clicking the action runs it in a real terminal, and the learner can just as easily type it themselves, or type something else and see what happens. Actions cover the terminal, files and the editor, notebooks and kernels, the interface and layout, and guidance such as hints and guided tours of the interface. A page can also say how the window should be arranged when it opens, so a lesson can start with a README rendered above a terminal and nothing else in the way.
If you have used Educates, the idea of clickable actions driving a session will be familiar. That concept carried over. The implementation did not, since this is a JupyterLab extension written from scratch to work with what JupyterLab already provides.
Checking the work
The part that makes it a workshop rather than a nicely formatted document is that a page can check what the learner has done. A verify block runs a check, which can be Python code run in a kernel of its own, separate from any the learner is using, a script run on the server, a shell command, or a list of predicates over files and the interface, such as a file existing, a file containing some text, a notebook cell having been executed, or a terminal being open. A quiz asks a question and a form collects values which can then flow into the commands and text on later pages.
A page can require some of those to have passed before the learner moves on, and the workshop manifest says whether that gating is advisory or enforced. Leaving a page with its requirements met marks it as completed, and the record of which pages have been completed is what the progress shown to the learner is based on. A checkpoint block snapshots the learner's files so a later page can put them back, which is how a workshop can have someone deliberately break something and then recover.
In the PyCon talk I said people quit at step eight because of a typo at step three, and that the fix is to have them verify their work after anything that can silently go wrong. Checks which run on their own the moment the terminal shows the expected output are that fix, built into the format rather than left to the author to remember.
Since a workshop can run commands on your machine, the learner is asked, before anything runs, how far to trust it. Every action type needs a capability, such as terminal or write-files, which the manifest has to declare, and an action whose capability is not declared never runs. When a workshop is opened the learner is told where it came from and what it wants permission to do, and chooses how much of that to allow. At the more cautious level, commands are typed into the terminal but not run until the learner presses Enter, and writing files or running code asks first. I will come back to that in a later post on deployment.
What a workshop is made of
A workshop is a directory. There is a workshop.yaml manifest with the name, title, capabilities and the ordered list of pages, a pages directory with one Markdown file per page, and a files directory holding whatever ships to the learner, such as starter code or data. A work directory is generated when the workshop first opens, filled from files, and that is where the learner works. Restarting the workshop throws the contents away and fills it afresh, so a learner can always get back to a clean start. Everything is plain text, so a workshop lives happily in git.
A page is MyST Markdown, and the actions are fenced blocks with the action name in braces. This is a complete page, taken from the documentation:
---
title: Your first commit
requires: [verify:first-commit]
---
Record the commit with a message describing the change.
```{execute}
git commit -m "Add README"
```
```{verify}
:id: first-commit
:label: You have made a commit
:trigger: terminal-output "Add README"
import subprocess
out = subprocess.run(["git", "log", "--oneline"], capture_output=True, text=True).stdout
assert out.strip(), "No commits yet: run git commit"
```
The execute block runs the command in a workshop terminal when clicked. The verify block runs its Python in the checking kernel, is triggered on its own when the terminal output contains the commit message, and the requires line in the front matter asks for it to pass before the learner can move on. That is about as much of the format as I want to show here. Writing a workshop from nothing is the subject of the next post.
Where it runs
Anywhere JupyterLab runs, which is the point. On your own machine it installs into a virtual environment alongside JupyterLab with uv add jupyterlab jupyterlab-workshop, or the pip equivalent. If you only want to do workshops rather than write them, uv tool install "jupyterlab-workshop[lab]" gives you a jupyter-workshop launch command that starts JupyterLab with the extension and presents the workshops it found for the learner to choose from, with nothing else to set up.
Running on your own machine also opens up a use that is not teaching at all. A workshop makes a good setup wizard. For software with a fiddly install, a project could provide a workshop that walks through it with clickable actions instead of a page of instructions to copy from, checking after each step that it worked. Since a page can run a command in the background, capture its output into a variable, and show or hide what follows on that value, the instructions can adapt to the machine they are running on, finding out which shell, package manager or Python is present and showing only the steps that apply.
For workshops other people will do, the repository holding them can carry a Binder configuration, and mybinder.org will build it into a temporary JupyterLab in the browser for anyone who clicks the link, with no account and no cost to anyone. That is how the wrapture workshops are hosted and I have no server, container image or cluster of my own behind them.
Since the wrapture posts went out, the same repositories have also gained a devcontainer, so they can be opened in GitHub Codespaces. That needs a GitHub account and uses the account's monthly Codespaces allowance, but where a Binder session is thrown away when it ends, a codespace is yours and persists, so a workshop can be finished across several sittings. Workshops can equally be shipped in a JupyterHub image, or built into a JupyterLite site, which is JupyterLab compiled to run entirely in the browser with a Python kernel in WebAssembly, so a workshop becomes a set of static files on GitHub Pages with no server at all. The extension runs there unchanged, doing in the browser what its server side would otherwise do. Those options deserve a post of their own and will get one.
Why not Educates
I have worked on Educates for years, and it remains the platform I would reach for when a workshop needs a Kubernetes cluster behind it, with several services, a database with data in it, or an environment already broken for the learner to diagnose. The wrapture workshops needed nothing like that. They needed a terminal, an editor and a Python virtual environment, and JupyterLab already provides all three.
The honest observation, which I will expand on in a later post about the challenges of getting Educates adopted, is that it requires Kubernetes and that has always limited who could pick it up. Large organisations either build their own platform or pay a vendor so there is someone to hold to a contract. Small teams and individuals are not going to take on running a cluster for the sake of delivering training. Turning Educates into a hosted service that people pay for would have meant starting a company, which is not something I wanted to do.
The idea of delivering the same guided experience as an extension to JupyterLab or VS Code is one I had many years ago and shelved. When I floated it with others it was generally dismissed, and getting the Jupyter community to engage on anything to do with training tooling has not been easy, so it stayed shelved. What AI has allowed me to do is finally loop back and build it, since bringing an idea like this to life is no longer the amount of effort it once was. That made it worth doing just to see whether it was possible, and if nobody else is interested, I have something I can use myself. Starting fresh also gave me the chance to explore new ideas in this space, which is not easy to do within the constraints of Educates as it stands.
The two are complementary rather than one replacing the other, and I suspect they appeal to different people. Educates suits an organisation with a training function and a cluster to run it on. A workshop that is a directory of text files, runs wherever JupyterLab runs, and can be hosted for free on mybinder or in the learner's own codespace, is something the maintainer of an open source project could provide for their own project without ever thinking about hosting. The same maintainer could use it for the guided install described above. The wrapture workshops are the worked example. One person, one library, 24 workshops, no infrastructure.
Try it
The quickest way to see it is the showcase collection, three short workshops which show what the extension does and why, in a full JupyterLab with a real terminal. Launch it on Binder or in Codespaces. The showcase repository is also the pattern to copy for publishing a collection of your own. If you would rather not wait for a build, there is a JupyterLite demo of one workshop running entirely in the browser, started afresh on every visit.
For something more substantial, the wrapture workshops are on Binder and Codespaces as well. The getting started page covers a local install and scaffolds a workshop of your own, and the tutorial writes a small one from nothing and publishes it.
As with wrapture, the extension was developed with the help of AI coding assistants, working to my design and direction, with me reviewing what they produced. The wrapture workshops themselves were largely written by an AI agent using the extension's own authoring tooling, which is a story for a later post too. If you would rather not use software produced that way, that is understood.
What's next
There are a few posts to follow. One on writing a workshop from scratch, one on the ways of getting workshops in front of people without running a server, one on writing them with an AI agent, and one on the challenges I ran into trying to get Educates adopted over the years and what I took from them, which were in part the catalyst for this extension existing at all. The problem of getting anyone to do a workshop once it exists is one I wrote about after PyCon and have no new answer to. If anything it may be getting harder, since I now lean on AI both to build the software and to write the workshops, and for many people that alone is reason enough to stay away. What I can do is make it as easy as possible to try, and that part is done. If you do try it, the issue tracker is where I would like to hear what worked and what didn't.
September 18, 2026 12:00 AM UTC
September 17, 2026
Python Software Foundation
Announcing the 2026 PSF Board Election Results!
The 2026 election for the PSF Board created an opportunity for conversations about the PSF's work to serve the global Python community. We appreciate community members' perspectives, passion, and engagement in the election process this year.
We want to send a big thanks to everyone who ran and was willing to serve on the PSF Board. Even if you were not elected, we appreciate all the time and effort you put into thinking about how to improve the PSF and represent the parts of the community you participate in. We hope that you will continue to think about these issues, share your ideas, and join a PSF Work Group or PSF initiative if you feel called to do so.
Board Members Elect
Congratulations to our three new and one returning Board members who have been elected!- Elaine Wong
- Laís Carvalho
- Ee Durbin
- Georgi Ker
We’ll be in touch with all the elected candidates shortly to schedule onboarding. Newly elected PSF Board members are provided orientation for their service and will be joining the upcoming board meeting in October.
Thank you!
We’d like to take this opportunity to thank our outgoing board members. Cheuk Ting Ho has been a super engaged PSF Board member, participating in many committees, and helping out on many PSF Programs and projects during her time on the PSF Board. Chris Neugebauer has been a longtime board member and in particular has been the watch guard of our bylaws conversations and has always been ready to share institutional knowledge. Denny Perez has been instrumental on the PSF Board, serving on the Executive Committee, as Treasurer, and on various committees during her tenure. All three of you helped shape the PSF’s Strategic Plan for the next 5 years, which was a massive undertaking. Thank you, Cheuk, Chris, and Denny for your leadership and dedication to the PSF and the Python community. You will be missed and are deeply appreciated!
Our heartfelt thanks go out to each of you who took the time to review the candidates and submit your votes. Your participation helps the PSF represent our community. We received 670 total ballots, easily reaching quorum–1/3 of affirmed voting members (1123). We’re especially grateful for your patience with continuing to navigate the additions to the elections processes with the inaugural Python Packaging Council election.
We also want to thank everyone who helped promote this year’s board election, especially Board Member KwonHan Bae, who took the initiative to cover this year’s election and worked with PSF Staff to conduct written interviews with candidates. This promotional effort was inspired by the work of Python Community News in 2023. We also want to highlight the PSF staff members and PSF Board members who put in tons of effort each year as we work to continually improve the PSF elections.
What’s next?
If you’re interested in the complete tally, make sure to check the Python Software Foundation Board of Directors Election 2026 Results page. These results will be available until November 10, 2026.
The PSF Election team will conduct a retrospective of this year’s election process to ensure we are improving year over year. We received valuable feedback about the process and tooling. We hope to be able to implement more changes for next year to ensure a smooth and accessible election process for everyone in our community. If you have feedback or comments about this year’s PSF Board election, we welcome you to join the discussion on discuss.python.org or email psf-elections@pyfound.org.
Finally, it might feel a little early to mention this, but we will have at least 3 seats open again next year. If you're interested in running or learning more, we encourage you to contact a current PSF Board member or two this year and ask them about their experience serving on the board.
September 17, 2026 08:48 AM UTC
Announcing the 2026 Python Packaging Council Election Results!
The inaugural Python Packaging Council (PPC) election has concluded. With 17 candidates and plenty of lively AMA discussions on DPO, we are pleased to see the level of engagement with this first PPC election.
We want to send a big thanks to everyone who ran and was willing to serve on the council. Even if you were not elected, we appreciate all the time and effort you put into thinking about how to improve Python’s packaging ecosystem and represent the parts of the community you participate in.
Council Members Elect
Congratulations to our five inaugural Python Packaging Council members who have been elected!
- Brett Cannon – for a 2 year term (Cohort A)
- Pradyun Gedam – for a 2 year term (Cohort A)
- Donald Stufft – for a 1 year term (Cohort B)
- Henry Schreiner – for a 1 year term (Cohort B)
- Ralf Gommers – for a 1 year term (Cohort B)
Our heartfelt thanks go out to each of you who took the time to review the candidates and submit your votes. We received 541 total ballots.
We also want to thank everyone who participated in the PEP 772 discussions to establish this council as well as those who helped with this year’s election–especially Barry Warsaw and Pradyun Gedam as community liaisons for this inaugural election. We also want to highlight the PSF staff members and PSF Board members who put in tons of effort each year as we work to continually improve our elections.
How do the terms/cohorts work?
This inaugural election fills all five seats on the PPC. The two candidates receiving the highest number of votes are designated Cohort A with a two year term, and the three candidates receiving the next highest number of votes are designated Cohort B with a one year term.
In future elections, each cohort will be elected for a full two-year term in alternating years, so that roughly half of the PPC turns over each cycle.
What’s next?
We are already in touch with all the elected candidates to schedule onboarding.
If you’re interested in the complete tally, make sure to check the Python Packaging Council Election 2026 Results page. These results will be available until November 10, 2026.
September 17, 2026 08:36 AM UTC
Bob Belderbos
Why Rust makes you import a trait to use its methods
An interesting challenge for a new student came up in our Rust cohort: they opened a file and wanted to read its contents from disk, but the compiler refused to let them call read_to_string.
This is what triggered a great discussion:
let mut file = File::open("data.txt")?;
let mut contents = String::new();
file.read_to_string(&mut contents)?; // Error! no method named `read_to_string`
The fix was one extra line at the top:
use std::io::Read;
let mut file = File::open("data.txt")?;
let mut contents = String::new();
file.read_to_string(&mut contents)?;
But the student, like most Pythonistas, was confused:
My thought is that if we are to use the file, it would be
std::io::File? The more confusing part is that we importReadbut it's not being used anywhere.
Exactly. If you're coming from Python, this feels backwards.
Why does a method only work if you import a trait?
What do you mean, "no method named read_to_string"? It's right there on the type.
Well, not quite.
read_to_string isn't an inherent method defined directly on File. It's a method provided by the Read trait, which File implements.
So Rust's method lookup needs to consider that trait.
That's what this does:
use std::io::Read;
You're not importing a function called read_to_string.
You're bringing the Read trait into scope so its methods can participate in method lookup.
This is a subtle distinction, and coming from Python, this confused me at first.
Why isn't the method simply on File?
A trait is roughly Rust's version of an interface: it describes behavior that different types can implement.
Read defines methods such as:
read(...)
read_to_string(...)
read_exact(...)
File implements Read.
But File isn't the only type that does. All of the following implement the same Read interface and therefore provide the same behavior:
File
TcpStream
Cursor<Vec<u8>>
Stdin
...
This is one of the powerful things about traits: the behavior is defined separately from the concrete type.
So when you write:
file.read_to_string(&mut contents)?;
Rust can conceptually follow this path:
file
↓
File
↓
implements Read
↓
Read provides read_to_string()
But the Read trait needs to be in scope for Rust to use that trait in method resolution.
So:
use std::io::Read;
is better understood as:
"Bring the
Readinterface into scope so Rust can consider its methods when resolving method calls."
Not:
"Import
Readbecause I'm directly going to useRead."
The Python contrast
This is one of those places where your Python mental model gets in the way.
In Python, if an object has a method, you call it:
with open("data.txt") as file:
contents = file.read()
You don't need to import some Readable interface before read() becomes available.
Python's rough equivalent to a Rust trait is a Protocol:
from typing import Protocol
class Readable(Protocol):
def read(self) -> str: ...
A type can satisfy that protocol simply by having the right method. You don't have to explicitly inherit from Readable to use the method:
def consume(source: Readable):
return source.read()
And importantly, you don't import Readable before you can call:
source.read()
The protocol describes the expected interface for type checking. Method lookup at runtime is still performed on the object.
Rust makes a different tradeoff.
A useful simplification is:
Modern Python
Data → Functions → Composition → Protocols
Rust
Data → Ownership → Traits → Composition
These aren't strict architectural layers, but they capture an important difference in the mental model.
In Python, you tend to think:
object → what methods does this object have?
In Rust, it's often more useful to think:
value → what type is it?
→ what traits does it implement?
→ which of those traits are in scope?
→ what methods does that make available?
That's why a seemingly unrelated import can affect whether a method call compiles.
Why does Rust do this?
Because traits are a central part of Rust's approach to composition and polymorphism.
Imagine read_to_string were defined separately on every type that can read:
File.read_to_string()
TcpStream.read_to_string()
Cursor.read_to_string()
Stdin.read_to_string()
...
You'd have duplicated interface definitions across types.
Instead, Rust defines the behavior once:
trait Read {
fn read_to_string(...);
}
and types implement it:
File → Read
TcpStream → Read
Cursor → Read
Stdin → Read
Now generic Rust code can work with anything that implements Read.
The trait is the abstraction. The concrete type provides the implementation.
The compiler even hints at this
That's also why your editor or compiler may tell you:
trait `Read` is implemented but not in scope
The type can do this, but the trait providing the method isn't visible to the compiler's method lookup.
A small curriculum tweak
We probably want to go back to our curriculum and change this comment:
// This WON'T work - you need to import the trait to use its methods
to:
// File implements the Read trait, but the trait isn't in scope,
// so Rust can't find read_to_string through method-call syntax.
That teaches the reason rather than just the rule.
And once you understand traits, the import stops looking so random.
Read isn't an unused import.
It's part of the method lookup.
September 17, 2026 12:00 AM UTC
September 16, 2026
Ned Batchelder
Silence is golden lightning talk
This is a lightning talk I did at PyCon US 2026: Silence is Golden. The overall message is to leave some quiet time so that reluctant speakers have a chance to participate. I start with a jokey disclaimer because I followed Simon Willison who gave an energetic, entertaining, and loud(!) speed-run through a year of progress in LLMs:
I do these talks and blog posts about how to better interact with people, but I hope they don’t come off as too preachy. I have to remind myself to keep these ideas top of mind. I am one of those people who speak easily that I describe in the lightning talk. I have to remember to hold back, to leave time and space for other people.
I had the idea for this lightning talk a few years ago at PyCon, while watching things going wrong. I was in a room with a few dozen people. The goal in the room was to hear from lots of people, but the leader of the discussion was a “speaks easily” kind of person, and I could see the dynamic of the room failing to make space for everyone.
I was really pleased to be able to extend the time-tested and well-known Pac-Man Rule from space to time. It made for an interesting hook, making the talk more interesting than just a scold.
It’s really hard to keep quiet. But it’s important sometimes.
September 16, 2026 11:02 PM UTC
PyCharm
We’ve all been there: you join a new project, and the first thing you ask for is the architecture diagram. You’re handed a diagram that looks great, but after a week of debugging, you realize it’s six months out of date. Service A hasn’t talked to Service B since the spring, and there’s a new message queue nobody bothered to document.
Figuring out how a complex system actually fits together is a classic engineering headache. You can try to figure it out manually (if you have faith in yourself and enough time to spare), or you can use static analysis to explore the codebase (which often fails to capture how services are actually wired together at runtime).
But there’s a third way: dynamic analysis. What if we could just watch the system run and draw the map based on what is actually happening?
Since the OpenTelemetry plugin is already collecting a wealth of runtime data – logs, metrics, and traces – we realized we had the perfect opportunity to auto-generate this architecture map for you. Here’s a look under the hood at the Service Map feature, built as part of a collaboration between the Rider Execution team and Software Engineering Research.
The magic ingredient: traces
If you’re familiar with observability, you know the “three pillars”: logs, metrics, and traces.
While logs tell you what happened and metrics tell you how much, traces show you the journey of a request through your system. Traces are made up of individual units of work called spans.
Because OpenTelemetry standardizes these spans (for instance, explicitly defining HTTP Client and HTTP Server spans), they are the ultimate cheat code for understanding system architecture. Relying on the OpenTelemetry standard means the plugin can visualize your system completely independently of your technology stack, as long as your app and libraries emit spans the way OTel expects.
Building a map from traces has one massive advantage: it’s the source of runtime truth. We aren’t guessing based on source code or outdated specs. We are looking at data generated by the live system.
How it works
So, how does this actually work inside your JetBrains IDE?
When you start your IDE with OpenTelemetry plugin enabled, the plugin starts a lightweight local OpenTelemetry backend that can process your application telemetry data.
When you hit Run in your IDE:
- Plugin provides standard OTel environment variables to the application, so it knows that data should be sent to the local backend.
- Your app (already configured to emit spans) starts sending telemetry data to our local backend.
- The backend asynchronously crunches these incoming spans, continuously building and updating an internal model of your architecture.
- When you click on the Service Map tab, the OpenTelemetry plugin fetches the latest structural model from the backend and renders the visual diagram.
The messy reality of telemetry data
If you look at an architecture diagram, it looks static and orderly. But the stream of telemetry data generating that diagram is anything but. Before we could write an algorithm to connect the dots, we had to solve a few hidden challenges:
Chaos in the wire
Spans arrive completely independently, and their order is never guaranteed. A parent span might finish and arrive after its child span has already been processed.
No finish line
A trace never explicitly says “I’m done.” At any given moment, we can never be 100% sure that a late-arriving span isn’t about to show up.
Untyped payloads
OpenTelemetry doesn’t provide a strictly typed version for each span type. Instead, each span carries a key-value map with attributes that describe the semantics of the operation. We had to deduce what kind of interaction they represent purely by inspecting their attributes.
The reconstruction algorithm
To handle this asynchronous, out-of-order data, we built the architecture reconstruction as a stream processing algorithm. Instead of waiting around for a complete trace – which, as we just established, is impossible to guarantee – we process every span the moment it arrives.
First we figure out what we’re looking at. We pull the basic metadata off the span, then inspect its semantic attributes to classify it: attributes such as http.request.method and http.response.status_code tell us it’s an HTTP call, while others point to a database query, a message queue interaction, and so on.
Next we ask which service emitted it. New service we haven’t seen? It goes on the map. Already there? We merge the new data in and update its statistics.
Then comes the interesting part: connecting the dots across service boundaries. A fully instrumented HTTP call has two sides: the calling service emits a CLIENT span, while the receiving service emits a SERVER span. The trace context travels with the request, so the downstream SERVER span is created as a child of the upstream CLIENT span.
So when an outgoing HTTP Client span shows up, we go looking for its child on the server side. When an incoming HTTP Server span shows up, we look for the parent that called it. If the partner span is already in our system, we draw (or update) the connection between the two services right away. If it isn’t, we park the span in memory and wait for its other half to arrive.
Other kinds of dependencies require slightly different rules. A database call is usually represented by a single CLIENT span, so we infer the database node directly from its semantic attributes. Messaging is more varied: producer and consumer operations may be connected through a parent-child relationship or through span links, depending on the messaging system and instrumentation. In every case, the backend processes spans as they arrive and incrementally enriches the map as more evidence becomes available
That last step is what lets the plugin build an accurate, real-time map, even when the network delivers everything late and out of order.
Service map showing cross-service http communication and db access.This way we can process and show you information about http requests, database requests and message queues.
Service map showing cross-service communication through message queue (rabbit) and db access.
Because the map is built from standard OpenTelemetry spans and the reconstruction algorithm relies on semantic conventions rather than framework-specific APIs, the feature is language- and vendor-agnostic. The same logic works across JVM, .NET, Python, Go, and other OpenTelemetry-instrumented applications, as long as their instrumentation emits the expected spans and propagates context correctly. This also means you can use the feature in the JetBrains IDE that best fits your stack, including IntelliJ IDEA, GoLand, PyCharm, WebStorm, and Rider.
See your own architecture
Want to see your own architecture mapped out in real-time? Expected one HTTP call or database query, but the diagram shows several? Finding that during development gives you time to fix it before release.
You can install the OpenTelemetry plugin right now and stop guessing how your services talk to each other.
September 16, 2026 04:41 PM UTC
Talk Python to Me
#563: Getting Started with Rust as Python Devs
Lint the entire CPython code base from scratch. It takes 0.3 seconds. Three blinks of an eye. <br/> <br/> That is ruff, and it is written in Rust. So are Pydantic, Polars, uv, and Granian. Rust shows up in Python three ways: tools that happen to be Rust, libraries Python imports, and servers that run Python inside Rust. This is Rust for Python developers, not Rust experts. <br/> <br/> Christopher Trudeau is back on Talk Python to discuss Rust and his latest course Up and Running with Rust. The core rule is that only one thing can own a value at a time. Pass it around freely in Python and the garbage collector cleans up. Do that in Rust and it will not compile.<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>Up and Running with Rust course</strong>: <a href="https://training.talkpython.fm/courses/up-and-running-with-rust" target="_blank" >training.talkpython.fm</a><br/> <br/> <strong>Rust</strong>: <a href="https://rust-lang.org?featured_on=talkpython" target="_blank" >rust-lang.org</a><br/> <strong>pydantic</strong>: <a href="https://pydantic.dev?featured_on=talkpython" target="_blank" >pydantic.dev</a><br/> <strong>ruff</strong>: <a href="https://docs.astral.sh/ruff/?featured_on=talkpython" target="_blank" >docs.astral.sh</a><br/> <strong>granian</strong>: <a href="https://github.com/emmett-framework/granian?featured_on=talkpython" target="_blank" >github.com</a><br/> <strong>By example</strong>: <a href="https://doc.rust-lang.org/stable/rust-by-example/?featured_on=talkpython" target="_blank" >doc.rust-lang.org</a><br/> <strong>rust-lang.org</strong>: <a href="https://rust-lang.org/tools/install/?featured_on=talkpython" target="_blank" >rust-lang.org</a><br/> <strong>rustup.rs</strong>: <a href="https://rustup.rs?featured_on=talkpython" target="_blank" >rustup.rs</a><br/> <strong>crates.io</strong>: <a href="https://crates.io?featured_on=talkpython" target="_blank" >crates.io</a><br/> <strong>main.rs</strong>: <a href="https://main.rs?featured_on=talkpython" target="_blank" >main.rs</a><br/> <strong>PyO3</strong>: <a href="https://github.com/pyo3/pyo3?featured_on=talkpython" target="_blank" >github.com</a><br/> <strong>https://github.com/ritwiktiwari/awesome-python-rs</strong>: <a href="https://github.com/ritwiktiwari/awesome-python-rs?featured_on=talkpython" target="_blank" >github.com</a><br/> <strong>ty</strong>: <a href="https://docs.astral.sh/ty/?featured_on=talkpython" target="_blank" >docs.astral.sh</a><br/> <strong>pyrefly</strong>: <a href="https://pyrefly.org?featured_on=talkpython" target="_blank" >pyrefly.org</a><br/> <strong>uv</strong>: <a href="https://github.com/astral-sh/uv?featured_on=talkpython" target="_blank" >github.com</a><br/> <strong>polars</strong>: <a href="https://pola.rs?featured_on=talkpython" target="_blank" >pola.rs</a><br/> <br/> <strong>Watch this episode on YouTube</strong>: <a href="https://www.youtube.com/watch?v=kmuoTQcbsnQ" target="_blank" >youtube.com</a><br/> <strong>Episode #563 deep-dive</strong>: <a href="https://talkpython.fm/episodes/show/563/getting-started-with-rust-as-python-devs#takeaways-anchor" target="_blank" >talkpython.fm/563</a><br/> <strong>Episode transcripts</strong>: <a href="https://talkpython.fm/episodes/transcript/563/getting-started-with-rust-as-python-devs" 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 16, 2026 04:07 PM UTC
Django Weblog
Executive Director Search Extended to September 22
We are extending the search for the Django Software Foundation's first Executive Director. Applications now close at the end of Tuesday, September 22, 2026, anywhere on Earth (AoE). As long as it is still September 22 somewhere, your application counts.
We are happy with the applications we received. We also knew several people had applications in progress, and it took a while for word about the role to reach everyone it should have. Giving people a little more time felt like the fairest option. If you were most of the way there, you have until Tuesday.
If you have already applied, this does not slow anything down for you. We are reviewing applications as they come in, and we will reach out to candidates and schedule interviews on the same timeline we planned.
The Executive Director will play a central role in helping the DSF grow its capacity and build a sustainable future for the Django project and its community. That includes leading fundraising and partnerships, supporting the Foundation's operations and programs, and working closely with the board, staff, volunteers, and the wider Django community.
We are looking for an experienced nonprofit leader who is comfortable taking on a broad role and building things as they go. Fundraising is central to this job. We need someone who is at ease sitting down with companies, making the case for Django, and connecting what the DSF does to what those organizations care about. Much of the Foundation's future depends on growing those relationships and turning them into steady support.
You don't need to be a Django or Python expert, and you don't need prior involvement in the Django community. Experience with open source or other community-driven organizations is welcome, but we are primarily looking for someone with strong fundraising instincts, plus leadership, communication, relationship-building, and organizational skills.
Could that person be you?
If you have been waiting for the right opportunity to step forward, we encourage you to review the role and submit your application.
And if you know someone who would be a strong fit, please share this with them. A great candidate may be just one introduction away.
See the full job description and application details.
If you are ready to help shape the next chapter of the Django Software Foundation, we want to hear from you. Applications close on September 22, 2026, anywhere on Earth.
September 16, 2026 12:50 PM UTC
PyCharm
Behind the Scenes: How the OpenTelemetry Plugin Maps Your Microservices in Real-Time
September 16, 2026 12:34 PM UTC
Speed Matters
fastlogging-rs
fastlogging-rs: High-Performance Logging for many different Programming Languages
Logging is often the hidden bottleneck in your application.
Every log.info(...) call can block your hot path, serialize your threads, and slow down your I/O-bound workloads.
That’s why I created fastlogging-rs: a Rust-powered logging framework that is extremely fast, thread-safe,
and available with a similar API in 8 different programming languages.
My first release, 0.8.1, is available with the following features:
- Initial release of the Rust core (
fastloggingcrate) with bindings for Python, C, C++, Go, Java (FFM and JNI) and C# - Non-blocking, asynchronous logging — writers run in background threads
- Optional file rotation and compression
- Optional AES encryption for network logging
- Configuration via API or configuration file (JSON, XML, YAML)
- Automatic forwarding of log messages from sub processes to the main process
Why fastlogging-rs?
Because speed matters…
🚀 Significant Performance Improvements
Compared to Python’s built-in logging module:
- Writing to a file is up to 147× faster
- Rotating file logging is up to 207× faster
- Even compared to Apache Log4j, fastlogging-rs is up to 9× faster
When your application logs millions of messages, these speedups can turn minutes into seconds.
Benchmarks results for writing to a file
🌍 One Framework, 8 Languages
fastlogging-rs is written in Rust and comes with thin wrappers for your favorite programming language. All bindings share a similar API,
so you can use the same logging concepts across your whole stack:
| Language | Binding | Layer |
|---|---|---|
| Rust | fastlogging |
Native core |
| Python | pyfastlogging |
pyo3 (>= 3.10) |
| C | cfastlogging |
FFI (cbindgen header) |
| C++ | cxxfastlogging |
type-safe cxx bridge |
| C++ | cppfastlogging |
C++17 RAII over the C ABI |
| Go | gofastlogging |
cgo wrapper |
| Java | jfastlogging-ffm |
Foreign Function & Memory API |
| Java | jfastlogging-jni |
Java Native Interface |
| C# | csharpfastlogging |
P/Invoke |
⚡ Non-Blocking Architecture
Logging calls are non-blocking: each call performs a cheap level check (a single integer comparison, no lock)
and hands the message to a channel. A background LoggingThread drains that channel and dispatches to each
writer’s own thread. The speed of your writers never slows down your application — as long as the queue
doesn’t run full.
🔧 Rich Feature Set
- Thread-safe logging calls
- Multiple writers (sinks) per logger: console, file, network, syslog, callback
- Optional file rotation and compression
- Optional AES encryption for network logging
- Configuration via API or configuration file (JSON, XML, YAML)
- Automatic forwarding of log messages from sub processes to the main process
Installation
Rust
cargo add fastlogging
Python
pip install pyfastlogging
Usage Examples
Rust
use fastlogging::{logging_new_default, LoggingError};
fn main() -> Result<(), LoggingError> {
let mut log = logging_new_default()?;
log.info("Hello, fastlogging!")?;
log.shutdown(false)?;
Ok(())
}
Python
from fastlogging import Logging
log = Logging()
log.info("Hello, fastlogging!")
log.shutdown(False)
Python with a colored console writer
from pyfastlogging import TRACE, Logging, ConsoleWriterConfig
logger = Logging(
TRACE,
"main",
[ConsoleWriterConfig(TRACE, True)],
)
logger.trace("Trace Message")
logger.debug("Debug Message")
logger.info("Info Message")
logger.shutdown()
Benchmark Results
Writing to a file
| Framework | Time |
|---|---|
| Python logging | 29.37s |
| log4j | 1.48s |
| fastlogging-rs | 0.2s |
Rotating file logging
| Framework | Time |
|---|---|
| Python logging | 35.24s |
| log4j | 1.56s |
| fastlogging-rs | 0.17s |
For detailed benchmark data and methodology, see the benchmark documentation:
https://github.com/brmmm3/fastlogging-rs/blob/master/docs/benchmarks/index.html
You can also explore the full benchmark results with interactive charts and tables:
https://brmmm3.github.io/fastlogging-rs/
Get Started
If your application spends time logging, fastlogging-rs can provide substantial performance improvements with minimal code changes — in whichever language you happen to be writing.
The API is intentionally familiar, making migration from logging, log4j, or your current framework
straightforward while unlocking significantly faster execution.
Source code, documentation, and issue tracker:
https://github.com/brmmm3/fastlogging-rs
Licensed under the MIT or Apache-2.0 License.
September 16, 2026 07:40 AM UTC
September 15, 2026
PyCoder’s Weekly
Issue #752: Dict Performance, Hypothesis, Lazy Imports, and More (2026-09-15)
#752 – SEPTEMBER 15, 2026
View in Browser »
Sets and Dictionaries Can Have Quadratic-Time Performance
A rough first approximation is that a dict has O(1) performance, but that only holds true for smaller containers. This article explores the performance limits of sets and dictionaries.
DANIEL LEMIRE
Stop Writing Edge Case Tests. Use Hypothesis Instead
Introduction to property-based testing in Python with Hypothesis. Move from ‘what input should I test?’ to ‘what invariant should always hold?’
PEYTON GREEN • Shared by Anonymous
Tired of Getting Blocked While Scraping the Web?
ScrapingBee handles proxies, browsers, anti-bot systems, and retries so you can focus on your data. Get clean Markdown, JSON, or HTML from the web with up to a 99,9% success rate. ScrapingBee is SOC 2 Type II and GDPR compliant, and trusted by 4,000+ developers. Try ScrapingBee With 1,000 Free Credits
SCRAPINGBEE sponsor
Python 3.15 Preview: Lazy Imports
Learn how Python 3.15 lazy imports work, how deferring heavy modules cuts your app’s startup time, and which imports still have to stay eager.
REAL PYTHON
Articles & Tutorials
Nifty Django Feature: Q() Objects
Django’s ORM includes the filter() method for returning a subset of rows in the database. Anything you can do with filter() you can do with a Q() object, which encapsulates the filter’s arguments. Since it is an object you can dynamically create and manage filters in your code.
TIM SCHILLING
Profile on Guido van Rossum
The BBN Times has done a profile piece on Python’s creator Guido van Rossum. It covers his background, the creation of Python, and how he helped shepherd the language to its current state.
FELIX YIM
Making a Python Interpreter in 1024 Bytes
Austin challenged himself to make a tiny subset of Python in C. It isn’t quite Python, but bares a resemblance and with a little code golf he built something quite small.
AUSTIN Z. HENLEY
Reading __dict__ Once Deoptimizes Attribute Access
The usual explanation for why hoisting attributes out of a loop is faster has been wrong since CPython 3.11. Read about what more recent interpreters do.
TIMOFEI IVANKOV • Shared by Timofei Ivankov
An Effective Python Development Environment
Choose a Python development environment that helps you get coding. Find tutorials and courses on editors, uv, virtual environments, and useful tools.
REAL PYTHON
How Hard Is It to Find a Remote Python Data Job?
Piotr analyzed 88,975 Hacker News job posts from 2012 to 2026. The board shrank, remote work peaked, pay became clearer, and senior roles took over.
PIOTR PŁOŃSKI
Python Timer Functions
Learn how to time your Python code with the time module, then build a reusable Timer class that works as a context manager.
REAL PYTHON course
Prototyping a New CLI for Wagtail
Wagtail 8 introduced a new API which has allowed devs to create a command line tool for interacting with a Wagtail CMS.
THIBAUD COLAS
Teaching NumPy’s ufuncs New Tricks
Iason recently did an internship working on NumPy internals. This post talks about what he accomplished.
IASON KROMMYDAS
Projects & Code
Plotext 6: Plot Data, Images and Video in the Terminal
GITHUB.COM/PICCOLOMO • Shared by Savino Piccolomo
dbmask: Discover & Mask Sensitive Data in Databases
GITHUB.COM/SEALANDSEACAT • Shared by Siyuan Feng
Events
Weekly Real Python Office Hours Q&A (Virtual)
September 16, 2026
REALPYTHON.COM
PyCon Cameroon 2026
September 17 to September 20, 2026
PYTHONCAMEROON.ORG
PyData Bristol Meetup
September 17, 2026
MEETUP.COM
Python Leiden User Group
September 17, 2026
PYTHONLEIDEN.NL
PyLadies Dublin
September 17, 2026
PYLADIES.COM
Happy Pythoning!
This was PyCoder’s Weekly Issue #752.
View in Browser »
[ 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 15, 2026 07:30 PM UTC
Python Bytes
#496 A lake house in Seattle
<strong>Topics covered in this episode:</strong><br> <ul> <li><strong><a href="https://eddie.codes/posts/pandas-should-go-extinct/?featured_on=pythonbytes">Pandas Should Go Extinct</a></strong></li> <li><strong><a href="https://github.com/tylerh111/pydantic-pint?featured_on=pythonbytes">Pydantic-pint puts real-world units in your Pydantic models</a></strong></li> <li><strong><a href="https://belderbos.dev/blog/how-libraries-run-rust-inside-python/?featured_on=pythonbytes">How Libraries Run Rust Inside Python (With PyO3)</a></strong></li> <li><strong><a href="https://aws.amazon.com/blogs/big-data/aws-and-ducklabs-building-the-future-of-analytics-together/?featured_on=pythonbytes">AWS acquires DuckLabs</a></strong></li> <li><strong>Extras</strong></li> <li><strong>Joke</strong></li> </ul><a href='https://www.youtube.com/watch?v=uK_gohaLkbM' style='font-weight: bold;'data-umami-event="Livestream-Past" data-umami-event-episode="496">Watch on YouTube</a><br> <p>Sponsored by <strong>Logfire from Pydantic</strong>: <a href="https://pythonbytes.fm/logfire">pythonbytes.fm/logfire</a></p> <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 <a href="https://pythonbytes.fm/stream/live"><strong>pythonbytes.fm/live</strong></a> 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://eddie.codes/posts/pandas-should-go-extinct/?featured_on=pythonbytes">Pandas Should Go Extinct</a></strong></p> <ul> <li>Pandas' slowness pushes teams toward "Big Data" tools (Spark, Databricks) they don't actually need — most workloads never hit true Big Data scale</li> <li>Amazon Redshift telemetry: ~95% of tables are under 100GB, ~87% of queries touch 80GB or less — that's "Medium Data," not Big Data</li> <li>Polars and DuckDB fill that gap: single-machine, fast, no cluster required</li> <li>1 Billion Row Challenge benchmark: Pandas took 4m28s vs. Polars 5.04s and DuckDB 5.19s — DuckDB also used 19x less memory</li> <li>On a real-world NYC taxi dataset (3GB parquet), pure DuckDB ran 2x faster than pure Pandas while using a fraction of the RAM</li> <li>Bonus: Apache Arrow lets you pass data between Pandas/Polars/DuckDB with zero copying, so trying them out doesn't mean a full rewrite</li> </ul> <p><strong>Michael #2: <a href="https://github.com/tylerh111/pydantic-pint?featured_on=pythonbytes">Pydantic-pint puts real-world units in your Pydantic models</a></strong></p> <p>Pydantic-pint bridges Pydantic and Pint so models can validate physical quantities like 4m or 12 meters instead of bare floats. Fields annotated with PydanticPintQuantity parse user input, convert between compatible units, and serialize quantities back out as strings. That closes a real gap for anything consuming API payloads, config files, or sensor data with measurements, letting you enforce units at the validation boundary instead of hoping every caller remembered them.</p> <ul> <li>via PyCoder's Weekly newsletter</li> <li>Unit mix-ups have literally crashed spacecraft; now your Pydantic models can refuse them at the door.</li> <li>Annotate a field as Annotated[Quantity, PydanticPintQuantity('km')] and inputs like 12 meters arrive auto-converted to kilometers</li> <li>Validation covers string, numeric, and quantity inputs, and model_dump_json serializes quantities as readable unit strings</li> <li>Installable from PyPI as pydantic-pint, MIT licensed, with docs at <a href="http://pydantic-pint.readthedocs.io/?featured_on=pythonbytes">pydantic-pint.readthedocs.io</a></li> <li>Early-stage solo project at version 0.4, so API stability and maintenance are open questions worth discussing</li> </ul> <p><strong>Calvin #3: <a href="https://belderbos.dev/blog/how-libraries-run-rust-inside-python/?featured_on=pythonbytes">How Libraries Run Rust Inside Python (With PyO3)</a></strong></p> <ul> <li>Pydantic v2's validation core (pydantic-core) is Rust under the hood, built with PyO3 — this post shows how that bridge actually works via a small hand-built JSON parser</li> <li>Four steps to get Rust into Python: write a normal Rust module, annotate with PyO3 macros (#[pyfunction], #[pymodule]), compile/install with maturin, then just import it</li> <li>The parser builds a Rust tree first — Python never touches it until the boundary crossing</li> <li>Key insight: converting the Rust result into Python objects (.into_pyobject) is often the expensive part, not the parsing — 100,000 JSON values means ~100,000 Python objects built after parsing's already done</li> <li>Errors cross the boundary too: Rust's typed errors convert into real Python exceptions (ValueError, FileNotFoundError) via From/?, so callers get clean Python semantics</li> <li>Takeaway for anyone porting Rust in: if you're returning a scalar, don't sweat it; if you're returning a big structure, profile the boundary — that's the real cost, not the algorithm</li> </ul> <p><strong>Michael #4:</strong> <a href="https://aws.amazon.com/blogs/big-data/aws-and-ducklabs-building-the-future-of-analytics-together/?featured_on=pythonbytes">AWS acquires DuckLabs</a></p> <p>Thank you Dylan McConnell.</p> <p>What does this mean for the DuckDB ecosystem?</p> <p><strong>DuckDB</strong> is the open-source in-process analytical SQL engine. MIT licensed. The IP is not owned by any company - it's held by the nonprofit DuckDB Foundation, which was created when the team spun out of CWI Amsterdam. Peter Boncz, the CWI representative on the Foundation board, describes it as the entity that holds all IP of open-source DuckDB.</p> <p><strong>DuckLabs</strong> (<a href="http://ducklabs.com?featured_on=pythonbytes">ducklabs.com</a>) is the company, formerly branded DuckDB Labs. Founded a little over five years ago by Hannes Mühleisen and Mark Raasveldt to give the DuckDB team a stable long-term home, bootstrapped deliberately instead of taking VC, grown to 30+ people in Amsterdam, funded by support and feature-prioritization contracts. It employs the core devs. It does not own DuckDB.</p> <p><strong>DuckLake</strong> is one of three projects DuckLabs builds, what they call the Duck Stack: DuckDB, DuckLake, and Quack. DuckLake is the lakehouse format that puts catalog metadata in a SQL database instead of in files on object storage. Quack is newer - an RPC-style protocol that turns DuckDB into a client-server system where both ends are DuckDB instances, slated to stabilize in DuckDB v2.0 in September 2026.</p> <p><strong>MotherDuck</strong> is a separate Seattle company, Jordan Tigani's, selling serverless hosted DuckDB. It was started in partnership with DuckDB Labs and has worked closely with Hannes and Mark for four years. It contracted DuckLabs for engineering work and contributes heavily upstream - three of its engineers are among the top 10 outside contributors to DuckDB. It also sells its own DuckLake offering. Customer and collaborator, never owner.</p> <p><strong>What the AWS post changes.</strong> Amazon bought the company, not the project. DuckLabs joined AWS effective September 1, with the process concluding August 31, 2026. Hannes and Mark keep leading the team and the project's technical direction, the team stays in Amsterdam, and DuckDB stays MIT under the Foundation. AWS gets the people and a direct line to the roadmap. The license protects your code, not your priorities.</p> <p><strong>Three second-order effects worth tracking</strong>:</p> <p>The Foundation board is the real question. It has three directors: Mühleisen, Raasveldt, and Boncz. Two now work for AWS. Commentary on the deal has focused on exactly this - the license protects the code, not the roadmap. The announced counterweight is governance: a technical advisory board on the Foundation, and opening the extension stack so extensions signed by other developers can run in DuckDB.</p> <p>MotherDuck immediately moved into the business DuckLabs vacated. It now sells DuckDB enterprise support, which it had avoided because it didn't want to compete with DuckLabs' business model, and says it has explicit blessing from Hannes and Mark now that they're joining Amazon. It also bought Tower.dev the day before the AWS announcement.</p> <p>Everyone expects an AWS DuckDB service. Tigani says Amazon will likely release one eventually, and welcomes the competition, citing Redshift's failure to slow Snowflake on AWS. The groundwork is already visible: Amazon Quick uses DuckDB to query S3 Tables and has processed over 2.5B queries with it since launching in October 2025.</p> <p>The DuckLake angle is the one to watch. AWS is heavily committed to Iceberg through S3 Tables, and it just acquired the team behind a competing lakehouse format. The stated plan is to use DuckDB, DuckLake, and Quack together to power a new generation of data services, but which format wins internal priority is unannounced.</p> <p><strong>Extras</strong></p> <p>Calvin:</p> <ul> <li><strong>astral-sh/uv 0.12.12: code-signed release binaries</strong> 🥳</li> </ul> <p>Michael:</p> <ul> <li><a href="https://forums.macrumors.com/threads/apple-releases-firmware-update-for-140w-usb-c-power-adapter.2488672/?featured_on=pythonbytes">My MacBook power supply rebooted to install updates</a> (?!?)</li> <li><a href="https://www.youtube.com/watch?v=kHL3XzjpT5w">The Story of VS Code | Official Documentary</a></li> <li><a href="https://aws.amazon.com/blogs/big-data/aws-and-ducklabs-building-the-future-of-analytics-together/?featured_on=pythonbytes">Amazon/AWS acquires DuckLabs</a> (see recent episode on DuckLake)</li> </ul> <p><strong>Joke: <a href="https://x.com/PR0GRAMMERHUM0R/status/2090076348114985385?featured_on=pythonbytes">We’re agentic now</a></strong></p>
September 15, 2026 03:24 PM UTC
Django Weblog
DjangoCon Europe 2027 is heading to Innsbruck, Austria! 🏔️⛷️🚠🇦🇹
We’re delighted to announce that DjangoCon Europe 2027 will take place in Innsbruck, Austria, from February 17–21, 2027!

Photo by Nicole Baster on Unsplash
Each year, DjangoCon Europe brings together people from across the Django community to learn, share ideas, contribute, and spend time together. In 2027, that community will come together in Innsbruck for five days of Django, Python, and community.
DjangoCon Europe is organized by community volunteers and has long been one of the highlights of the Django community calendar. Developers, contributors, newcomers, and long-time community members from around the world come together to exchange knowledge, make new connections, and help shape the future of Django.
Save the dates
📅 February 17–21, 2027
📍 Innsbruck, Austria
And there’s even more good news: the Call for Proposals is open, and tickets are now on sale!
Submit a proposal
Have something you’d like to share with the Django community? The Call for Proposals is open.
Whether you have a deep technical topic, a lesson you’ve learned from building with Django, an idea that could benefit the community, or something completely unexpected, we’d love to hear from you.
Get your ticket
Ready to join us in Innsbruck?
Tickets for DjangoCon Europe 2027 are now on sale.
Come spend five days learning, sharing, meeting fellow Djangonauts, and enjoying everything the Django community has to offer.
Volunteer at DjangoCon Europe
DjangoCon Europe is a community-run conference, and volunteers play an important part in making it happen.
If you’d like to help us make DjangoCon Europe 2027 a great experience for everyone, sign up to volunteer. Whether you’re a long-time member of the community or attending your first DjangoCon, we’d love to have you involved.
Sponsor DjangoCon Europe
Support the conference financially and gain visibility in the Django community.
Learn more about sponsorship →
Download the sponsorship brochure →
There’s plenty more to come as we get closer to the conference. Keep an eye on the DjangoCon Europe 2027 website for the latest news and updates:
Visit the DjangoCon Europe 2027 website →
See you in Innsbruck in 2027! 🇦🇹
September 15, 2026 01:20 PM UTC
Python Software Foundation
Announcing the PSF Strategic Plan 2026
In May, the Python Software Foundation (PSF) shared the high-level goals of our Strategic Plan. In June, we published the full draft and opened it for community feedback. Today we are sharing the outcome: at its July 8 meeting, the PSF Board adopted the PSF Strategic Plan 2026, covering 2026 to 2031.
Our new PSF Strategy page is the permanent home for the plan, with information about how the board sets priorities and how you can share feedback. We’ll publish annual review findings and future updates there so the community can follow our progress and any changes to the plan.
What the feedback changed
Feedback from PSF Staff and the Python community shaped real changes to the draft we published in June.
Staff feedback added a Security Baseline objective for all PSF projects and services and a Vulnerability Management objective in response to the rapid growth in security reports, and it sharpened wording across the organizational goals. Community feedback shaped the plan, too: translation and localization are now part of the accessibility work, companies that want to fund specific work get clearer pathways, and the ideas under financial sustainability now include growing the value of PSF membership.
The plan's "How This Plan Was Shaped" section documents the process, and we are grateful to everyone who took the time to share their perspectives.
The annual review
A five-year plan is only useful if it stays current. Alongside adoption, the board established an annual review of the strategic plan. Each year, the board will:
Assess progress against the goals
Evaluate whether priorities need to shift
Incorporate feedback from PSF staff and the community
Publish a summary of findings and any changes made
The plan is designed as a living document, and we will be sharing details on the first review cycle.
What happens next
Implementation of the Strategic Plan is the job of PSF staff, and it has already started. The 2026 Grants Program Funding Round announced in July is the first concrete step under the plan's grants reform direction, and awards will be announced later this month.
We also know the community is waiting for updated financial information. The PSF has engaged an external accounting firm to support this work, and we will share an update as soon as the numbers are ready for publication.
The plan now informs how the PSF allocates its budget and staff time, and there are several ways to stay involved. We welcome community feedback at strategy@python.org year-round, and the monthly PSF Board Office Hours on the PSF Discord are a good place to connect with the board and to ask questions about the plan. Input arriving now feeds the first annual review.
Thank you to everyone who read the draft, contributed feedback, joined the office hours, and talked with us at PyCon US. The PSF’s Strategic Plan is better for it.
Jannis Leidel, PSF Board Chair, on behalf of the PSF Board of Directors
September 15, 2026 01:15 PM UTC
LernerPython blog, from Reuven Lerner
Python += calls __add__ and rebinds to a new object
If you invoke +=, Python can use __add__.
MyClass implements __add__ (calling print for debugging):
m1 = MyClass(10)
m2 = MyClass(20)
m1 += m2 # prints "Now in MyClass.__add__"
m1 now refers to a new object, and its repr is:
MyClass instance, vars(self)={‘x’: 30}
The post Python += calls __add__ and rebinds to a new object appeared first on LernerPython.
September 15, 2026 06:00 AM UTC
Graham Dumpleton
Hands-on learning in the age of AI
At PyCon AU 2026 I gave a talk in the DevRel track titled "Hands-on learning in the age of AI", with the subtitle "Are developer workshops still relevant?". The video is now up on YouTube if you want to watch it. What follows is the main points I was trying to make, along with something the talk didn't cover.
That something is that a couple of weeks or so after giving the talk, I released 24 free workshops for wrapture which anyone can run in their browser. If you watch the talk you will notice I spent the last part of it explaining why that sort of workshop is the kind most under threat from AI. I don't think the two are in conflict in this case, but it takes some explaining, so I will get to it at the end.
For most of the time I have worked on mod_wsgi and wrapt, the bulk of my effort didn't go into writing code. It went into answering the same questions over and over on mailing lists, Stack Overflow and GitHub issues. Every one of those now gets answered in seconds by an AI, and a lot of the answers it gives are probably mine, since it has read everything I ever wrote. The loss of that contact with the people using what you build is a separate topic though, and not what the talk was about. I touched on it in Developer Advocacy in 2026.
Where AI does a better job
Before making any case for workshops, I wanted to be straight about the things AI simply does better than we do, because some of us are still putting effort into content where our time would be better spent elsewhere.
The tutorial that walks you through configuring something, the getting-started guide, the blog post that takes you through a setup step by step. I don't think we should be writing those any more. An AI will give you the version for the release you are actually running, on your operating system, in the context of your own project, at two in the morning when there is nobody around to ask. Reference documentation still needs to exist, as that is what the AI learned from, but the walkthrough where the reader is a passive observer is done.
The thing is, explaining something clearly was never the hard part. Think about the last tutorial you read that was genuinely well written. Did you come away able to do it, or just confident that you could? Unless you sat down and worked through it, probably the second. Getting someone to actually do the thing, keeping them going when it breaks, and knowing which bit will confuse them before it does, that was always the hard part. AI has got dramatically better at the explaining, but that was the half we had already worked out.
Why doing is different from reading
So what does the doing actually give you? I argued three things, and that all three get more valuable as AI gets better, not less.
The first is that being wrong has to cost something. In a chat window, being wrong costs nothing. You ask, you get an answer, you nod, you move on, and half the time you never find out you were wrong at all. In a live environment, wrong means something is broken, and when something is broken you have to work out why, which means understanding what you actually did. Nobody gets paid to know the right answer. They get paid to work out why the thing in front of them isn't working, and you can't practise that by reading.
The second is that the struggle is the part that works. Think about something you genuinely know well, as opposed to something you have read about. My guess is you learned it because something went wrong and you had to sort it out. Nobody remembers the explanation that made sense at the time, but everybody remembers the bug that cost them a day. This is where it gets awkward, because an AI assistant exists to remove exactly that friction. That is the right thing when you are trying to get work done and the wrong thing when you are trying to learn, so the better these tools get at their job, the worse they are at teaching you anything.
The third is that you can't ask about what you don't know exists. An AI answers the question you asked, but it has no way of telling you about the thing you never thought to ask. Ask how to deploy your web application and you will get a good answer. What you won't get is a warning that your application isn't thread safe, because you didn't ask, and you had no reason to think you needed to. You find out in production six months later. In a workshop somebody else picked the steps, so you end up in front of the problem you would never have gone looking for.
Where the work actually is
That is the case for a workshop, meaning something people do rather than read. The catch is that plenty of people won't finish it, and when they don't, it is usually not the tooling that lost them.
People give up on workshops for four reasons. One is the environment. Something didn't install, the versions don't match, and twenty minutes in they have had enough. The other three are all content. The steps are too big a jump, they are told what to type but never why, or there is no way to tell whether what they just did actually worked. The environment problem can be solved with tooling. The other three, no platform will fix for you.
Step size is the one you can't feel, because you already know the answer. Write a step that says "now put it behind nginx" and to you that is one thing. For the person doing the workshop it is four. Install it, write a config file, work out why every redirect is coming back as http instead of https, and discover that X-Forwarded-Proto exists, which nobody has ever told them about. That step looked reasonable when you wrote it and it looks reasonable now. The only way you find out is by watching somebody try it. It goes the other way too. If every step is trivial, people stop reading them, and when a step actually matters they have already learned not to pay attention.
How much to tell them is the second. Write "run this exact command" and they will run it, it will work, and they will learn nothing, because they were typing rather than thinking. Write "now configure the server for production" and you have lost everyone who doesn't already know what that means, which is everyone, or they wouldn't be doing your workshop. What works is in between. Give them the command, let it work, then ask them to change one thing and say what they think will happen. Start the server with four workers. Now set it to one worker, send two requests at the same time, and before you run it, what do you reckon happens? That question is the whole trick. If they are wrong they find out in about two seconds, and now they actually understand what a worker is, which no amount of me explaining would have achieved.
Verification is the third, and the one people underestimate. Picture someone on step three. They edit a config file and make a small typo. Everything still starts, nothing looks wrong, and they keep going. At step eight the login flow fails for no obvious reason. They didn't quit at step three when they made the mistake. They quit at step eight, when they can't work out what is wrong and have no way to go back and find it. The part that should bother you is that they don't conclude they made a typo. They conclude your workshop is broken. So after anything that could silently go wrong, have them verify it worked before they move on, and show them what the output should look like. It is not much work, and it is the difference between finishing and giving up.
All three of these are doing the same job. When a workshop is working there is a rhythm to it. Read a bit, do a bit, check it worked, move on. Everything above is in service of not breaking that rhythm.
Give them somewhere to work
The environment problem is the one where tooling does solve it. Everyone has been in the room where a third of the people still aren't running forty minutes in, and the ones who did get it working are bored. Worse than the lost time, you have no idea what state anyone is in, so everything I said about checking their work goes out the window.
The answer is to host it, so every person gets a browser tab with the environment already in it, the instructions beside the terminal rather than in a separate window, an editor and a console in the same place, and all of it running before they arrive. I work on Educates, which is open source, but Killercoda, Strigo and Instruqt do this too, and the point is the category rather than the product. What matters is that everyone starts from the same place, which is what makes the checking possible at all.
Once you are hosting it, something more interesting opens up. You can hand someone an environment that is already broken and make them work out why, which is the only practical way to teach diagnosis, since if they build it themselves they only ever meet their own mistakes. You can start them at step seven with the first six already done, so the time goes on the part worth teaching. You can give them three services and a database with realistic data in it. The setup friction was never just a tax on the workshop. It was a limit on what the workshop could be about.
Using AI to write it
Yes, I see the irony. I spent the first part of the talk saying AI is why nobody needs our written walkthroughs, and I use AI to help write workshops. I don't think those are in conflict, but it pays to be specific about where it helps.
It is useful for getting a first draft down when the alternative is staring at an empty file, for generating variations on an exercise so you can pick the one that works, and for the tedious parts such as formatting, boilerplate and checking whether the commands you wrote three years ago still work. There is a pattern to that list. None of it requires knowing anything about the person who will do the workshop. All of it requires knowing the subject, because if you don't, you can't judge whether what comes back is right.
Where it falls down is exactly the three content problems, and for the same reason each time. It can't feel step size because, like you, it already knows the answer. It defaults to telling you exactly what to type because that is what documentation looks like, and documentation is what it learned from. It won't put checks in because the material it learned from doesn't have checks in it either. Some of that will change, since these tools move quickly, but the underlying cause doesn't. It has never watched anybody get stuck.
The way I think about it is that correct and teachable are different axes. AI is optimising for correct, meaning the commands work and the explanation is accurate. Teachable means paced for a real person who doesn't already know the answer, and nothing in how these tools are built is aiming at that. So let it draft, you shape it, and then you test it on an actual person. That third step is the one everyone skips and the only one that catches the problem, because the whole issue with AI-written material is that it reads fine. You can't catch it by rereading. You catch it by watching somebody try.
Getting to the starting line
Everything so far assumes that someone is already sitting in front of the workshop, ready to go. You can build the best workshop in the world and none of it matters if nobody starts it, and I think getting people to start is getting harder.
Workshops mostly get used in six situations. A conference booth, a conference workshop session, online and on demand, training at a customer site, a one-on-one demo for someone evaluating a product, and running it locally on their own machine. Sort those by why the person is there. At one end are the sales demo and the customer training, where there is a business reason behind attending, and that motivation doesn't disappear because AI got good. At the other end are online self-serve and the local download, where the only thing bringing someone is their own curiosity. Curiosity is exactly what a chat window satisfies, faster and for nothing. So the pressure isn't spread evenly. It is concentrated at the voluntary end.
That end has a second problem, which is that if you are an individual rather than a company, you have no way of telling anyone the workshop exists. The blogs that used to carry this sort of thing don't get read. The forums people used to hang around in have emptied out. Social media is so full of AI slop that anything real gets lost in it. Unless you already have a YouTube following, there is no route from "I made this" to "somebody knows about it". If you work somewhere that can put money behind getting the word out, you can buy the reach an individual doesn't have. Which means these workshops don't stop existing. They end up belonging to whoever can pay to be found.
I don't know how that resolves, and this is what I have seen rather than anything I have measured, so I am not going to claim self-serve workshops are finished. But I think we have been asking the wrong question. It isn't "are workshops still relevant". It is "relevant to somebody arriving how".
So why release 24 more
Which brings me back to wrapture. As I described in Trying out wrapture, there are now 24 workshops which run in JupyterLab on mybinder. Free, self-serve, click a link and start. That is precisely the end of the spectrum I just said is in trouble.
The difference is that wrapture is barely a month old. It isn't in any model's training data. Ask an AI how to use it and you will get either an admission that it doesn't know, or something confidently made up. The only way to get useful help from an AI is to deliberately feed it the documentation first, which you can do, since ReadTheDocs makes the complete wrapture documentation available as a single PDF. Short of that, the workshops are competing with a chat window that can't actually answer the question. That window will close as the models catch up, but it is open today, and while it is open a workshop is still the best way to learn wrapture by doing rather than by reading.
The other difference is one I glossed over in the talk. The AI will need material like these workshops to learn from. Whatever it can tell people about wrapture next year will be drawn in part from these posts and workshops, so even on the pessimistic reading, the effort isn't wasted. The value just shows up somewhere other than in people doing the workshops. That is a strange thing to be building for, but it is where things are.
There is a third reason as well, which is that creating the workshops was a way of further exploring how effective AI can be at producing hands-on material. I said in the talk that it fails at step size, at setting problems rather than regurgitating the documentation, and at adding verification, because it has never watched anybody get stuck. The question I wanted to test is whether it can be guided well enough to get those three right anyway. Whether that means being explicit about how big each step should be, insisting on the change one thing and predict what happens pattern, and requiring a check after anything that can silently go wrong. The wrapture workshops are the result of trying that, and by my own argument I can't yet say how well it worked, since the only way to find out is to watch people do them.
The discovery problem, this time, got partly solved by luck. Simon Willison wrote about wrapture twice, the Python Bytes podcast covered it, and the stars on GitHub jumped. That is the reach I said an individual doesn't have, borrowed rather than bought. Without it the workshops would most likely have sat there unnoticed.
What remains is the problem I have no solution for. Even when people know a workshop exists, sitting down and working through one is now seen as a chore next to asking an AI or watching a YouTube video. People have become accustomed to getting the answer without the doing. You do what you can. If people don't want to make use of what you provide, there isn't much more you can do about it.
Four things to take away
Reading and doing are different things. That hasn't changed, and it is the part AI hasn't touched. Three of the four reasons people give up are about your content rather than your tooling, and no platform fixes those. AI will make your content correct but it won't make it teachable, and that is still you. And know which channel you are building for, because whether workshops are still relevant turns out to depend entirely on who is sitting down and why they are there.
September 15, 2026 12:00 AM UTC
September 14, 2026
LernerPython blog, from Reuven Lerner
Python in operator: How __contains__ speeds up membership tests
How does the “in” operator work in Python?
– If an object defines __contains__, then its (boolean) result is returned (coerced to bool).
– If not, then Python iterates over it with __iter__
Can you find and return a result faster than __iter__? Then define __contains__.
The post Python in operator: How __contains__ speeds up membership tests appeared first on LernerPython.
September 14, 2026 06:00 AM UTC
Wingware
Wing Python IDE Version 12.0.3 - September 14, 2026
Wing Python IDE version 12.0.3 has been released. This release adds support for Python 3.15, substantially improves source code analysis of modern type hints, and speeds up auto-completion in large Python environments, including when auto-imports are enabled. It also improves management of Claude Code sessions and Tasks tool queues, updates the bundled typeshed, fixes several terminal issues, and fixes a number of bugs. See the change log for details.
Wing 12 integrates the Claude Code AI coding agent directly into the IDE, with a new Claude Code tool, a Tasks tool for planning and reviewing AI agent work, and a set of MCP servers that give the agent access to Wing's source code analysis, unit testing, debugger, and code review features. See the list of Wing 12 features below for details.

Downloads
Wing 12 -- the full Python IDE, available as Wing Pro (for agentic development) or Wing Classic (for manual development) depending on your license, with a free 30-day trial of Wing Pro.
Wing 101 v. 12 -- a simplified free Python IDE for teaching beginning programmers.
Wing 11 and earlier versions are not affected by installation of Wing 12 and may be installed and used independently. However, project files for Wing 11 and earlier are converted when opened by Wing 12 and should be saved under a new name, since Wing 12 projects cannot be opened by older versions of Wing.
New in Wing 12
AI Coding Agent Integration with Claude Code
Wing 12 adds a Claude Code tool that integrates the Claude Code AI coding agent with the IDE. Set Up for Claude Code in the Project menu configures the active project for AI agent development.
A set of MCP (Model Context Protocol) servers gives Claude Code access to Wing's source code analysis, testing, and debugger functionality, so the agent can more efficiently navigate and understand your code, write, run, and fix unit tests, and use the debugger to diagnose difficult runtime errors. In our benchmarks, giving Claude Code access to Wing's MCP servers made agent-driven coding tasks both faster and cheaper.
Tasks Tool
The new Tasks tool lets you plan, queue, execute, review, and audit the history of AI agent development tasks, making it easier to supervise and inspect the agent's work before committing it to revision control.
FIX Features and Write Tests
Wing 12 adds AI agent driven FIX features that hand the current debugger bug, failing unit tests, or code warnings to Claude Code for resolution. New Write Tests items in the Testing and editor context menus prompt the agent to write unit tests for selected code.
Code Actions
Wing 12 also adds AI Code Actions, accessed from the FIX icon in the editor toolbar, that operate on selected code or the enclosing scope. Built-in actions include explaining code, reviewing it for quality or security risks, fixing code warnings, optimizing for performance, and updating comments and docstrings. The action list is user-extensible, so you can add your own prompts for tasks you run often.
Pseudo-Terminal for OS Commands and Debug I/O
The OS Commands and Debug I/O tools now default to using a pseudo-terminal that implements full ANSI terminal emulation, so you can run and debug programs that use color output, cursor positioning, or full-screen TUIs.
Redesigned OS Commands Capability
The OS Commands tool has been replaced with configurable OS Commands in the Tools menu. Each OS Command acts like its own tool, for use in any tool or editor split.
Tools in Editor Splits and Reorganized Tools Menu
Tools can now also be added or dragged to editor splits, allowing for much more flexible workspace layout. The Tools menu has been reorganized into related groups, with less-used and legacy tools in an Other sub-menu, so more commonly used tools are easier to find.
Test Discovery and Preferences Search
Wing 12 adds automatic test file discovery and discovery of individual unit tests within files, so you usually don't need to specify test file patterns or add test files individually. The Preferences dialog now supports text search and back/forward navigation.
Other Minor Features and Improvements
Wing 12 also includes many other improvements, including:
- Support for Python 3.15
- IDE build for ARM64 Windows
- Improved and significantly faster source analysis
- Improved performance and responsiveness
- Improved remote agent installation and remote development
- Faster auto-completion in large Python environments
- Prompts for SSH passphrases and HTTPS credentials when needed during VCS operations
- Faster detection of externally modified files, with reduced CPU load
- Saving and restoring of tool console scrollback across project close and reopen
- Clickable OSC 8 hyperlinks in the OS Commands and Debug I/O tools
- A preference to select the ssh or plink.exe SSH implementation
- A notice on the next startup when Wing's previous session ended in an unexpected crash
Wing 12 also makes a number of other bug fixes and usability improvements.
Product Line Changes
Wing 12 simplifies the product line. The Commercial / Non-Commercial use distinction has been replaced by two feature-based product tiers:
- Wing Pro -- the full-featured Python IDE including AI agent development tools
- Wing Classic -- the complete traditional Python IDE for hands-on development, with no AI agent features
Anyone may purchase either tier for any purpose. Existing Commercial and Non-Commercial Use licenses both become Wing Pro. Customers who don't need the AI agent features may move to Wing Classic at renewal time, or any time sooner by contacting support@wingware.com.
Wing Personal has been discontinued. Existing Wing Personal users may continue to use Personal 11.x indefinitely, switch to free Wing 101, or purchase a Wing Classic license. See Pricing for details.
Changes and Incompatibilities
The single-LLM-query AI features originally introduced in Wing 11 (the AI Coder and AI Chat tools) are considered legacy in Wing 12 and hidden from the user interface by default. They remain available in projects that already use them and can be re-enabled with Project Properties > AI in Project Properties or in the Projects > AI preferences.
See Wing's Claude Code Agent Integration for Wing 12's AI agent approach.
If you have questions, please don't hesitate to contact us at support@wingware.com.
September 14, 2026 01:00 AM UTC
Armin Ronacher
Interpreting Pangram
Yesterday David Sacks wrote a tweet and within a few minutes people did, what they usually do, and they asked Pangram if it was AI. And Pangram said it’s entirely AI generated. To which David replied that these AI detectors are bogus.
Now Pangram has a pretty low false positive rate, but if you have ever used an LLM as a writing assitant, you will have probably noticed that it claims your posts 100% AI, even though you don’t feel like they are.
Pangram itself is a trained model, that attempts to detect segments of text as being definitely human, definitely AI and a mixture of the two. If you want to know how it works, they published a paper. The short summary is that they are manufacturing its own training data by starting from collections of known human authored text. An LLM is then tasked to understand the text and write a fresh new text on the same topic. They also let the LLM perform partial edits on that original human text and through that they can pick up on these co-authored details. Pangram claims their model to have rates of 0.0041% false AI accusations and 0.34% missed AI text.
So now that we know this I figured it might be fun to have an LLM re-create David’s tweet. I first came up with a prompt. And when I say I came up with that prompt I in fact used an LLM to propose to me from that tweet what I might want to say for the structure. I’m sure if you ask Pangram about if the above text is AI, it will probably say so, but that’s not really the point. The point is that I then used Opus 5 to generate a text which reads entirely AI generated.
If you are curious, this is the prompt I used:
Original prompt to generate a David Sacks like tweetPlease write an 8 paragraph tweet about “Pacing the Frontier”, not longer than 350 words.
Context of this tweet is this post by Dario: https://darioamodei.com/post/we-must-pace-the-frontier
And this tweet by Sam Altman: “I agree with Dario that we need to pace the frontier. This has been a primary topic of discussions we’ve had at OpenAI in recent weeks.
Committing to having independent evaluators with employee-like access is a great idea, and we will do the same. We’ll have more to share soon.”
Structure for the post:
- Open with a concession: Note that Dario (“We Must Pace the Frontier”) and Sam Altman both want to slow down at the frontier. Say “go ahead” which reads as a surprising response.
- Establish they own the frontier: Argue OpenAI and Anthropic hold a duopoly on frontier intelligence by all kind of metrics (market share, capability, revenue etc.) and that they themselves claim the lead is widening via RSI.
- Concede you can’t see their unreleased models, so if those are scary, being responsible is their call.
- Follow with a list of a rapid-fire paragraph (“stop pretending”) rejecting the asks from Dario’s post.
- Name the commercial self-interest (Slowing down isn’t purely altruistic)
- Close with the challenge that pacing buys room for a smarter regulatory debate than Bernie Sanders “shut it all down”. Say something about China and Open Weight models.
The text that Opus 5 then wrote, was this one:
Dario published “We Must Pace the Frontier.” Sam agreed, and said OpenAI will also commit to independent evaluators with employee-like access. My response is not the one they expect: go ahead. Please do.
Because you two are the frontier. OpenAI and Anthropic hold a duopoly on frontier intelligence by every metric anyone actually uses: capability, revenue, enterprise share, developer mindshare. And you both keep telling us the lead is widening, that models are starting to improve models. If that’s true, pacing costs you a quarter you can reclaim whenever you like.
I also can’t see your unreleased checkpoints. If what’s sitting behind the curtain genuinely scares the people who trained it, then slowing down is your call to make, and the right one. I’m not going to second-guess a risk assessment I have no access to.
But stop pretending the rest of us are the problem. Stop pretending this is a collective action failure in which a handful of startups are the defectors.
Stop pretending open weights are the dangerous frontier while the two of you ship the most capable model in the world every few months. Stop pretending third-party evals require new law, when you both just committed to them voluntarily, in public, on a blog.
And stop pretending it’s purely altruism. Pacing the frontier is also margin repair. It slows the price war, stabilizes the capex story, stretches depreciation schedules across a longer product cycle, and makes life considerably harder for fast followers who survive by distilling whatever you shipped last month. Safety and self-interest are pointing the same direction here. That’s worth saying out loud.
Still, the upside is real. Pacing buys room for a smarter regulatory debate than the one on offer, where the loudest alternative is Bernie Sanders’ “shut it all down.” That’s a slogan, not policy. Meanwhile China keeps shipping competitive open weights, and any rule that binds two American labs while the rest of the world downloads its capability for free isn’t safety, it’s theater with a very expensive ticket price.
And well, Pangram agrees that this is 100% AI. So far, so uninteresting. It does read somewhat like David’s tweet, but obviously not entirely. Given that the original prompt does not have enough information to re-create the tweet entirely you would expect some divergences.
The actual thing that interests me is if you can take this output at all, and then rewrite it from scratch, but by sticking to the general structure and ideas. Will Pangram give us a AI or human rating?
I read the generated text. Then I read each paragraph and decided to rewrite and rephrase it without an LLM. According to some similarity checkers, they the final texts are 50% similar which seems about right. But strictly speaking, not a single sentence is the same. Here is the 100% human rewritten text of the above one. No LLM was used to write it, but an LLM was used to fix up typos in the end. That from my experience really does nothing to tick off an LLM detector.
Dario has written “We Must Pace the Frontier,” and Sam from OpenAI has agreed. My response might surprise people: go ahead, please.
You two are the frontier! Your companies, OpenAI and Anthropic, are at the frontier by all metrics: revenue, developer mindshare, adoption, capabilities. And yet you both claim that your lead is widening as a result of recursive self-improvement as models are improving models. You currently are the duopoly of self-improving models!
I am unable to see what unreleased models you have. When what you have behind those doors really scares your folks, then you should slow down. I’m not going to tell you otherwise and I support you.
But please don’t pretend we are the problem. Stop pretending you need our permission. Stop pretending this is all a collective issue when in reality this is all on you. Stop pretending open weights are the problem here. Stop pretending pulling third-party evaluators in requires lawmaker involvement. And for the love of all the good things in the world: stop pretending this is all about altruism.
Pacing the frontier is also about your margins, and it makes it harder for fast followers. And it patches up your capex story and has the potential for slowing down the price war ahead of the IPOs.
But yes: pacing might give us the space for a better debate than Bernie Sanders’ “shut it all down.” There is no policy there. And while we’re having fights at home, China will keep shipping competitive open-weight models and won’t adhere to any American agreements.
This is all regulatory capture hiding behind a safety debate, and the rest of the world is watching.
So what does it say? Well this text too comes back as 100% slop. And it does not surprise me all that much. I have generally noticed that if you rely on an LLM to give your text structure, it will score badly on Pangram even if you do plenty of edits over it. In fact, it’s quite unlikely you’re going to get a post that starts out as slop into a structure that will make it appear that it’s not.
I came to quite appreciate the existance of Pangram because at the very least it has made me quite aware of some of the effects that using LLMs for writing blog posts has. This blog has been AI supported for about two years (as you can see from the AI transparency link on the bottom but I did notice that I became both more reliant on those tools and that they have become much more aggressive editors and it gave me pause.
Yet, I also think that plenty of people will find a “100% AI” rating misleading when in fact the author has done plenty of editing. But maybe it’s fair to have this to show up as entirely AI?
September 14, 2026 12:00 AM UTC
September 13, 2026
Bob Belderbos
How Libraries Run Rust Inside Python (with PyO3)
Every time you validate data with Pydantic v2, the data-validation library most Python apps reach for, a Rust extension does the work. Its core, pydantic-core, is built with PyO3, the same toolchain we'll use here.
This post builds that same kind of bridge, small enough to read in one sitting: a JSON parser written in Rust, exposed to Python, so you can import it like any other package. The last step, turning the Rust result into Python objects, is the one to understand before you port anything: for a parser like this, it can cost more than the parsing itself.
The four steps from Rust to import
Getting Rust code into Python takes four steps:
- Write a normal Rust module.
- Annotate it with PyO3 macros.
- Let maturin compile and install it.
- Import the result.

#[pyfunction] and #[pymodule] are the two Rust macros that do the wiring. A Rust attribute macro is close to a Python decorator: it rewrites the function it sits on, here adding the glue that lets Python call it and handles the type conversions and reference counting at the boundary.
Maturin then compiles the crate to a shared library (.so, .dylib, .dll) and drops it into your virtual environment, so import just works. I walk through this whole setup, from cargo new to the first import, in How to run Rust in Python with PyO3 and Maturin.
That first tutorial returns a single number. This one picks up where it left off, because the interesting part starts once you return a structure instead of a scalar.
The parser produces a Rust value first
The structure this parser returns is a JSON tree, and it's the running example for the rest of this post. In our Python to Rust cohort, students spend six weeks writing a JSON parser from scratch in Rust, a hand-rolled tokenizer and recursive-descent parser with no serde, then expose it to Python through PyO3.
The code I'll walk through here is my own implementation.
The parser produces a plain Rust enum. A Rust enum holds one of several shapes, and each variant can carry data, so it maps a JSON tree cleanly:
pub enum JsonValue {
Null,
Boolean(bool),
Number(f64),
String(String),
Array(Vec<JsonValue>),
Object(HashMap<String, JsonValue>),
}
That tree lives entirely in Rust. Python never sees it. The PyO3 layer is a thin adapter on top.
Exposing one function
Exposing a function to Python takes two lines:
#[pyfunction]
fn parse_json<'py>(py: Python<'py>, input: &str) -> PyResult<Bound<'py, PyAny>> {
parse(input)?.into_pyobject(py)
}
For a Python reader, the signature is the most interesting part:
py:Python<'py>is a token representing access to the Python interpreter and is what you pass to PyO3 APIs that need access to Python objects. On traditional Python builds, this access is associated with holding the GIL. PyO3 hands it to you and you pass it along wherever you touch a Python object.Bound<'py, PyAny>is a handle to a Python object of any type, the Rust side of what you'd think of as aPyObject.PyResult<T>isResult<T, PyErr>: return the value, or an error PyO3 raises as a Python exception.?propagates that error. If parse fails, the function returns early and Python sees an exception; otherwise it unwraps theJsonValueand moves on.
So parse(input)? does the real work, and .into_pyobject(py) builds the Python objects the caller asked for. That last call is where the cost lives: it has to create Python objects for the nodes in the tree, and on a large document that can add up to more work than the parse itself.
The return trip is the expensive part
Here is why that conversion is not free. .into_pyobject walks the entire JsonValue tree and rebuilds it as native Python objects: a dict per object, a list per array, a float or str per leaf. You provide that translation by implementing the IntoPyObject trait, which PyO3 calls to convert a Rust value into a Python one:
impl<'py> IntoPyObject<'py> for JsonValue {
fn into_pyobject(self, py: Python<'py>) -> Result<Self::Output, Self::Error> {
match self {
JsonValue::Null => Ok(py.None().into_bound(py)),
JsonValue::Number(n) => Ok(n.into_pyobject(py)?.to_owned().into_any()),
JsonValue::Object(obj) => {
let py_dict = PyDict::new(py);
for (k, v) in obj {
py_dict.set_item(k, v.into_pyobject(py)?)?; // recurses
}
Ok(py_dict.into_any())
}
// ...arrays, strings, booleans
}
}
}
A document with 100,000 values means on the order of 100,000 Python objects being created at the boundary, all after parsing is completely done. On a large document this materialization loop, not the parsing, can dominate the end-to-end time.
Errors cross the boundary the same way
The return value is not the only thing that has to translate. A parse failure is a typed Rust error, and Python wants an exception. One From impl, the trait Rust uses to convert one type into another, lets ? do the work:
impl From<JsonError> for PyErr {
fn from(err: JsonError) -> PyErr {
match err {
JsonError::UnterminatedString { position } => PyValueError::new_err(
format!("Unterminated string starting at position {position}")
),
// ...one arm per error variant, position preserved
}
}
}
Now malformed input raises a ValueError carrying the offset where parsing broke. The file-reading path gets the same treatment for free: std::io::Error already converts to the matching Python exception, so a missing path raises FileNotFoundError.
The caller gets Python semantics without the Rust layer leaking through.
What this means for your own port
If the Rust function you're porting returns a scalar, port it and move on. The boundary is usually small enough to ignore.
If it returns a large structure, the conversion is your real cost, and it is the next thing to optimize once the parser itself is fast. Preallocating the PyDict can help at the margins, but the bigger win is architectural: don't materialize the whole tree if the caller won't touch all of it. Hand back a lazy, Rust-backed view and build Python objects on demand.
So when you reach for PyO3, profile the boundary, not just the algorithm. Getting Rust to run fast is the easy half. What you build on the way out, the trip from Rust values to Python objects, is the half that decides whether the port was worth it.
September 13, 2026 12:00 AM UTC
Graham Dumpleton
Trying out wrapture
Since introducing wrapture at the end of August I have been putting out a post every day or so, first working through how it is used in unit tests and then how the same bindings trace a running application. With the last of the tracing posts now done, this is the point where I wanted to stop, take stock, and pull everything together in one place for anyone who wants to try it out.
Where things stand
The short version is that wrapture has been bumped to version 1.0.0b1. That move from alpha to beta is deliberate. I am happy with the APIs as they are and I am not seeing a need to change them, so what the beta series needs now is not more features from me but people using it on real code and reporting back. Reports of it working, or not working, on something I never thought to test, and of what confused you or what you found missing, are what will decide whether anything changes before a release candidate. They go to the issue tracker on GitHub.
One area where feedback would be especially useful is the OpenTelemetry export. The events wrapture records are mapped through to spans, attributes and metrics, and the intent was for that mapping to follow the OpenTelemetry semantic conventions. As I explained in the introductory post, the code was written by an AI under my direction rather than by me, so I cannot claim to have checked every attribute against the conventions myself, and can only say I hope the information is being mapped through okay. If you spend your days looking at traces in an OpenTelemetry backend and find that what arrives is not named or shaped the way the conventions say it should be, or the way your backend expects, I would like to hear about it while it is still cheap to change.
The companion instrumentation packages have been bumped to 1.0.0b1 at the same time. Until 1.0.0 is final a plain pip install wrapture picks up the latest pre-release automatically, so there is no need to pin a specific version to try it.
The links you need are:
- wrapture on GitHub, and the documentation on ReadTheDocs. The getting started page is the place to begin, and if you are coming from
unittest.mockthere is a comparison page mapping each mock idiom to its wrapture counterpart. - wrapture-instrumentation, the core collection of packaged instrumentation. It covers the web frameworks Flask, Django, FastAPI, Starlette and aiohttp, the servers uvicorn, werkzeug and
wsgiref, the HTTP clients requests, httpx, urllib3, aiohttp,urllib.requestandhttp.client, XML-RPC on both ends, gRPC, SQLAlchemy andsqlite3, and Jinja2 templates. - wrapture-instrumentation-aws, for the AWS SDK (boto3 and botocore), with every AWS API call recorded as one event.
- wrapture-instrumentation-postgresql, for the PostgreSQL drivers psycopg, psycopg2 and asyncpg.
- wrapture-instrumentation-mysql, for the MySQL drivers PyMySQL, mysqlclient and aiomysql.
The split between the core collection and the separate packages is that the core package only covers targets which can be exercised in-process, with no backend product or service needed to test against. Anything that needs a real server to test against, which the database and AWS packages do (their test suites run the real thing in a container), lives in a package of its own with its own release cadence. I also plan to have packages for Redis and MongoDB. Beyond that it will depend on what people are interested in seeing instrumentation for, with the only other thing I can see at the moment being the LangChain packages.
The posts so far
The posts are collected on the testing and tracing with wrapture guide page, which is the list that will keep growing, but for the record the reading order is as follows. First the starting point, on what wrapture is, why I built it, and how it was written:
Then the unit testing side, where the difference from unittest.mock is that the real code still runs and everything that flows through it is recorded:
- Unit testing with wrapture.
- Recording calls with wrapture.
- Phased behaviour in wrapture.
- Beyond callables in wrapture.
And the tracing side, where the same bindings observe a running application instead, from a live call tree in a terminal through to spans in an OpenTelemetry backend:
- Live tracing with wrapture.
- Zero-code tracing with wrapture.
- Tracing Flask with wrapture.
- Finding slow code with wrapture.
- OpenTelemetry export in wrapture.
Learning it by doing
Reading about a library only gets you so far, so alongside the posts there is now a wrapture-workshops repository on GitHub containing 24 workshops you can work through to learn wrapture in an interactive workshop format. Each takes one thing you might want to do with wrapture and walks you through doing it in a live JupyterLab session, with the instructions in a side panel whose actions drive the session and check your work as you go. The early workshops track the blog posts, one per post, and the later ones go into areas the posts have not covered yet, such as using wrapture with pytest properly, converting an existing mock based test suite, async code, patching third party libraries, distributed tracing across two processes, and writing an instrumentation package of your own.
You need nothing installed to try them. The workshops can be run in a hosted environment on mybinder.org, a free public service that builds the repository into a temporary JupyterLab running in your browser. Building takes a minute or two. A session is discarded when it ends, so finish a workshop in the session you started it in. If you would rather run them locally, the repository README has the steps for doing that under any JupyterLab.
Each workshop installs wrapture into a virtual environment of its own inside the workshop directory, the way a project would, so nothing is left behind in the JupyterLab environment. The workshops are pinned to a released version of wrapture, so the documentation may at times describe something newer than what a workshop uses.
Beaten to the punch
My intention was always to write this summary post once the testing and tracing posts were done, but Simon Willison got there first with Don't sleep on wrapture, which was his second post on wrapture after covering the initial announcement back in August. I am not complaining. His reach on social media is a great deal larger than mine, so a lot more people will have seen his summary than would have seen this one, and that has been reflected in the way the number of stars on the GitHub repository jumped after his post went out. The Python Bytes team also featured wrapture on episode 494 of their podcast, which helped as well. Thanks to all of them.
Either way, this post still serves a purpose, since it is the one place with all the links, the package list and the workshops together, and it will be the post I point people at when they ask where to start.
A side note on the workshops
I do hope people try the workshops on mybinder, and not only for what they teach about wrapture. I have worked on Educates for many years as a way of hosting online interactive workshops, and it remains the platform I would reach for when a workshop needs a full Kubernetes-backed environment. Being able to deliver workshops inside JupyterLab, with instructions in a side panel that drive the session and check what you have done, is something completely new which I only wrote in the past week, as a JupyterLab extension called jupyterlab-workshop. A workshop is nothing more than a directory with a manifest and some Markdown pages, and it runs wherever JupyterLab runs, so mybinder can host it with no container or cluster of my own behind it. I will do some followup posts on that in the coming week or so, since it deserves more than a paragraph at the end of a post about something else.
September 13, 2026 12:00 AM UTC
September 12, 2026
Mike C. Fletcher
Hardware Accelerated Video Capture for PyOpenGL
The pyopengl-video library is a small hack that lets you pass FrameBufferObjects (FBOs) directly to the local platform video encoding library (or allows you to use your existing video buffer as an FBO, depending on the platform). The purpose of the library is to allow an off-screen renderer to generate h264 in mp4 video previews of that off-screen rendering. I find that useful to allow an agent to capture e.g. gameplay demos or the like so that you can see what it is working on while you are not at your desk.
On Linux with nVidia uses NVENC. For linux with Intel or AMD uses vaapi. Windows uses D3D11 and COM. Requires PyOpenGL 4.0.0a5 or above. So far no OS-X solution.
September 12, 2026 01:05 AM UTC
Graham Dumpleton
OpenTelemetry export in wrapture
Everything in the last four posts rendered a trace for a person to read or wrote it to a file for later. The other destination is a tracing backend, fed while the application runs, and OpenTelemetry is the one that the ecosystem has converged on. wrapture treats it as a first-class destination rather than something you bolt on: the wrapture.otel subpackage ships in every wheel, and the otel extra brings the SDK and the OTLP exporter with it.
$ pip install "wrapture[otel]"
A plain install pays nothing for this, since nothing in base wrapture imports the subpackage until a config asks for it.
One table
Export is switched on by a top-level [otel] table in the same config file the Flask shop has been using. The presence of the table opts in, a service name identifies the process, and each signal's tuning nests beneath it. I shortened the metrics export interval so the demonstration would not have to wait a minute for a data point.
[otel]
service_name = "webshop"
[otel.metrics]
export_interval = 2
[[instrument]]
name = "flask"
ignore_paths = ["/health"]
[[observe]]
target = "shop:OrderService"
name = "place"
redact = ["card"]
[[observe]]
target = "shop:Gateway"
name = "charge"
redact = ["card"]
[[observe]]
target = "shop:Ledger"
name = "record"
Where the spans go is decided by the standard OpenTelemetry environment variables, so with a collector listening on the usual port nothing more is needed. For a look without a collector, the console exporters print the spans and metrics to standard output instead, which is what I used here:
$ OTEL_TRACES_EXPORTER=console OTEL_METRICS_EXPORTER=console \
python -m wrapture -m flask --app webshop run --port 5003
One event becomes one span. A request becomes a SERVER span, a call or a block becomes an INTERNAL span beneath it, and the tree the printer drew is the tree the backend receives. For the quote of an item that is not in the catalog, the view's span arrives with error status and the exception recorded on it (trimmed here to the parts that matter; the real output includes the stack trace and the resource attributes):
{
"name": "quote",
"context": {
"trace_id": "0x02f1fd262a7817d4f8b42fb0f6a30db3",
"span_id": "0x5b94b62db087bbc9"
},
"kind": "SpanKind.INTERNAL",
"parent_id": "0xfba0a55ab3a322f0",
"status": {
"status_code": "ERROR",
"description": "KeyError"
},
"attributes": {
"wrapture.path": "webshop:quote",
"wrapture.kind": "call",
"wrapture.arg.item": "missing"
},
"events": [
{
"name": "exception",
"attributes": {
"exception.type": "KeyError",
"exception.message": "'missing'",
"exception.escaped": "True"
}
}
]
}
And the request span it is parented under:
{
"name": "GET /quote/<item>",
"context": {
"trace_id": "0x02f1fd262a7817d4f8b42fb0f6a30db3",
"span_id": "0xfba0a55ab3a322f0"
},
"kind": "SpanKind.SERVER",
"parent_id": null,
"status": {
"status_code": "ERROR"
},
"attributes": {
"http.request.method": "GET",
"url.path": "/quote/missing",
"http.route": "/quote/<item>",
"http.response.status_code": 500,
"wrapture.data.endpoint": "quote",
"wrapture.data.remote": "127.0.0.1"
},
"events": [
{
"name": "exception",
"attributes": {
"exception.type": "KeyError",
"exception.message": "'missing'",
"exception.escaped": "False"
}
}
]
}
A few things in there are worth pointing at. The request span is named GET /quote/<item>, by the route pattern rather than the URL, because the Flask instrumentation annotates the request with its matched route once routing has run, and the exporter reads that as the semantic-convention http.route. A backend then groups by endpoint rather than seeing every URL as a distinct operation. The captured arguments and anything added with annotate() become span attributes under wrapture.arg.* and wrapture.data.*, with the card number already redacted before it got anywhere near the exporter. And the KeyError appears on both spans, once as the exception that escaped the view and once as the one noted against the request after Flask caught it, so the request span shows the 500, the error status and the reason together rather than a status with no explanation.
One trace across two processes
A trace within one process is only half of what a tracing backend is for. The trace-propagation example in the wrapture repository is two processes: a client that places orders against a quote service over HTTP, and the service itself, both observed by wrapture and both writing JSON Lines files. Every tree wrapture records carries a W3C trace id, minted at its root, and on the client side an instrumentation for urllib puts that id into the traceparent header of each outbound request. On the server side the WSGI middleware parses the header at the boundary, so that process's trees join the client's trace instead of minting their own.
The join needs no backend at all. Printing the first eight characters of the trace id from every line of both files, with the file it came from:
0b2ad016 server.jsonl backend:app
8f19b8c6 client.jsonl frontend:fetch_quote
8f19b8c6 client.jsonl frontend:fetch_quote
8f19b8c6 client.jsonl frontend:fetch_quote
8f19b8c6 client.jsonl frontend:place_order
8f19b8c6 client.jsonl urllib.request:OpenerDirector.open
8f19b8c6 server.jsonl backend:app
8f19b8c6 server.jsonl backend:quote
b1a416b0 client.jsonl frontend:fetch_quote
b1a416b0 client.jsonl frontend:fetch_quote
b1a416b0 client.jsonl frontend:place_order
b1a416b0 client.jsonl urllib.request:OpenerDirector.open
b1a416b0 server.jsonl backend:app
b1a416b0 server.jsonl backend:quote
...
Each order is one id across both files, client half and server half of one distributed trace. The repeated fetch_quote lines are the two blocks the client marks inside that function, which record under its path, and the lone server-only line at the top is a request that arrived with no traceparent header, which minted an id of its own at the boundary. The whole public surface the client instrumentation needed for this was wrapture.trace_headers(), which returns the pairs an outbound message made right now should carry, and is empty when nothing is being recorded, so injecting it is always safe.
Switching on [otel] in both processes changes nothing about the ids. The exporter claims the identity wrapture minted rather than minting one of its own, so the JSON Lines files, the outbound headers and the exported spans all read the same trace id, and the server's request span is created with the arrived identity as a remote parent. In the console output from the same run, the server's GET /quote/widget span carries the client's trace id and names the client's urllib.open span as its parent:
{
"name": "GET /quote/widget",
"context": {
"trace_id": "0xcdde803e61b96f52e2eb3820c7004df0",
"span_id": "0x9bb64b3bad9a6848"
},
"kind": "SpanKind.SERVER",
"parent_id": "0x670e42eb0ac7690a",
...
}
{
"name": "urllib.open",
"context": {
"trace_id": "0xcdde803e61b96f52e2eb3820c7004df0",
"span_id": "0x670e42eb0ac7690a"
},
"kind": "SpanKind.INTERNAL",
"parent_id": "0x0e06d5674120b1db",
...
}
In a viewer, each order is one distributed trace with the service's request span attached beneath the outbound call that made it. One invariant governs the header handling and it is worth stating because it is the thing that makes this safe to switch on in a service that sits between other people's systems: never break a trace you do not understand. A header wrapture parses but nothing claims is forwarded verbatim, so an upstream product sees this service as a transparent hop, and headers wrapture does not parse are never touched at all.
Metrics for free
The traces signal exports events individually. The metrics signal aggregates the same events instead, and both were on above since the default is all signals. Request durations go into the semantic-convention http.server.request.duration histogram, attributed by method, route and status code, and observed calls go into a per-path wrapture.call.duration histogram whose error series split out by exception type. From the same run, the attribute sets on the request histogram's data points were:
{
"http.request.method": "POST",
"http.route": "/order",
"http.response.status_code": 200
}
{
"http.request.method": "GET",
"http.route": "/quote/<item>",
"http.response.status_code": 500,
"error.type": "KeyError"
}
So per-endpoint latency and error rate read straight off the histogram with no code involved. The reason the bound path is safe as a metric attribute where a raw URL would not be is that the config chose the bindings, so the set of values is closed; requests are attributed by route pattern for the same reason, never by URL. The design is the Aggregate collector from the previous post with the aggregation handed to the SDK: bounded memory, no values captured, nothing retained.
What it costs
The usual objection to instrumenting Python is the overhead, so this is worth a paragraph. Exporting through wrapture costs about the same as instrumenting with the OpenTelemetry SDK directly, and on a call that raises it costs noticeably less, because the SDK's record_exception formats the stack trace through traceback.format_exception, which on current Pythons parses each frame's source to draw caret underlines that no backend renders, and wrapture's sink formats the same frames without them. The design point behind the rest is that the sink does not use the SDK's tracer at all. Everything a span needs is known when its event closes, so the sink builds the finished span at that moment and hands it to the SDK's own processor, skipping the tracer's mutable span object with its validated attribute store and locks, which is where most of the per-span cost used to go. The measured figures, with the methodology, are in the cost section of the OpenTelemetry export page, and I would rather point there than quote numbers that will be out of date by the time anyone reads this.
The thread from the beginning
When I introduced wrapture I said there were two interests behind it, correctness in testing and instrumenting programs for tracing, and that underneath they wanted the same thing: a way to see the real calls as they happen. This is where the second one ends up. The three bindings on the shop have not changed since the first testing post. In a test they feed a tape and the assertions read off it. In production they feed a backend, with a request as one tree, a trace id that survives crossing to another service, and metrics aggregated from the same events. The only thing that changed along the way is who was listening.
The OpenTelemetry export page has the rest of the table, including sampling, the logs signal and how wrapture's pipelines coexist with an application that already uses the OpenTelemetry API on its own account.

.png)
.png)