skip to navigation
skip to content

Planet Python

Last update: November 10, 2026 09:47 PM UTC

November 10, 2026


Graham Dumpleton

Muru: WSGI and ASGI together

The previous post was about serving HTTPS from a development run. This time I want to get back to something I mentioned in passing when I first talked about Muru, which is that it isn't limited to WSGI, and that WSGI and ASGI can be mixed in the one application.

The case where this matters most is an existing WSGI application which now needs websockets. WSGI has no way of doing websockets, so the usual answer is to stand up a separate ASGI server beside the WSGI one, put a proxy in front to route the websocket paths to it, and then work out how the two halves are going to share things like sessions and configuration. The other answer is to port the whole application to ASGI, which for a large application is rarely something anyone wants to do just to get one feature.

With Muru, neither is necessary. The WSGI application can hand a request over to an ASGI application running in the same worker process.

An existing WSGI application

To show how this works I will use a small Flask application, standing in for an application which has been around for years. The project has Flask installed with uv add flask, and the pyproject.toml file names the entrypoint:

[tool.muru]
entrypoint = "shop.app:app"

The application itself has the existing page, plus a new /chat view where the websocket is to live:

from flask import Flask, request

from shop.chat import chat

app = Flask(__name__)


@app.route("/")
def index():
    return "Welcome to the shop\n"


@app.route("/chat")
def chat_view():
    bridge = request.environ["muru.wsgi_to_asgi"]
    if not bridge.is_websocket:
        return "websocket required\n", 426
    return bridge.handoff(chat)

Every WSGI request Muru passes to an application has a muru.wsgi_to_asgi object in the WSGI environ. This is the bridge across to the ASGI side. It can say whether the request is a websocket upgrade, and its handoff() method takes an ASGI application, not a WSGI one. Calling handoff() marks the request so that, once the WSGI side has returned, Muru carries on with the request by passing it to that ASGI application.

What handoff() returns is there for convenience, and is designed to suit both raw WSGI applications and web frameworks. It is an empty iterable, which is what a WSGI application returns for a response with no body, so a raw WSGI application can return it straight away. The same object can also be called as if it were a WSGI application, in which case it responds with a 200 status and returns itself as an empty body. Between them, these cover what web frameworks will accept from a view. Current versions of Flask take an iterator returned from a view as the body of a response, while older versions instead run a callable returned from a view as a WSGI application. Either way, a Flask view can return what handoff() gives back, as the example above does.

For any other framework, how you return from the handler doesn't matter. Once a request has been handed off, Muru ignores whatever response the WSGI side produces, so all that is needed is to call handoff() and then return whatever response the framework expects.

Websockets as callbacks

What the request is handed off to, chat, has to be an ASGI application. You could write that as a raw ASGI coroutine, but if your application is WSGI, the people working on it are probably more at home with synchronous code. For that Muru provides muru.asgi.websocket.Handler, an ASGI application which turns the events of a websocket into method calls on an object of your own. Here is a minimal chat room in shop/chat.py, where every message received is sent on to everyone connected:

from muru.asgi import websocket


class Chat:
    def __init__(self):
        self.sockets = set()

    def on_connect(self, ws):
        self.sockets.add(ws)

    def on_receive(self, ws, message):
        for peer in list(self.sockets):
            try:
                peer.send(message)
            except websocket.ConnectionClosed:
                self.sockets.discard(peer)

    def on_disconnect(self, ws, code, reason):
        self.sockets.discard(ws)


chat = websocket.Handler(Chat())

The methods are called on the worker's application threads, the same threads the WSGI application uses. Calls for any one socket happen one at a time and in order, while different sockets can be handled at the same time. The important part, which I will come back to, is that a socket sitting there waiting for its next message isn't holding on to a thread while it waits.

Turning handoff on

Start this with muructl run and the ordinary page works, but a websocket client connecting to /chat is turned away. The access log shows why, with the view having answered with a 426 status:

426 GET /chat 19B 0.5ms queue=0.0ms app=0.5ms worker=0 id=01M6JWP0E8W9FQ721K81F9AJ9H

Handing requests to ASGI has to be enabled, as it means the worker needs to run an event loop beside its threads. Without the setting, a websocket upgrade is just an ordinary request, so is_websocket is false and the view turns it away. Enabling it is one more line in pyproject.toml:

[tool.muru]
entrypoint = "shop.app:app"
handoff = "asgi"

Run it again and the start-up output confirms the worker now has an event loop as well as its threads:

muru: serving shop.app:app on http://127.0.0.1:8080 with 1 worker of 4 threads and an event loop of concurrency 1000

To check the chat room really works, I used a small client written with the websockets package, installed into a separate virtual environment, which connects as two users and has each of them say something:

import asyncio

from websockets.asyncio.client import connect

URL = "ws://127.0.0.1:8080/chat"


async def main():
    async with connect(URL) as alice, connect(URL) as bob:
        await alice.send("hello from alice")
        print("alice got:", await alice.recv())
        print("bob got:", await bob.recv())
        await bob.send("hi alice, bob here")
        print("alice got:", await alice.recv())
        print("bob got:", await bob.recv())


asyncio.run(main())

Running this the output is:

alice got: hello from alice
bob got: hello from alice
alice got: hi alice, bob here
bob got: hi alice, bob here

The access log has a line for each websocket when it is accepted, and another when it closes, recording who closed it, how, and how many messages went each way. Both are marked with handoff=asgi so you can tell the request was handed across:

101 GET /chat 0B 1.8ms queue=0.0ms app=0.5ms worker=0 handoff=asgi id=01M6JWPC691F8KMJB3TQH8CKSK
101 GET /chat 0B 0.3ms queue=0.1ms app=0.2ms worker=0 handoff=asgi id=01M6JWPC6B1F8KMJB3TQH8CKSK
101 GET /chat 34B 1.7ms websocket=closed by=client code=1000 received=1 sent=2 worker=0 handoff=asgi id=01M6JWPC6B1F8KMJB3TQH8CKSK
101 GET /chat 34B 2.9ms websocket=closed by=client code=1000 received=1 sent=2 worker=0 handoff=asgi id=01M6JWPC691F8KMJB3TQH8CKSK

Sockets don't hold threads

The reason this isn't just a party trick is what happens to the thread the WSGI view ran on. Once the view hands the request off and returns, the thread goes back to the pool to handle other requests. The websocket now lives on the worker's event loop, and only borrows a thread for the brief time it takes to run one of the callback methods.

That matters, because this worker only has four threads. If each open websocket tied up a thread, the fifth person to open the chat would stop the rest of the site from responding. To check, I changed the client to open fifty websockets to /chat, leave them all open, and then make an ordinary request to the home page:

50 websockets open
GET / answered b'Welcome to the shop\n' in 6.2ms

Fifty open websockets on a worker with four threads, and the rest of the site carries on as normal.

Side by side by path

Handing off in code is the right tool when the decision about which side a request goes to belongs in the application, such as wanting the Flask view to check the user is logged in before accepting the websocket. When the decision is simply about the path, Muru can instead mount an ASGI application beside the WSGI one.

Say newer parts of the shop are being written as async code under /api. Here is a stand-in for that in shop/api.py, where the sleep pretends to be waiting on some other service:

import asyncio
import json


async def app(scope, receive, send):
    if scope["type"] != "http":
        return

    await asyncio.sleep(1)  # stand in for waiting on another service

    body = json.dumps({"root_path": scope["root_path"], "path": scope["path"]}).encode()
    await send({"type": "http.response.start", "status": 200, "headers": [(b"content-type", b"application/json")]})
    await send({"type": "http.response.body", "body": body})

Mounting it at /api is a table in pyproject.toml:

[tool.muru.mounts.api]
path = "/api"
entrypoint = "shop.api:app"

On start-up, Muru says what each entrypoint is being served as, and where each mount is:

muru.host[89570]: shop.app:app is served as WSGI
muru.host[89570]: mount api: shop.api:app is served as ASGI
...
muru: mount api serves /api from shop.api:app

Anything under /api now goes to the ASGI application, and everything else to Flask. The ASGI application sees the mount path as its root_path, as the ASGI specification asks:

curl http://127.0.0.1:8080/api/orders
{"root_path": "/api", "path": "/api/orders"}

Both applications run in the same worker processes, imported by the same preload, from the one project.

Requests to the ASGI application run on the worker's event loop, so they aren't held back by the four threads the WSGI application has. Firing off twenty requests to /api at once, each awaiting that one second sleep, they all finished together in 1.06 seconds, rather than queuing up four at a time behind the threads.

Left alone, a mount shares the worker's event loop, and so its concurrency, with everything else running on it, such as the websockets handed off from Flask. If one part of the site shouldn't be able to take all of that, a mount can be given a concurrency of its own:

[tool.muru.mounts.api]
path = "/api"
entrypoint = "shop.api:app"
concurrency = 500

Muru confirms this when it starts:

muru: mount api serves /api from shop.api:app with concurrency 500 of its own

There is a similar max_connections setting to put a limit on how many websockets an ASGI mount can hold open.

And back the other way

Handing off works in the other direction too. An ASGI application can hand a request to WSGI code, which is useful when moving an application to ASGI a piece at a time, leaving the parts not yet converted as they are. In the scope given to an ASGI application, Muru's extensions include an asgi_to_wsgi bridge, whose handoff() runs a WSGI application for the rest of the request:

def legacy_report(environ, start_response):
    body = f"report generated by WSGI code for {environ['SCRIPT_NAME']}{environ['PATH_INFO']}\n".encode()
    start_response("200 OK", [("Content-Type", "text/plain")])
    return [body]


async def app(scope, receive, send):
    if scope["type"] != "http":
        return

    if scope["path"].startswith("/api/reports/"):
        return scope["extensions"]["muru"]["asgi_to_wsgi"].handoff(legacy_report)

    ...

As before, this has to be enabled, and if you forget, the error says exactly what to add:

muru.HandoffNotEnabled: mount api does not enable handing requests to WSGI; set handoff = "wsgi" on the mount

WSGI code needs threads to run on, so as well as setting handoff, the mount here is given two threads of its own:

[tool.muru.mounts.api]
path = "/api"
entrypoint = "shop.api:app"
concurrency = 500
handoff = "wsgi"
threads = 2

The start-up output now shows the mount with its own threads as well as its own concurrency:

muru: mount api serves /api from shop.api:app on 2 threads of its own with concurrency 500 of its own

Now a request for a report goes to the old WSGI code, while everything else under /api stays with the ASGI application:

curl http://127.0.0.1:8080/api/reports/daily
report generated by WSGI code for /api/reports/daily

with the access log marking it with handoff=wsgi:

200 GET /api/reports/daily 53B 0.4ms queue=0.0ms app=0.4ms mount=api worker=0 handoff=wsgi id=01M6JWRJ0DQ38194C2WQX2KDCJ

gRPC as well

The same mount mechanism also takes care of the third kind of service I mentioned in the first post, which is gRPC. A gRPC mount doesn't have a path. Instead, calls are recognised by their content type and service name, so gRPC and ordinary HTTP share the same port. The entrypoint is a function which registers the servicers generated by grpcio-tools, just as you would with a grpcio server:

class Greeter(greeter_pb2_grpc.GreeterServicer):
    def SayHello(self, request, context):
        return greeter_pb2.HelloReply(message=f"Hello {request.name}")


def register(server):
    greeter_pb2_grpc.add_GreeterServicer_to_server(Greeter(), server)
[tool.muru.mounts.api]
interface = "grpc"
entrypoint = "svc.app:register"
muru.host[91221]: mount api: svc.app:register is served as gRPC
...
muru: mount api serves the gRPC services of svc.app:register

With that, a grpcio client calling SayHello and a browser fetching the home page can talk to the same port of the same development run, and both show up in the one access log:

200 GET / 30B 0.2ms queue=0.0ms app=0.2ms worker=0 id=01M6JWS8HV7NK43EP3VYEA225P
200 POST /greet.Greeter/SayHello 14B 0.9ms queue=0.0ms app=0.8ms worker=0 id=01M6JWS8KT7NK43EP3VYEA225P

One catch is that grpcio doesn't provide wheels for free-threaded Python, so for gRPC I had to use a regular build of Python. There is a lot more to gRPC in Muru, such as streaming calls and gRPC-Web, which I may come back to in a post of its own.

What's next

What I like about all of this is that you don't have to make a big decision up front. An application can start as WSGI, take websockets when it needs them, grow async parts under a path, and move across to ASGI piece by piece if that ever makes sense, all without becoming more than one deployment.

That wraps up what I wanted to show of Muru in development mode. Next it is time to move on to the daemon, starting with deploying an application to a server, where the releases that a reload creates in development mode become deploys that can run side by side, take a share of the traffic, and be rolled back.

November 10, 2026 09:39 PM UTC


PyCoder’s Weekly

Issue #760: Type Checkers, Claude Code Hooks, MarkItDown, and More (2026-11-10)

#760 – NOVEMBER 10, 2026
View in Browser »

The PyCoder’s Weekly Logo


How a Python Type Checker Decides a Variable Is Unused

A variable is unused only if no read resolves to its binding and in Python that read can sit inside a nested function or comprehension. This post walks through how ty, Astral’s Python type checker, finds local bindings nobody reads.
DENYSZHAK.COM • Shared by Denys Zhak

How to Automate Your Workflow With Claude Code Hooks

Learn how to use Claude Code hooks that auto-format your Python code after every edit, enforce your chosen tools, and ping you when a task finishes.
REAL PYTHON

Why Your Python Agent Returns Outdated Memories

alt

A user changes a preference, but vector search still surfaces the old one alongside the new. Link the new memory to the one it replaces and mark the old one historical, so your agent retrieves what’s current and keeps the history. Explore the walkthrough and runnable notebook.
ORACLE sponsor

Converting Documents to Markdown With MarkItDown

Learn how to use MarkItDown to convert Excel files, PDFs, and images into clean Markdown that’s ready for LLMs and AI workflows.
REAL PYTHON course

Quiz: Converting Documents to Markdown With MarkItDown

REAL PYTHON

Django Security Releases Issued: 6.1.3, 6.0.10, and 5.2.19

DJANGO SOFTWARE FOUNDATION

Agentic Coding With Codex CLI: A Live Course for Python Developers

If you pay for ChatGPT Plus, you already have Codex CLI, an agent that runs in your terminal, edits your files, and runs your tests. Over two live half-days on November 14–15, you’ll use it to build a Python CLI app from an empty directory to GitHub, write an AGENTS.md file so it follows your project’s rules, and practice debugging the code it writes. You keep the recordings. Registration closes Friday, November 13 →
REAL PYTHON

Discussions

Pull Request Limits for CPython Repo

PYTHON.ORG

Articles & Tutorials

Soft Delete in Django Without Making a Mess

Soft delete sounds simple: add a deleted_at field and stop calling DELETE. The trouble starts a little later, when deleted rows still show up in queries. This tutorial shows a soft-delete setup that handles those cases without hiding too much magic in the model layer.
STEIN HELSET

Hello Python 3.15, Goodbye 3.10, and Now I Can Use 3.11

Simon covers the latest features in Python 3.15. If you’ve seen that already, the second half of the article is also worth reading: now that 3.10 is end-of-life, what Python 3.11 features can you use everywhere?
SIMON WILLISON

Parsing JSON at Tens of Gigabytes Per Second

This article talks about fastsimdjson, a drop-in replacement for the json module in the standard library. It has been optimized for speed, demonstrated by reading large amounts of JSON GitHub commit data.
DANIEL LEMIRE

Why Python Devs Should Know About the ABI

Why should every Python developer know about the CPython ABI? This week on the show, we speak with Nathan Goldbaum about free-threaded Python and the changes to the Python Application Binary Interface.
REAL PYTHON podcast

Building a Lift Scheduling Algorithm in Python

This article walks you through the logic needed to code a scheduling algorithm for elevators, starting with a single car to a more complex multi-car system.
PIETER CLAERHOUT

Histogram Plotting: NumPy, Matplotlib, pandas & Seaborn

In this tutorial, you’ll be equipped to make production-quality, presentation-ready Python histogram plots with a range of choices and features.
REAL PYTHON

Python Exceptions: An Introduction

Learn what Python exceptions are, how to raise them, how to handle them with try, except, else, and finally, and how to create custom ones.
REAL PYTHON

Python Program Lexical Structure

Dig into the Python program structure: the syntactic elements that make up statements, and how statements group into larger blocks.
REAL PYTHON

Announcing New Security Developer at the PSF

The Python Software Foundation welcomes Brett Smith as an additional security developer.
PYTHON SOFTWARE FOUNDATION

Python’s Binary Wheels and Supply Chain Attacks

Why precompiled Python wheels are a weak spot in the supply chain security.
ARTEM GOLUBIN

Projects & Code

pgcli: Postgres CLI With Autocomple & Syntax Highlighting

GITHUB.COM/DBCLI

django-mcpz: Easy Peasy MCP Servers in Django

GITHUB.COM/ADAMCHAINZ

Ghostman: Postman Alternative Built in Python With Textual

GITHUB.COM/4YK5 • Shared by 4yk5

VizTracer: Visualize Code Debugging/Profiling/Logging

GITHUB.COM/GAOGAOTIANTIAN

django-ses: Django Backend for Amazon’s Simple Email Service

GITHUB.COM/DJANGO-SES

Events

Weekly Real Python Office Hours Q&A (Virtual)

November 11, 2026
REALPYTHON.COM

Python Help

November 12, 2026
MEETUP.COM

Python Leiden User Group

November 12, 2026
PYTHONLEIDEN.NL

Python Atlanta

November 13, 2026
MEETUP.COM

PyCon Chile 2026

November 14 to November 16, 2026
PYCON.CL

PyCon Hong Kong 2026

November 14 to November 16, 2026
PYCON.HK

Wagtail Space 2026

November 18 to November 21, 2026
WAGTAIL.ORG

PyCon Wroclaw 2026

November 20 to November 21, 2026
PYCONWROCLAW.COM

PyCon Ireland 2026

November 21 to November 22, 2026
PYCON.IE


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

alt

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

November 10, 2026 07:30 PM UTC


Python Bytes

#504 Who turned the GIL back on?

<strong>Topics covered in this episode:</strong><br> <ul> <li><strong><a href="https://blog.pypi.org/posts/2026-11-06-ui-updates-complete/?featured_on=pythonbytes">PyPI's redesigned project pages</a> now warn when provenance changes</strong></li> <li><strong><a href="https://mkennedy.codes/posts/catch-the-import-name-the-culprit-keep-threads-free-introducing-gilguard/?featured_on=pythonbytes">gilguard catches the import that turns the GIL back on</a></strong></li> <li><strong><a href="https://pip.pypa.io/en/stable/news/#v26-3">pip 26.3:</a> pylock extras, and pip says plainly that untrusted wheels aren't safe</strong></li> <li><strong><a href="https://heyutils.com/tools/?featured_on=pythonbytes">All developer tools</a></strong></li> <li><strong>Extras</strong></li> <li><strong>Joke</strong></li> </ul><a href='https://www.youtube.com/watch?v=rPczSyhinpc' style='font-weight: bold;' data-umami-event="Livestream-Past" data-umami-event-episode="504">Watch on YouTube</a><br> <p><strong>About the show</strong></p> <p>Sponsored by <a href="https://pythonbytes.fm/longlink"><strong>LongLink</strong></a>: Open-source platform for building and running custom business applications with Python and FastAPI <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> 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. Finally, if you want an artisanal, hand-crafted 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.</li> </ul> <p><strong>Calvin #1: <a href="https://blog.pypi.org/posts/2026-11-06-ui-updates-complete/?featured_on=pythonbytes">PyPI's redesigned project pages</a> now warn when provenance changes</strong></p> <ul> <li>Live now on <a href="http://pypi.org/?featured_on=pythonbytes">pypi.org</a> (Nov 6). Announced in July, shaped by user research, funded by OpenSSF and built by Kabu Creative.</li> <li>New Security tab on every project page shows each release's provenance state and per-file attestations, with a link to the transparency log.</li> <li>Two attestation types: PyPI Publish (uploaded by a Trusted Publisher) and SLSA Build Provenance (ties a file to its source commit, build platform and instructions).</li> <li>"No provenance" isn't a malware flag; it means the file's origin can't be cryptographically verified. That's typical for older releases or projects without Trusted Publishing.</li> <li>Four new provenance warnings: Incomplete (e.g. wheels via Trusted Publishing but sdist via API token), Inconsistent (mixed build systems or repos), Change (different repo or workflow than last release), Loss (stopped publishing attestations).</li> <li>Warnings are meant to catch files that may not have come from the maintainers, such as after an account or publishing-workflow takeover. PyPI says a Change of Provenance "can also indicate a compromised pipeline or account takeover," and an unexpected Loss of Provenance should be treated "with skepticism until verified.”</li> <li>Warnings now sit at the top of the page with yanked, archived and pre-release banners. A quarantine banner overrides all of them. Sidebar metadata is labeled "PyPI data" or "Data verified by PyPI."</li> <li>Release files tab gets search, filters, a per-file provenance column and orange icons for "late" files (uploaded more than 14 days after release). Those only appear on releases from before July 2026, when PyPI started rejecting new files after 14 days.</li> </ul> <p><strong>Michael #2: <a href="https://mkennedy.codes/posts/catch-the-import-name-the-culprit-keep-threads-free-introducing-gilguard/?featured_on=pythonbytes">gilguard catches the import that turns the GIL back on</a></strong></p> <p>gilguard catches a nasty free-threading surprise: importing a C extension without declared free-threading support can turn the GIL back on for the rest of the process. Import gilguard first and it raises a detailed error naming the extension, distribution/version, import chain, and the line of your code responsible.</p> <ul> <li>A transitive dependency can undo your move to free-threaded Python. Fail at the offending import instead of discovering lost parallelism in a benchmark.</li> <li>Uses import hooks plus CPython's warning path to catch GIL activation; GILEnabledError inherits BaseException, so even except Exception won't swallow it.</li> <li>python -m gilguard audits installed top-level packages in fresh interpreters. Supports targeted modules and JSON output; exits 1 when it finds an offender for CI.</li> <li>The audit misses extensions loaded lazily on first use. Exercise those paths in tests with gilguard installed early; one process can identify only its first GIL-flipping import.</li> <li>GILGUARD_ACTION=warn reports without stopping; off permits standard builds. Default mode raises on standard Python or an already-enabled GIL. PYTHON_GIL=0 prevents activation, so the guard cannot detect offenders there.</li> <li>Published on <a href="https://pypi.org/project/gilguard/?featured_on=pythonbytes">PyPI</a> as 0.1.0. Install with uv pip install gilguard. Python 3.13+, MIT; no runtime dependencies.</li> </ul> <p><strong>Calvin #3: <a href="https://pip.pypa.io/en/stable/news/#v26-3">pip 26.3:</a> pylock extras, and pip says plainly that untrusted wheels aren't safe</strong></p> <ul> <li>pip 26.3 released Nov 2. Its security policy now says pip "has never considered installing untrusted wheels safe," because Python's import system is dynamic, so pip can't guarantee installing a wheel won't execute third-party code (#14412).</li> <li>The change: pip blocks unexpected imports during wheel installation "to avoid loading code from packages being replaced." It loads what it needs for installing and error reporting up front (PR #14247, merged Nov 2: https://github.com/pypa/pip/pull/14247).</li> <li>CLI vs. library use: from the command line, imports stay blocked until pip exits. Code that calls pip programmatically gets warnings after control returns.</li> <li>Rolled out in steps: pip 26.1 started warning ("Unexpected import of '...' after pip install started. pip 26.3 will enforce this behavior change"), and 26.3 turns it into an error. Tracking issue #13842 collected reports before enforcement: https://github.com/pypa/pip/issues/13842</li> <li>What tripped it in the wild: a <code>.pth</code> startup hook at Pradyun Gedam's workplace that registers an atexit handler to report interpreter usage metrics over HTTP (https://github.com/pypa/pip/issues/13842#issuecomment-4613463933), plus warnings involving <code>lark_oapi</code> and <code>rich._emoji_codes</code>.</li> <li>Escape-hatch debate: the author, Damian Shaw (notatallshaw), suggested appending module names to <code>pip._internal.commands.install._EAGER_IMPORTS</code>. Pradyun objected that this requires importing from <code>pip._internal</code>, that importing pip's install command is expensive (about 689 ms with no .pyc files and a cold filesystem cache), and that pip isn't necessarily installed in every environment. Shaw's reply: it "just moves where the import cost is," and turning the warning into an error is "crucial."</li> <li>Interrupted installs: pip now writes recovery metadata and reports recovery failures and where it kept backups. An interrupted uninstall may need manual recovery, and an interrupted --target install can leave a partial target directory.</li> <li>Also in 26.3: <code>-r pylock.toml[extra1,extra2]</code> for extras, <code>--group pylock.toml:docs</code> for non-default groups, <code>--no-binary</code> with pylock files, and pip refuses to follow an HTTP redirect to a file: URL.</li> </ul> <p><strong>Michael #4: <a href="https://heyutils.com/tools/?featured_on=pythonbytes">All developer tools</a></strong></p> <p>HeyUtils is a collection of 306 free developer utilities in 41 categories, with no account or installation required. The full catalog covers data conversion, APIs, networking, DevOps, frontend work and binary protocols. Python developers get model generation and packaging helpers alongside the everyday formatters and decoders worth bookmarking.</p> <ul> <li>One bookmark for the tiny jobs that interrupt coding: inspect a payload, compare configs, decode a token or figure out a cron schedule.</li> <li>Data tools include JSON/YAML/TOML conversion, CSV joins, JSONPath and JMESPath testers, a jq playground, and a SQLite viewer with read-only queries.</li> <li>Python-specific options go beyond regex: generate dataclasses or Pydantic models from JSON, check PEP 440 versions, and inspect requirements.txt or pyproject.toml dependencies.</li> <li>The collection also covers JWT/OAuth debugging, DNS and TLS checks, Docker Compose, Kubernetes YAML, GitHub Actions, and frontend helpers such as color contrast checks.</li> <li>Most tools process input locally, according to the site. Network tools make requests after you start them and explain what they send; don't assume every page is offline.</li> <li>Generated models need review: sample data cannot describe a complete validation contract, and dataclass type hints do not validate incoming values.</li> </ul> <p><strong>Extras</strong></p> <p>Calvin:</p> <ul> <li><a href="https://github.com/hedgerow-dev/coldframe?featured_on=pythonbytes">Coldframe</a>: a stdlib-only honeypot that poses as a supermarket fridge controller** Michael:</li> <li><a href="https://pyfound.blogspot.com/2026/10/announcing-new-security-developer-2026.html?featured_on=pythonbytes">Announcing new Security Developer at the PSF</a> <ul> <li>Brett Smith is joining the PSF as a Security Developer, a new role funded by Anthropic. He'll work with Seth Larson and Mike Fiedler on vulnerability response and PyPI malware removal, with more time for proactive work as the team's workload grows. <ul> <li>More paid security capacity for the Python and PyPI infrastructure we depend on every day.</li> <li>The PSF reports 3-4x the volume of reports and CVEs for Python and pip compared with a year ago.</li> <li>Malware published and removed from PyPI is up 60% year over year. That's a workload measure, not a claim that 60% more packages are malicious.</li> <li>Planned work includes scaling PyPI's autonomous quarantine program and streamlining how the Python Security Response Team handles reports.</li> <li>Smith brings software development and Linux administration experience, including work at W3C and Software Freedom Conservancy.</li> </ul></li> </ul></li> </ul> <p><strong>Joke: <a href="https://x.com/PR0GRAMMERHUM0R/status/2016768305395040692?featured_on=pythonbytes">No papa</a></strong></p>

November 10, 2026 07:04 PM UTC


The Python Coding Stack

Generalist vs Specialist in the After AI Era

I always struggled to identify whether I was a specialist or a generalist.

During my studies, I narrowed my focus at every step. I studied maths, physics, and chemistry at A level, then maths and physics during my undergrad, and finally physics during my PhD. And the PhD wasn't in all of physics. It was in optics, and specifically in a narrow field within optics. I then spent over a decade working in this specialised field.

That settles it, then.

But was I a true specialist? I got quite good at the branch of optics I needed for my work, but others around me were better. I got reasonably proficient with coding to model physical processes and analyse data, but I had plenty of gaps in my programming knowledge. I quickly learnt how to become an effective lecturer and communicate with students as I designed and delivered undergraduate courses. Being an expert in a subject doesn't automatically make you good at teaching it.

When I left academia and started teaching Python, I wasn't an expert programmer and I was relatively new to Python. Sure, I got a lot better relatively quickly. But my strength was in teaching and communication, not in my programming expertise.

To run my business, I had to know or learn a bit of lots of stuff: accounting, marketing, sales, customer service, and managing staff. But I lacked a lot in all of these areas, too.

I'm a scientist by training and a technical educator by profession. But I enjoy languages, history, geography, sports, economics. The list goes on. I like to know stuff about any topic.

Perhaps I am a generalist and I've always been, even though I thought I was a specialist. Maybe it's time to embrace my generalist nature.

In the last few months, I've found a good reason to do just that. AI is helping me do more with knowledge and skills I already had, in several different fields. Things I knew something about, but not enough to carry through on my own, are becoming things I can actually do.

And I'm loving it.

Subscribe now

Generalist vs Specialist in the Before AI Era

The debate is not new. We need people who know a lot about a narrow field, and we need people who know enough about several fields to connect them and put that knowledge to use.

Knowledge in fields such as science and medicine has expanded so much that it's impossible to master a field without dedicating large amounts of time and effort to it. There's good reason for specialisation.

But depth takes time. And time spent going deeper into one field is time you can't spend learning about another.

That's not a criticism. It's a constraint.

A generalist makes a different trade-off. They have a broader base, but they run into the limits of their knowledge in more places. Knowing something about marketing isn't the same as being able to put together an effective marketing campaign. Understanding programming doesn't mean you're equally comfortable building every kind of software.

You can learn more, of course. Or work with someone who has the expertise you lack. But both require resources, whether that's time, money, or access to the right people.

So you can have interests, ideas, and useful knowledge that you never quite manage to turn into anything.

AI is changing that.

Generalists and Specialists During the Transition

To get from Before AI to After AI, we need to go through a transition. A period when things change. And boy oh boy we're in turbulent, changing, uncertain, unstable times right now.

When the situation changes and what worked yesterday no longer works today, it helps to have more than one way forward. A generalist has a large toolbox and more possibilities for using the tools differently. They're typically also more used to making connections across different fields.

A specialist often sees things through the lens of their speciality, and their skillset is optimised to go deeper and further within their speciality. Change can be challenging.

That doesn't mean specialists can't adapt. And being interested in lots of things doesn't automatically make you good at dealing with change.

But having several useful starting points gives you options. AI makes some of those options easier to act on.

That's the part I find most exciting. Not just having somewhere else to turn when one kind of work changes, but being able to do more in areas I already care about.

Generalist vs Specialist in the After AI Era

Before we go further, let's tear down a simplification I've been using in this post so far.

No one is really just a specialist or a generalist.

A pure specialist could be represented by this:

Don't worry about the maths-y bits there if you're not familiar with delta functions. An "ideal" specialist, or a 100% specialist, has expertise in one narrow topic and no knowledge of anything else. A generalist could look like this:

The 100% generalist has a constant amount of knowledge about everything.

You can tell I'm a physicist by nature. Physicists love to start from idealised assumptions. You've probably heard the “let's assume we have a spherical cow” joke.

A person who knows everything about one narrow topic and absolutely nothing about anything else would be a useful starting point for a model. But not a person you'll ever meet.

And a generalist doesn't know exactly the same amount about every topic, either.

There aren't any 100% specialists or 100% generalists.

Here's a typical sketch comparing specialists and generalists. The bars represent different skill areas, and their heights represent depth of expertise:

But there's an infinite number of variants. Some people have two or more high bars among the low ones. Others have a speciality but a good generalist level across many other skills. And not all generalists are the same. Some can have high bars across the board, even if not a single bar is at specialist level.

You don't have to choose between depth and breadth as though you can only have one. You can have both, in different proportions and in different fields.

So, rather than asking which of two boxes a person belongs in, we can look at the shape of their knowledge and skills. The labels are shorthand. The profile is more interesting.

AI helps fill gaps where you're not an expert. But I think the best use of AI is when a person and an AI agent collaborate (and I'm writing about this in a future post, so stay tuned), rather than when the agent just gets an instruction and works without input from the human.

If you know something about a topic, you can bring more to that collaboration. You have a better idea of what you want, you can ask more useful questions, and you have some basis for questioning the answers.

Let me give you a personal example that has nothing to do with programming.

From coaching theory to a training programme

I'm a newly qualified athletics (track and field) coach. I have plenty of theoretical knowledge, because I've been interested in this field for a long time, but limited coaching experience.

I worked with my AI agent to create a full training programme for myself. I didn't ask it to choose everything for me. I knew the philosophy I wanted to follow: build speed first, with a focus on sprinting form (running technique), then extend to speed endurance.

The focus on form mattered to me. I wanted to prioritise maintaining it, even if that meant delaying speed endurance. I also had views on how to organise the training programme across various phases.

AI helped me translate that direction into individual sessions, including the details of how the different exercises should fit together. It helped me plan the progression across the season and balance the different training demands.

I brought knowledge and priorities. AI helped me work through the detail needed to turn them into a programme.

Does that make me an experienced specialist coach? No. Creating a programme isn't the same as gaining years of experience coaching athletes, observing how they respond, and adjusting what you do.

But I could put more of what I knew into practice than I could have done alone. And I learnt more about the details of season and session planning along the way.

That's the opportunity I'm talking about.

More than one place to start

The same opportunity exists in other fields where I have some knowledge but lack the skills or experience to do everything I'd like to do. But it's not limited just to those fields. AI also helps me take some of my strongest skills further.

In some areas, it helps me bridge gaps in expertise. In others, it helps me carry through ideas I wouldn't otherwise have the time or resources to pursue.

I'll share more about some of the work my AI agent and I have been doing recently, spanning many of my strong and not-so-strong skills, in the bonus section at the end of this post.

Sometimes, several of those skills come together in one project. But they don't have to. My coaching knowledge is useful when I'm planning athletics training, whether or not I use my programming or writing skills alongside it.

A broad base gives me several places where I can work with AI to take things further. Here's a sketch of what that could look like:

The lighter parts represent what working with AI might add to what a person can do. They're not extra years of experience magically appearing.

Nor should we read the drawing as a promise that AI adds the same amount in every field or for every person. It's a simple illustration of the opportunity. The specialist can benefit, too. The generalist has several areas with a useful foundation to build on.

And the starting point matters. Knowing nothing about a subject doesn't stop you from using AI. It can help you learn. But that's different from using knowledge you already have to direct the work and question what comes back.

Nor does knowing a little guarantee you'll spot every mistake. AI can be wrong, and so can I. The need to check the work doesn't disappear because we produced something impressive together.

My first post in the Agent & Co. section pokes fun at this idea:

Read “Thoroughly Checked”

Still, the gap between “I know something about this” and “I can do something useful with it” is becoming easier to cross.

Subscribe now

So, Is This the Age of the Generalist?

AAI, After AI, is the Age of the Generalist. I think so. But not because specialists are doomed.

Specialists can use AI to go further in their own fields, and to explore others. Deep knowledge still matters. My years in physics didn't become worthless when I started teaching Python, and they haven't become worthless now that I work with AI. They help shape how I think and how I approach problem-solving.

What excites me is that breadth can now take me further, too.

Before, I could look at some of my interests and see mostly what I lacked. I knew a bit, but there was a lot more I'd need to learn before I could do what I wanted.

There still is plenty to learn. But I don't always have to fill every gap myself before I can get started.

A generalist with strong skills across several fields has several opportunities to do more with those skills. In some tasks, working with AI may bring the result closer to what previously required specialist help. That doesn't mean the person has become a specialist in all those fields.

But it does change what's possible.

And AI can also help the generalist with bespoke learning to push those skills further.

Perhaps being interested in lots of things wasn't such a distraction after all.

Bonus: How I'm Enjoying Being a Generalist in This AAI Era

I always liked the idea of being a specialist. That's probably what led me to scientific research in my first career. But over the years, I gradually realised I like lots of things and I'm not too bad at several of them.

I didn't realise until recently that I'm more of a generalist than a specialist.

That's a shame, because I could have capitalised more on my strengths as a generalist, perhaps. But never mind, because in the last few months, I got it.

Here are a few places where I've been putting that breadth to use. Not just what I asked AI to do, but what I brought to the work and how we worked through it together.

These aren't examples of handing over a task and coming back when it's finished. The conversation is part of the work.

The bonus section is exclusively available to premium subscribers.

This post has bonus content for paid subscribers. Upgrade to get full access.

stephengruppetta.com

November 10, 2026 08:05 AM UTC


Python Insider

Python 3.16.0 alpha 2 is now available

The second developer preview of Python 3.16 is ready for testing.

November 10, 2026 12:00 AM UTC


Graham Dumpleton

Muru: HTTPS in development

Everything I have shown of Muru so far has been served over plain HTTP on port 8080. That is how almost everyone runs a Python web application while developing it, and most of the time it is fine. The application in production is going to be behind HTTPS though, and there are some things which behave differently when it is, which you only find out about once it is deployed.

The usual way of testing those things locally is to put a proxy such as nginx or Caddy in front of the development server, generate a certificate for it, and then configure the application to trust the headers the proxy adds to say the original request was HTTPS. It is enough of a hassle that most people never bother. With a Muru development run it is one option.

Turning it on

Given a WSGI application in shop/wsgi.py, add --https when starting it:

muructl run shop.wsgi:application --https
muru.host[61235]: preloaded shop.wsgi:application in 1 ms
muru.host[61235]: shop.wsgi:application is served as WSGI
muru.host[61235]: worker 0 started as pid 61241
muru.host[61235]: worker 0 (pid 61241) is ready
muru: serving shop.wsgi:application on http://127.0.0.1:8080 and https://127.0.0.1:8443 with 1 worker of 4 threads
...
muru: certificate for localhost, 127.0.0.1, ::1 issued by the mkcert authority in /Users/graham/Library/Application Support/mkcert (created, /Users/graham/Library/Caches/muru/certs/dev.pem)

The run now listens for HTTPS on port 8443 as well as HTTP on 8080, and it has created a certificate to use.

How that certificate was made depends on what you have installed. If you use mkcert and have run mkcert -install at some point, there is a local certificate authority on your machine which your browsers already trust. Muru finds it and issues the certificate from it, so the browser accepts the certificate without any warnings. If mkcert isn't installed, Muru falls back to creating a self-signed certificate, and prints out how to trust it, or how to install mkcert instead.

Either way, the certificate is kept in your user cache directory rather than in the project, so there is no risk of a private key ending up committed. It is reused on later runs, which the same line says by showing reused rather than created, and is only replaced when it nears expiry or is asked to cover a name it doesn't already. If you already have a certificate of your own you would prefer to use, --tls-cert and --tls-key will take that instead.

Why bother

To see what difference HTTPS makes to the application, here is a WSGI application which reports back the URL scheme and protocol of the request, and the host it was sent to:

def application(environ, start_response):
    body = f"{environ['wsgi.url_scheme']} {environ['SERVER_PROTOCOL']} {environ['HTTP_HOST']}\n".encode()
    start_response("200 OK", [("Content-Type", "text/plain")])
    return [body]

Making a request to each of the two ports:

curl http://127.0.0.1:8080/
curl https://127.0.0.1:8443/

the result being:

http HTTP/1.1 127.0.0.1:8080
https HTTP/2.0 127.0.0.1:8443

Over HTTPS, the application sees wsgi.url_scheme set to https, and the request arrived as HTTP/2. Neither of these can be tested over plain HTTP. Browsers only use HTTP/2 over TLS, so if you want to know how your application behaves when a page pulls in dozens of assets over the one connection, this is the only way to find out locally. Any code which uses wsgi.url_scheme to construct absolute URLs, for redirects or links in emails, will get them wrong unless the request really did arrive over HTTPS. And cookies marked as Secure, which any session cookie in production should be, aren't sent back by the browser over plain HTTP at all. An application which sets SESSION_COOKIE_SECURE in its production settings can behave quite differently to the one you have been developing against.

What makes this simple to provide is how Muru is put together, which I went through in how it fits together. TLS and HTTP/2 are handled by the edge, which is written in Rust and runs no Python code. By the time a request reaches the worker it has already been decrypted and parsed, and the application gets an ordinary WSGI environ with the scheme already set correctly. There is no proxy to configure, and no need to tell the application to trust an X-Forwarded-Proto header. It is also the same code which terminates TLS when running under the daemon in production, rather than something which only exists for development.

Names other than localhost

Using localhost for everything gets awkward once you work on more than one project, since cookies are tied to the host name and not the port. Log in to one project on localhost and its session cookie gets sent to every other project you run on localhost too.

The --host option adds names to the certificate:

muructl run shop.wsgi:application --https --host shop.localhost
muru: certificate for localhost, 127.0.0.1, ::1, shop.localhost issued by the mkcert authority in /Users/graham/Library/Application Support/mkcert (created, /Users/graham/Library/Caches/muru/certs/dev.pem)

Modern browsers such as Chrome and Firefox, as well as curl, resolve any name ending in .localhost to the loopback address, so there is no need to edit /etc/hosts to use them. The project can then be reached at https://shop.localhost:8443/, with its cookies kept apart from those of any other project, and with a certificate the browser trusts for that name.

HTTPS only

With --https, both HTTP and HTTPS are served, which can let a problem slip by if some page or redirect is quietly sending you back to plain HTTP. If you want to be sure everything is going over HTTPS, as it will be in production, use --https-only instead:

muructl run shop.wsgi:application --https-only --host shop.localhost
muru: serving shop.wsgi:application on https://127.0.0.1:8443 (http://127.0.0.1:8080 redirects to it) with 1 worker of 4 threads

Port 8080 is still listened on, but now answers every request with a redirect to the same path on the HTTPS port:

curl -i 'http://shop.localhost:8080/cart?item=42'
HTTP/1.1 307 Temporary Redirect
location: https://shop.localhost:8443/cart?item=42
server: muru
...

The redirect is a 307 rather than a 301 for two reasons. A 307 keeps the method, so a form posted to the HTTP port is posted again to the HTTPS port rather than turning into a GET. And browsers don't cache a 307, so if you later go back to running without HTTPS on the same port, the browser won't keep sending you off to a port where nothing is listening any more.

So you don't have to remember to type it each time, the same can be turned on for the project in pyproject.toml:

[tool.muru.run]
https_only = true
hosts = ["shop.localhost"]

These settings only apply to muructl run. They have no effect on how the application is served by the daemon in production, where certificates are a different matter altogether.

What's next

None of this is especially complicated, but it is the sort of thing which only gets tested if it is easy, and getting it for free is a consequence of the edge doing all the work of facing clients. Next up I will look at what else Muru lets a single application do, which is to mix WSGI and ASGI, including handing a request from one to the other so an existing WSGI application can take on websockets.

November 10, 2026 12:00 AM UTC

November 09, 2026


Graham Dumpleton

Muru: import once, fork many

Every time Muru has started an application in this series, starting with a first look at Muru, one of the first lines of output has been something like:

muru.host[88644]: preloaded app:application in 1 ms

In how it fits together I described the host as the Python process which imports the application once and forks the workers from it, but skipped over what that involves. It is time to come back to it, because how an application gets loaded into worker processes is one of those things that matters a lot for Python, and is easy to get wrong.

Why import once

To see what is going on, here is an application which records the process ID at the point the module was imported, and then reports that along with the process ID handling each request:

import os

IMPORTED_IN = os.getpid()

def application(environ, start_response):
    body = f"imported in {IMPORTED_IN}, serving from {os.getpid()}\n".encode()
    start_response("200 OK", [("Content-Type", "text/plain"), ("Content-Length", str(len(body)))])
    return [body]

Running it with two worker processes:

muructl run app:application --processes 2
muru.host[88644]: preloaded app:application in 1 ms
muru.host[88644]: app:application is served as WSGI
muru.host[88644]: worker 0 started as pid 88645
muru.host[88644]: worker 1 started as pid 88646
muru.host[88644]: worker 0 (pid 88645) is ready
muru.host[88644]: worker 1 (pid 88646) is ready
muru: serving app:application on http://127.0.0.1:8080 with 2 workers of 4 threads

and making a few requests, the result being:

imported in 88644, serving from 88646
imported in 88644, serving from 88645
imported in 88644, serving from 88646
imported in 88644, serving from 88645

The application was imported once, in the host process, and both workers were then forked from the host. Neither worker ever imported the application itself. Each started out as a copy of the host with every module already loaded.

For a toy application this makes no difference. For a large Django application, where importing everything can take several seconds and a lot of memory, it makes a big difference. Forked workers share the memory pages holding those modules with the host, and with each other, until something writes to them. Starting a new worker, whether to replace one that crashed, to recycle one that has grown too large, or to add capacity, costs a fork rather than a full import. Muru also takes care to freeze the garbage collector's view of the preloaded objects before forking, so that collections in the workers don't go touching, and therefore copying, all those shared pages.

This was one of the things I always wanted for mod_wsgi daemon mode but couldn't have. The daemon processes in mod_wsgi are forked by the Apache parent process, and that isn't a place where it is safe to load application code. Each daemon process therefore has to import the application itself, and restarting one means paying for that import again.

What doesn't survive a fork

Preloading isn't a new idea, and other Python WSGI servers have had an option for it for a long time. The reason it is usually an option, rather than the default, is that it can break applications in subtle ways.

Forking copies a process's memory, but some things live outside of memory. A socket is a kernel object, so after a fork both the parent and child hold the same connection, each thinking it is theirs alone. A thread exists only in the process which started it, so a background thread started during import simply doesn't exist in the workers, even though all its data structures do. A child process has one parent, so the workers can't manage a helper process the host launched.

So if importing the application opens a database connection, starts a thread to flush metrics, or launches a helper process, preloading produces workers that are quietly broken. Worse, these problems tend to be intermittent, and show up in production under load rather than on the first request you make in development.

The import check

What Muru does differently is that it doesn't just trust you to know whether your application is safe to preload. When the host imports the application, it records any sockets, pipes, threads and child processes that were left behind by the import.

To show this, here is a module standing in for a database client which connects when it is imported, as some do:

import socket

# Pretend this is a database client connecting when the module is imported.
connection = socket.create_connection(("127.0.0.1", 9340))

The WSGI application in shop/wsgi.py imports it with from shop import db. Running it gives:

muru.host[88997]: preloaded shop.wsgi:application in 2 ms
muru.host[88997]: shop.wsgi:application is served as WSGI
muru.host[88997]: preload: the import of shop.db left a connected socket (fd 3, to 127.0.0.1:9340); close it in a pre_fork hook and reopen it in post_fork, or open it lazily on first use
muru.host[88997]: preload: falling back to per-worker imports, which costs slower worker replacement and no shared memory; set preload to false to silence this
muru.host[88997]: worker 0 started as pid 89031
muru.host[88997]: worker 1 started as pid 89032
muru.worker[0]: worker 0 imported shop.wsgi:application in 2 ms
muru.worker[0]: worker 0 serves shop.wsgi:application as WSGI
muru.host[88997]: worker 0 (pid 89031) is ready
muru.worker[1]: worker 1 imported shop.wsgi:application in 2 ms
muru.worker[1]: worker 1 serves shop.wsgi:application as WSGI
muru.host[88997]: worker 1 (pid 89032) is ready

Muru found the connected socket, said where it goes, and decided not to fork from the host. Instead it fell back to having each worker import the application itself, which is always safe. The application still works, it just loses the benefits of preloading, and the message tells you what you would need to change to get them back.

Threads are caught the same way. Here is a module which starts a background thread when imported, to periodically report how many requests have been handled:

import threading
import time

counts = {"requests": 0}

def flush():
    while True:
        time.sleep(5)
        print(f"flushed {counts['requests']} requests", flush=True)

threading.Thread(target=flush, daemon=True).start()

Under a naive preload this thread would run in the host process, which never handles a request, and so would forever report zero. The workers doing the actual work would have no thread at all. Muru spots it:

muru.host[89680]: preload: the import of stats left a thread running; start it from a post_fork hook instead
muru.host[89680]: preload: falling back to per-worker imports, which costs slower worker replacement and no shared memory; set preload to false to silence this

Doing things at the right time

Most things which are wrong to do at import time are perfectly fine to do a moment later, once the worker exists. For that Muru supports a hooks module, named with the --hooks option or with hooks in the [tool.muru] table of pyproject.toml. The module can define functions to be called at different points in the life of the host and its workers.

The one you will need most is post_fork, which is called in each worker immediately after it is forked. For the metrics thread, the module is changed to provide a function to start the thread rather than starting it on import:

def start():
    threading.Thread(target=flush, daemon=True).start()

and a hooks.py calls it:

import stats

def post_fork(context):
    print(f"worker {context.worker_id} starting the stats thread", flush=True)
    stats.start()

Each hook is passed a context object which says where it is running, including the index of the worker. Running with the hooks module and two processes:

muructl run app:application --hooks hooks --processes 2
muru.host[90139]: preloaded app:application in 1 ms
muru.host[90139]: app:application is served as WSGI
muru.host[90139]: worker 0 started as pid 90140
muru.host[90139]: worker 1 started as pid 90141
muru.worker[0].stdout: worker 0 starting the stats thread
muru.host[90139]: worker 0 (pid 90140) is ready
muru.worker[1].stdout: worker 1 starting the stats thread
muru.host[90139]: worker 1 (pid 90141) is ready
...
muru.worker[0].stdout: flushed 3 requests
muru.worker[1].stdout: flushed 3 requests

The import check passes, the host preloads, and each worker gets its own thread. Note that each worker reports its own count. The counts dictionary started out shared from the host, but the moment a worker writes to it, it has its own copy. Anything that needs to be shared across workers has to live somewhere else, such as a database or a metrics system.

The database connection is dealt with in the same way. The module provides a connect() function rather than connecting on import, and a post_fork hook calls it, so that each worker opens its own connection. Alternatively, the connection could be opened lazily on first use, which many database libraries already do.

Beyond post_fork, there is on_load for things to run once in the host after the import, such as warming caches that are safe to share, and pre_fork which runs in the host before the first fork. In the workers, on_ready runs just before a worker starts taking requests, on_drain when it has been told to stop, and on_exit after its last request. When the fallback to per-worker imports is in effect, on_load and pre_fork are run in each worker before post_fork, so the same hooks module works either way.

Choosing how strict to be

What happens when the import check fails is controlled by the preload setting, which can be given as an option or set in [tool.muru]. The default is auto, which is what you have seen so far, preloading when the check passes and falling back with a warning when it doesn't.

Setting it to true makes a failed check an error. Going back to the version of the shop which connected on import:

muructl run shop.wsgi:application --preload true
muru.host[89241]: preload: the import of shop.db left a connected socket (fd 3, to 127.0.0.1:9340); close it in a pre_fork hook and reopen it in post_fork, or open it lazily on first use
muru.host[89241]: preload is true, so the import check failing stops the release
Error: the host exited with exit status: 1 before its workers were ready

This is what you want in production. Once an application has been made safe to preload, you don't want a change by someone that breaks that to quietly fall back to per-worker imports and go unnoticed until someone wonders why memory use went up.

Setting it to false skips preloading and the check altogether, with each worker importing the application:

muru.host[89381]: worker 0 started as pid 89382
muru.host[89381]: worker 1 started as pid 89384
muru.worker[1]: worker 1 imported shop.wsgi:application in 5 ms
muru.worker[1]: worker 1 serves shop.wsgi:application as WSGI
muru.worker[0]: worker 0 imported shop.wsgi:application in 13 ms
muru.worker[0]: worker 0 serves shop.wsgi:application as WSGI

That is for applications which can't be made fork safe, or where you have decided it isn't worth the effort.

Processes and threads

Preloading is about how workers come to exist. The other half of the process model is what runs inside each worker. By default a WSGI worker has four threads, each of which handles one request at a time, and you choose how many worker processes to have and how many threads each gets.

For most web applications, which spend their time waiting on databases and other services, threads work well. On a regular Python interpreter though, only one thread can be running Python code at a time because of the global interpreter lock (GIL). Waiting on a database releases the GIL so other threads can get on with things, but code that is busy computing holds it.

To show the effect, here is an application which does some pure Python computation for each request:

import sys

def work(n):
    total = 0
    for i in range(n):
        total += i * i
    return total

def application(environ, start_response):
    work(10_000_000)
    gil = "enabled" if sys._is_gil_enabled() else "disabled"
    body = f"done, GIL {gil}\n".encode()
    start_response("200 OK", [("Content-Type", "text/plain"), ("Content-Length", str(len(body)))])
    return [body]

Running this on a regular build of Python 3.15, with the default of one worker and four threads, and firing off four requests at the same time, all four had finished after about 1.3 seconds. The access log shows each request taking most of that:

200 GET / 18B 1.19s queue=0.0ms app=1.19s worker=0 id=01M6JWV35JYRMR8K10KBMQ1H5N
200 GET / 18B 1.21s queue=0.0ms app=1.21s worker=0 id=01M6JWV35JYRMR8K10KBMQ1H5J
200 GET / 18B 1.27s queue=0.1ms app=1.27s worker=0 id=01M6JWV35JYRMR8K10KBMQ1H5K
200 GET / 18B 1.28s queue=0.1ms app=1.28s worker=0 id=01M6JWV35JYRMR8K10KBMQ1H5M

There were enough threads, so nothing waited in the queue, but the four threads had to take turns holding the GIL, and so each request took about four times as long as it would on its own. This is a good illustration of why the queue and app times aren't the whole story. Here the time is being charged to the application, yet the code isn't any slower, it is waiting for the GIL. Muru does keep track of time spent waiting for the GIL separately, which I will come back to in a later post.

The traditional answer for CPU bound work is to use processes rather than threads. Running the same thing with --processes 4 --threads 1, so each request gets a process of its own, all four requests finished in about 0.5 seconds.

Free-threaded Python

Python 3.14 brought official support for free-threaded builds of the interpreter, where the GIL is removed and threads can run Python code in parallel. The uv tool knows them by names like 3.15t, so a project only needs a .python-version file containing 3.15t for uv venv to create its virtual environment from a free-threaded build of Python 3.15.

Muru supports these builds from the start. Its runtime library was written for them rather than adapted later, and it fetches the right build of the library for whichever kind of interpreter the project uses. Running the same application on the free-threaded build, again with one worker and four threads, all four requests were done in about 0.36 seconds:

200 GET / 19B 331ms queue=0.1ms app=331ms worker=0 id=01M6JWV8A36WZSBQEFEM4EZ7W5
200 GET / 19B 331ms queue=0.1ms app=331ms worker=0 id=01M6JWV8A36WZSBQEFEM4EZ7W7
200 GET / 19B 334ms queue=0.1ms app=334ms worker=0 id=01M6JWV8A36WZSBQEFEM4EZ7W6
200 GET / 19B 336ms queue=0.0ms app=336ms worker=0 id=01M6JWV8A36WZSBQEFEM4EZ7W4

The four threads ran in parallel within the one process, each request taking only as long as it would have on its own. These are rough numbers from my laptop rather than a proper benchmark, but the difference is clear enough.

This changes the trade-off between processes and threads. Processes still give you isolation, so that a crash, a memory leak or a stuck request takes down one worker rather than all of them. But you no longer need lots of processes just to make use of every CPU core, and fewer processes with more threads means sharing caches and connection pools rather than duplicating them.

One thing to watch for is that importing an extension module which hasn't declared support for free-threading causes the interpreter to turn the GIL back on. That is why the example reports whether the GIL is enabled. Your application can end up running on a free-threaded build with the GIL on and not realise it, which is something Muru keeps an eye on as well.

What's next

Having an application imported once, with the check to make sure that is safe, and with workers forked from it, is the foundation for much of what Muru does later, such as starting new releases quickly and scaling the number of workers up and down. The next post will be a shorter one, on something a development run does which most development servers don't, which is serve HTTPS with a certificate your browser will trust.

November 09, 2026 12:00 AM UTC


Armin Ronacher

AI and Irontown

For our last movie night we watched Princess Mononoke. I remembered loving that movie when I was younger, but watching it as an adult made me once again realize how Miyazaki has a way of expressing diverging sides without picking one. The film shows people, animals and spirits with legitimate and largely incompatible needs, and each faction is further engaged in infighting.

A central arc of the story is Lady Eboshi’s Irontown, which gives people who were largely rejected by society work, protection and dignity. She gives shelter and work to those who have leprosy and to former sex workers. The mine and the weapon factory provide them with a livelihood, but they also destroy the forest around them. The forest’s inhabitants, in particular the boars, response choose war against the humans to defend themselves. Eboshi is not portrayed as a villain. She does real good for the people of Irontown, but also is an arms manufacturer who is destroying the forest around it.

The film anchors around a character called Ashitaka, who has been condemned to death by an unfortunate encounter with a god that was turned into a demon after being fatally wounded by one of Eboshi’s guns. He doesn’t take sides, but he wants to understand and find the truth. Another character is San (whom they call Mononoke), a girl who was raised by wolves and, despite being human, considers herself part of the forest.

This movie’s backdrop is human progress, and it reminded me so much of the challenges we are facing today with AI. AI is useful, empowering and consequential beyond the people who benefit from it. It’s similar to the iron in the story. Irontown gives the outcast purpose and power. There are people today who see themselves in an environment just like that: people who were never let in before and who can now build things that were out of reach. They are wielding AI to their benefit. But there are negative externalities. In Mononoke it’s not just the spirits and animals in the forest who bear the consequences, it’s also other people. Irontown produces guns, and its people are engaged in fights with other humans. There is conflict and power seeking everywhere.

Another, unseen character is the power-hungry emperor, who wants the head of the forest spirit, a god, because he believes it will make him immortal. Eboshi is willing to support that cause, not because she thinks it’s right, but because she is at war with the forest’s gods who are preventing her expansion, so they have a common interest. But there really is no need for anyone to kill the forest spirit, in the same way there is no need for any one country to “win at AI”. What drives it is the fear of what happens if someone else wins first. And the emperor is willing to have a god killed for it. The people who help him with that desire, Eboshi included, don’t understand the chaos it will unleash.

There it also shows the dangers of seeking overwhelming power without comprehension of how to control its consequences. The pursuit of power itself becomes destructive. In the end, the gods die, and what grows back is not the old forest anymore. Irontown is destroyed. Eboshi wants to rebuild it, and to build a better town this time. Ashitaka stays with the people of Irontown after his curse is lifted by the dying forest spirit. San cannot forgive the humans and stays with the wolves. In the end, nothing is truly resolved, but there is a truce that came at great cost.

Ashitaka’s role in the movie is one of a neutral actor. He was wronged both by a spirit turned demon in the forest, who put a curse on him, and, indirectly, by Eboshi, who caused the spirit to turn evil. Yet he harbors no ill will towards either side. He sets out to “see with eyes unclouded by hate,” and in the course of the movie he is seen as an unaligned actor. He helps people on both sides, and stands in their way to prevent further conflict. He doesn’t feel like a pacifist, but he’s the closest to one there is. He’s a truth seeker in a changing world. Because he engages he’s seen as suspicious by both sides, and ends up shot by the people of Irontown when he tries to rescue San.

I use AI every day and find it genuinely empowering. But I also recognize the pain it causes some, and the moral problems it poses. It’s genuine progress for humanity, but it comes at significant cost. And many of those costs will be carried by people who aren’t going to benefit from it.

So it’s no surprise that AI feels like a threat to many today. Communities currently strongly align themselves with one side and fight against the other. The forest spirit did not have to die and the boars did not have to die in a senseless war.

The question is not whether there will be iron, but what kind of Irontown we build with it. I want our conversation about AI to make room for nuance and genuine understanding, for us to look at it and each other with eyes unclouded by hate. We should take advantage of its real benefits, but also recognize the damage it can do, and the choices we still have about what we build and what we’re willing to sacrifice for it. I like to think there is a path for people with differing opinions can find a way to live alongside before losing something we can’t get back.

November 09, 2026 12:00 AM UTC

November 08, 2026


LernerPython blog, from Reuven Lerner

New course: Introduction to agentic coding with Claude Code

I’m excited to announce my newest course, Introduction to agentic coding with Claude Code (https://lernerpython.com/courses/intro-agentic-coding-claude-code/).

If you want to go beyond giving simple prompts or using Claude Code in trivial ways, this is the course for you. I’ve distilled what I’ve learned from using Claude Code every day for about a year to run my business, create and improve open-source projects, and create new applications, and I’ve put them into this four-hour course.

Of course, it includes hands-on exercises. And it includes advice on using “uv” (for Python projects) and Git (for all projects).

The course is included in a LernerPython membership, which has a two-week free trial. Give it a shot at https://LernerPython.com !

The post New course: Introduction to agentic coding with Claude Code appeared first on LernerPython.

November 08, 2026 06:12 PM UTC

Pandas 3.1 filter: Selecting rows with a pd.col condition

Pandas 3.1 filter: Selecting rows with a pd.col condition

Starting with Python Pandas 3.1, use “filter” to apply a condition to rows. So:

(
  df
  .loc[ pd.col('passenger_count') > 1 , 'trip_distance']
  .mean()
)

can now be (the much slower):

(
  df
  .filter(cond=pd.col('passenger_count') > 1)
  ['trip_distance']
  .mean()
)

The post Pandas 3.1 filter: Selecting rows with a pd.col condition appeared first on LernerPython.

November 08, 2026 07:00 AM UTC


Graham Dumpleton

Muru: how it fits together

In a first look at Muru and looking inside the workers I have been showing what Muru can do in development mode, without saying much about how it is put together. Words like host, worker, edge and release have kept turning up in the output, and I have mostly waved my hands at what they mean. Before going any further, it is time to explain what those parts are and how they fit together, since a lot of what makes Muru different follows from that design.

The processes in a development run

When you run muructl run with two worker processes and look at the process tree, this is what you find:

59435     1 muructl run app:application --processes 2
59440 59435 .venv/bin/python -m muru.host --app app:application --release r1 --processes 2 --threads 4 ...
59443 59440 .venv/bin/python -m muru.host --app app:application --release r1 --processes 2 --threads 4 ...
59444 59440 .venv/bin/python -m muru.host --app app:application --release r1 --processes 2 --threads 4 ...

The muructl process started a Python process running muru.host. That host then forked the two workers, which is why they show the same command line as the host. There is no Python in the muructl process at all. It is a Rust program, and in development mode it contains two of Muru's main parts, the edge and the controller.

Mermaid diagram

The edge is the part which faces clients. It accepts connections, handles TLS, speaks HTTP/1.1 and HTTP/2, works out where each request should go, and passes it on to a worker. It also deals with the things that don't need Python at all, such as serving static files, compressing responses and producing error pages. Because it is written in Rust and never runs any Python code, a slow request in your application can't hold it up.

The host is a Python process which imports your application once and forks workers from it, which I will look at more closely in the next post. It never serves requests itself. Its job is to make workers, and to replace them if they die.

The workers are where your application runs. Each one is a Python interpreter with your code already loaded, plus a small layer written in Rust which talks to the edge and hands requests over to your code, either on a thread for WSGI or on the event loop for ASGI.

The controller decides what should be running. It starts and stops hosts, tells the edge where to send traffic, and keeps track of releases. Importantly, it is never on the path a request takes.

The path a request takes

Following a single request through the system shows how the work is divided up.

Mermaid diagram

The edge reads the request and decides whether it is something it can answer itself, such as a static file or a redirect, or whether it needs to go to the application. If it does, the edge picks which release should handle it according to the traffic policy, then which worker within that release, choosing the one with the fewest requests already in flight. If every worker is busy, the request waits in a queue at the edge. That time spent waiting is the queue figure in the access log, which I made a point of back in a first look at Muru.

The request then crosses to the worker over what Muru calls the worker link, a connection over a Unix socket. What travels over it isn't HTTP. The edge has already parsed the request, so the method, path, headers and details about the connection are sent as small typed messages, followed by the request body as it arrives. Nothing gets turned back into HTTP text, only to be parsed all over again on the other side.

In the worker, your application sees exactly what it would under any other server. A WSGI application gets an ordinary environ dictionary, an ASGI application gets an ordinary scope. The response then flows back over the same link, with the worker gathering up small pieces of a response before sending them, so an application which yields a thousand tiny strings doesn't cause a thousand separate writes. The edge writes the response to the client at whatever pace the client can take it. If the client is slow, that slowness is passed back through the edge and the worker to the application, which waits with the interpreter released, so nothing ends up being buffered without limit along the way.

Request bodies are handled on demand in the same way. The edge only reads more of the body from the client as the application reads it, so an application which rejects a request without looking at the body doesn't cause a large upload to be read in for nothing. Where the application responds with a file, the edge sends it to the client straight from the file, rather than the contents being copied through the worker.

What the extra hop costs

Anyone who has looked at how mod_wsgi daemon mode works will recognise the general shape here. There, the Apache child processes accept the connection and pass the request over a Unix socket to the daemon processes where the Python application runs. Muru does much the same, with a separate front end proxying requests to Python processes. The obvious question is what that costs compared with a server where Python reads the HTTP request directly off the socket.

The answer is one more copy of each byte, and some tens of microseconds of work per request, when both processes are on the same machine. Next to the time a typical Python web application spends handling a request, that is lost in the noise, and in return you get the edge doing all the client-facing work in Rust.

The bigger payoff though isn't performance. It is that the edge and the Python processes can come and go independently of each other. The edge can keep accepting connections while the application behind it is replaced. Workers can be recycled or restarted without dropping connections at the edge. More than one version of an application can be running at the same time, each on a different version of Python if need be. None of that is possible if the process handling the client connection is also the one running your Python code.

A release is a unit you can swap

That brings things back to releases. A release is a fixed combination of application code, Python version and settings, together with the host and workers running it. Releases are never changed once started. If something needs to change, a new release is started.

That is what is happening when development mode reloads after you save a file. The controller doesn't restart anything. It starts a new release beside the current one, and only moves traffic across once the new release is ready.

Mermaid diagram

If release r2 never becomes ready, because the code fails to import for example, the step that changes the routing never happens, and r1 keeps on serving as if nothing happened. That is what was going on in the first post when a broken import left the previous release serving.

Because moving traffic is just a change to the edge's routing, it doesn't have to be all or nothing either. The edge could just as easily send most requests to one release and a small share to another, or send particular clients to a preview of a new release. A reload in development mode is a straight switch, but the mechanism underneath is the same one used for blue/green deploys, canaries and rolling back.

The same parts under the daemon

In development mode, the edge and the controller both live inside the one muructl run process, and there is only ever one application. Running on a server under the daemon, the same parts are arranged a bit differently.

Mermaid diagram

The controller now lives in a long running daemon process, along with the admin API that muructl commands and the dashboard talk to, and a store which keeps track of what should be running. The edge runs as a separate process of its own. There can be many applications, and several releases of each running side by side, here with a new release of an application on a newer version of Python running beside the old one.

Keeping the daemon off the request path has a useful consequence. If the daemon is restarted or upgraded, the edge carries on serving requests using the routing it last received, and the hosts keep running. Replacing the edge is also handled without refusing connections, as the new edge process takes over the listening sockets from the old one. Traffic keeps flowing while the parts managing it are swapped out.

This is the part I most wanted to get right, given my history. Under Apache, the parent process owns the lifecycle of every child process, including the mod_wsgi daemon processes. Their number is fixed by the configuration, and changing anything significant means a restart of Apache, with everything that comes with it. With Muru, the thing that decides what should run is separate from the thing serving traffic, and both are separate from the Python processes. Each can be changed without disturbing the others.

The same model also applies when running in a container with muructl serve. There the edge, the controller, the host and its workers all run as the container's main process, much as in development mode, since a container is meant to run a single application.

What's next

That covers how the parts fit together. The daemon, and deploying an application to a server with releases and traffic moving between them, will come once I have finished with development mode. Before that, in the next post I will go back to the host, and how Muru imports an application once and forks its workers from it, along with what it does to make sure that is safe.

November 08, 2026 12:00 AM UTC

November 07, 2026


Graham Dumpleton

Muru: looking inside the workers

In a first look at Muru I made a point of the access log separating how long a request waited for a worker from how long it spent in the application. That tells you where a slow request lost its time, but not why. Sooner or later the question you end up asking is what the workers are actually doing, usually because a request is taking far longer than it should.

The traditional answers to that are to guess, to sprinkle print statements and timing code through the application, or to bring in a separate monitoring product. Having spent twenty years on mod_wsgi seeing what people struggle with when hosting Python web applications, and five years at New Relic working on exactly this problem for Python, I have some fairly strong opinions about how much of this a hosting system should be able to tell you itself. Quite a lot, as it turns out, is the answer Muru gives.

A slow report

To have something to look into, here is a small WSGI application with a deliberately slow report. Fetching the rows for the report pretends to wait on a database for a tenth of a second per month, and summarising them is pure Python arithmetic over eight million rows per month.

import time
from urllib.parse import parse_qs


def fetch_rows(months):
    time.sleep(0.1 * months)  # as if waiting on a slow database query
    return range(8_000_000 * months)


def summarise(rows):
    total = 0

    for row in rows:
        total += row * row % 7

    return total


def build_report(months):
    rows = fetch_rows(months)
    return f"{months} months: {summarise(rows)}\n"


def application(environ, start_response):
    query = parse_qs(environ["QUERY_STRING"])
    months = int(query.get("months", ["1"])[0])

    if environ["PATH_INFO"] == "/report":
        body = build_report(months)
    else:
        body = "ok\n"

    start_response("200 OK", [("Content-Type", "text/plain")])
    return [body.encode()]

Rather than giving everything as options to muructl run, Muru can read its settings from a [tool.muru] table in the project's pyproject.toml. Here they name the entrypoint, ask for two worker processes, and also set a soft timeout of three seconds, which I will come back to shortly:

[tool.muru]
entrypoint = "report:application"
processes = 2

[tool.muru.timeouts]
soft_timeout = "3s"

Start it with muructl run and ask for reports covering one month and then three months. The access log shows what we would expect:

200 GET /report?months=1 19B 382ms queue=0.0ms app=382ms worker=0 id=01M6T02DPVG7XRY5V2P2V9S7WG
200 GET /report?months=3 19B 1.15s queue=0.0ms app=1.15s worker=1 id=01M6T02E30G7XRY5V2P2V9S7WG

The queue time is zero and the application time is all of it, so we know straight away the problem is in our code and not a lack of capacity. Three months taking over a second also means a year is going to be painful.

Catching the worst case

By default a request in Muru can run for as long as it likes, up to a deadline at the edge of five minutes. Even when the edge gives up and answers the client, though, the request keeps going in the worker, tying up a thread for no benefit to anyone. That is what the soft timeout is for, and why I turned it on for this example.

Ask for twelve months, using JSON since that is easier to read here:

curl -H 'Accept: application/json' 'http://127.0.0.1:8080/report?months=12'

Three seconds later the response comes back:

{"type":"about:blank","title":"Gateway Timeout","status":504,"detail":"The request ran past the soft timeout of 3 s.","reason":"soft_timeout","request_id":"01M6T02F76G7XRY5V2P2V9S7WG","traceback":"..."}

When the request passed three seconds, a watchdog in the worker noted where the request was, then raised a muru.RequestTimeout exception in the thread running it. The exception unwound the report just like any other exception would, and Muru answered with a 504. The terminal says exactly where the request was when it was stopped:

muru.host[74087]: request.soft_timeout: worker 0 (pid 74088): request 01M6T02F76G7XRY5V2P2V9S7WG GET /report?months=12 ran past the soft timeout of 3 s at report.py line 14 in summarise, called from report.py line 21 in build_report, called from report.py line 29 in application
muru.worker[0]: Traceback (most recent call last):
muru.worker[0]:   File "/Users/graham/reports/report.py", line 29, in application
muru.worker[0]:     body = build_report(months)
muru.worker[0]:   File "/Users/graham/reports/report.py", line 21, in build_report
muru.worker[0]:     return f"{months} months: {summarise(rows)}\n"
muru.worker[0]:                                ~~~~~~~~~^^^^^^
muru.worker[0]:   File "/Users/graham/reports/report.py", line 14, in summarise
muru.worker[0]:     total += row * row % 7
muru.worker[0]:     ^^^^^
muru.worker[0]: muru.RequestTimeout
504 GET /report?months=12 638B 3.07s queue=0.0ms app=3.07s reason=soft_timeout worker=0 id=01M6T02F76G7XRY5V2P2V9S7WG

Only the one request was stopped, and the worker carried on serving others.

One detail worth knowing is that muru.RequestTimeout derives from BaseException and not Exception. A lot of application code catches Exception to log an error and carry on, and if the timeout were an Exception that code would swallow it and keep going. An application which does want to respond in its own way, such as telling the user to narrow down the date range, can still catch muru.RequestTimeout by name.

The soft timeout can only interrupt Python code. A thread stuck inside a C extension won't see the exception until the call returns. For that case there is also a hard timeout, which gives up on the whole worker and replaces it, but that is a topic for another time.

Which requests are in flight

A timeout tells you about a request after the fact. Often what you want is to know what is running right now, and with more than one worker, the first thing to work out is which worker a slow request is running in. A six month report takes about two seconds, so start one in the background while a few one month reports are also being requested, and ask Muru what requests are in flight:

curl -s 'http://127.0.0.1:8080/report?months=6' &
muructl requests
RELEASE              GROUP        QUEUED  QUEUED-OLDEST  THREADS  THREAD-OLDEST
report@r1            default      0       -              5/8      1.2s

Under the daemon you would need to say which application to look at, but a development run only ever serves the one application. The muructl commands find the run by its admin socket when you run them from the project directory, so there is nothing more to type.

This first view counts the requests for each release. Nothing is waiting in the queue, five of the eight threads across the two workers are busy, and the oldest request has been running for 1.2 seconds. Adding --list shows each request, oldest first:

muructl requests --list
AGE      STATE    KIND            WORKER  REQUEST                                  CLIENT           REQUEST-ID
1.2s     running  wsgi            1       GET /report?months=6                     127.0.0.1        01M6T02SA1G7XRY5V2P2V9S7WH
0.5s     running  wsgi            0       GET /report?months=1                     127.0.0.1        01M6T02T18G7XRY5V2P2V9S7WJ
0.4s     running  wsgi            0       GET /report?months=1                     127.0.0.1        01M6T02T56G7XRY5V2P2V9S7WJ
0.3s     running  wsgi            0       GET /report?months=1                     127.0.0.1        01M6T02T94G7XRY5V2P2V9S7WJ
0.1s     running  wsgi            1       GET /report?months=1                     127.0.0.1        01M6T02TD2G7XRY5V2P2V9S7WJ

The six month report is the oldest, and it is running in worker 1.

Strictly speaking, this isn't looking inside the workers at all. The answer comes from the edge, which knows about every request it has passed to a worker, along with how long it has been running and which worker it went to. Nothing has to be asked of the workers to get it, so it costs them nothing, and it still answers even when a worker is too wedged to respond. That is why it is the first place to look. Once you know which worker a request is in, the more expensive tools can be pointed at just that worker.

There is an equivalent muructl websockets command for WebSockets, which aren't being used here. It counts the WebSockets open for each endpoint, and with --list shows each one, the longest open first.

Seeing a request in progress

Knowing the six month report is in worker 1, the next step is to see what it is doing. Asking for the stacks of that worker:

muructl debug stacks --worker 1

Among the threads shown is the one serving the report:

muru-app-1 in worker 1:
  serving GET /report?months=6 for 1.3 s (request 01M6T02SA1G7XRY5V2P2V9S7WH)
  File "/Users/graham/Library/Caches/muru/runtime/0.1.0a18/cp315t/muru/_pool.py", line 30, in serve_jobs
    serve()
  File "/Users/graham/reports/report.py", line 29, in application
    body = build_report(months)
  File "/Users/graham/reports/report.py", line 21, in build_report
    return f"{months} months: {summarise(rows)}\n"
  File "/Users/graham/reports/report.py", line 14, in summarise
    total += row * row % 7

It shows which request the thread is handling, how long that request has been running, and where in the code it is at that moment. Leave off --worker and you get every thread in every worker instead, all without stopping anything.

How the stacks are obtained depends on the platform. On Linux with Python 3.15 or later, Muru reads the stacks from outside the worker process using the remote debugging support added by PEP 768, so it works even if a worker is wedged in C code while holding the interpreter. On macOS, where reading another process needs root, the worker is asked to report its own stacks instead, which is what happened here.

Where the time goes

A stack is a single moment in time. To know where the time goes overall, you want a profile, which samples the stacks repeatedly over a period and counts where they were. To get some load to profile, keep reports coming in another terminal:

while true; do curl -s 'http://127.0.0.1:8080/report?months=2' > /dev/null; done

and then profile the workers for ten seconds:

muructl debug profile --for 10s --output report.folded
808 samples of running Python code in 1632 rounds across 2 workers
   73.4%  summarise (report.py:10)
   26.6%  fetch_rows (report.py:5)
muru: collapsed stacks written to report.folded

Three quarters of the time is going on summarising the rows and a quarter on waiting for them, so a faster summary would be worth a lot more than a faster query. Being able to say that with evidence, rather than a hunch, is the whole point.

The file written out holds the collapsed stacks with their counts, which is the format flame graph tools expect, so flamegraph.pl can turn it into an SVG or you can open it in speedscope in a browser. If the application has many endpoints and only one of them is a problem, the profile can also be restricted to requests for a particular path.

Sampling every so often tells you proportions. If you want exact numbers instead, muructl debug trace counts every call of every Python function over a period, along with the time spent in each:

muructl debug trace --for 3s
worker 0 (pid 74088)
    total ms     calls  function
    1483.823         2  application (report.py:24)
    1483.674         2  build_report (report.py:19)
    1073.508         2  summarise (report.py:10)
     410.103         2  fetch_rows (report.py:5)
       0.095         2  parse_qs (parse.py:859)
       0.056         2  parse_qsl (parse.py:902)
       0.042         4  parse_qsl.<locals>._unquote (parse.py:937)
       0.031         4  unquote_plus (parse.py:983)
       0.014         4  unquote (parse.py:836)

worker 1 (pid 74089)
    total ms     calls  function
     736.969         2  application (report.py:24)
     736.857         2  build_report (report.py:19)
     531.764         2  summarise (report.py:10)
     410.168         2  fetch_rows (report.py:5)
       0.104         2  parse_qs (parse.py:859)
       0.064         2  parse_qsl (parse.py:902)
       0.048         4  parse_qsl.<locals>._unquote (parse.py:937)
       0.036         4  unquote_plus (parse.py:983)
       0.018         4  unquote (parse.py:836)

Tracing every call is a lot more expensive than sampling, so Muru will refuse to trace a worker which already has more than half of its threads busy with requests unless forced to with --force. The worker does keep serving while the trace runs though, since the requests it takes in that time are what the trace is measuring. Those requests are slowed down by the tracing, so this isn't something to leave running for long against an application people are using.

Memory and running code

Slow requests aren't the only thing that goes wrong. Python web applications also have a habit of slowly growing in memory until something gives. Running muructl debug memory reports for each worker its memory use, the garbage collector's counts and the object types it holds most of. Adding --trace turns on tracemalloc for a period and reports the places in the code which allocated the most memory during that time, and there are options for taking a snapshot at one point and comparing against it later.

When all else fails, muructl debug exec will run a snippet of code inside the workers and print the result, which is handy for checking the state of some cache or other in a running process:

muructl debug exec -- 'import sys; sys.version'
worker 0 (pid 74088):
'3.15.1 free-threading build (main, Oct 21 2026, 17:44:52) [Clang 23.1.3 ]'
worker 1 (pid 74089):
'3.15.1 free-threading build (main, Oct 21 2026, 17:44:52) [Clang 23.1.3 ]'

Under the daemon this one is turned off unless explicitly allowed in the daemon's configuration, since it lets someone run arbitrary code inside a production application. In a development run it is always on, since the person at the terminal is the one who started the workers in the first place.

Stopping at a breakpoint

All of the above is about looking at a worker from the outside. Sometimes though you really do want to stop in the middle of a request and poke around, and the usual way to do that in Python is a call to breakpoint().

The catch is that with a normal development server, breakpoint() drops you into pdb on whatever terminal the server's output is going to, interleaved with log output from everything else. In Muru, a worker which hits breakpoint() instead waits for a debugger to attach, and the terminal tells you how to do that. Add a breakpoint() call to build_report() after the rows are fetched, save the file so it reloads, make a request, and the terminal shows:

muru.host[75915]: debug.breakpoint: worker 0 (pid 75918): breakpoint() at /Users/graham/reports/report.py:21 waits 60 s for muructl debug attach report --worker 0

The end of that line gives the command to run, which is muructl debug attach report --worker 0. Running it from another terminal opens a pdb session in the worker, at the point the request stopped:

muructl debug attach report --worker 0
muru: attached to worker 0 at /Users/graham/reports/report.py:21; the end of input lets the code go on
> /Users/graham/reports/report.py(21)build_report()
-> breakpoint()
(Pdb) p months
1
(Pdb) continue
muru: the session in worker 0 has ended

Typing continue lets the request carry on, and it completes as normal. While the session was open, the worker was taken out of routing so it wouldn't be given any new requests, leaving those to worker 1. The terminal records that, along with the worker being put back once the session ended:

muru.host[75915]: worker 0 (pid 75918) leaves routing while it is debugged
muru.host[75915]: debug.detached: worker 0 (pid 75918): the debugger session at /Users/graham/reports/report.py:21 ended
muru.host[75915]: worker 0 (pid 75918) serves again: no debugger is attached
200 GET /report?months=1 19B 2.28s queue=0.0ms app=2.28s worker=0 id=01M6T043M4G7XRY5V2P2V9S7WJ

The request was also exempt from the soft timeout, so it wasn't killed off while I was looking at it. If nobody attaches within the timeout, the request just carries on by itself.

For those who prefer to debug from an IDE, muructl debug attach --dap speaks the Debug Adapter Protocol instead, so something like VS Code can attach to the worker through muructl. That needs debugpy installed in the project's environment, and I will leave the details of that to the Muru documentation.

The dashboard

Everything so far has been done from the command line. When you just want to keep an eye on things, the run also serves a dashboard, by default at http://127.0.0.1:8081/.

The Muru development dashboard showing the last minute's requests, latency and errors, the two workers and how busy they are, charts over time, and the most recent requests

The top of the page shows the last minute at a glance, with requests per second, the 50th and 95th percentile latency, the count of server errors, how many requests are in flight and how many WebSockets are open. Below that is a table of the workers and how many of their threads are busy, charts of the same figures over time which can be switched to cover the last hour or the last day, the most recent requests with the same queue and application times as the access log, and the latest output from the run. A link from the workers table opens a page for each worker with its status, and the same stacks, tasks and memory figures the muructl debug commands give. In a development run there is no sign-in, and the dashboard is read-only, so nothing on it can change what the run is doing.

The same address also serves a /metrics endpoint in the Prometheus format, so if you do want to point some other monitoring system at a development run, you can.

What's next

What I like about all this is that none of it required changing the application, installing an agent, or restarting anything, and all of it works the same way against an application running under the daemon in production. Along the way I have been talking about hosts, workers, the edge and admin sockets without really explaining them, so in the next post I will step back and look at how the parts of Muru fit together.

November 07, 2026 12:00 AM UTC


Peter Hoffmann

Stable URLs for 42,000 Huts and Peaks

biwak.info is my map of Alpine huts, bivouacs and peaks. For a long time the only way to link to a hut was a #Name hash that did an exact name match and fell back to the search when the name wasn't unique. I wanted real URLs I can share and that show a proper preview card in a chat: biwak.info/p/matterhorn.

The data comes from OpenStreetMap: 9,429 huts and bivouacs and 33,437 named peaks, 42,866 places in total. Turning a name into a URL is easy. The hard part is that many places share a name, and that a URL, once shared, should keep pointing to the same place, even when the data changes underneath.

The preview card for https://biwak.info/p/bivouac-des-periades

From a name to a slug

The first step is a plain slugify: German umlauts are written out, everything else is folded to ASCII, lowercased, and every run of other characters becomes a single dash.

GERMAN_TRANSLIT = str.maketrans({"ä": "ae", "ö": "oe", "ü": "ue", "Ä": "Ae", "Ö": "Oe", "Ü": "Ue",
                                 "ß": "ss", "ẞ": "ss"})


def slugify(text):
    """German transliteration for umlauts and ß, then ASCII-fold, lowercase, everything else to single dashes."""
    text = (text or "").translate(GERMAN_TRANSLIT)
    text = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode("ascii").lower()
    return re.sub(r"[^a-z0-9]+", "-", text).strip("-")

So Oberaletschhütte becomes oberaletschhuette, Štuhecov dom becomes stuhecov-dom and Rifugio Città di Busto becomes rifugio-citta-di-busto. None of the 42,866 names ends up empty.

Names are not unique

Alpine names repeat a lot. Every valley has its Jagdhütte and its Rosskopf. With the name alone, the most common slugs are:

Slug Places
jagdhuette 70
schwarzkogel 37
buchberg 32
rosskopf 32
mitterberg 31

Before choosing a scheme, I measured how many places would still share a slug with different suffixes:

Slug made of Shared slugs Places affected Worst
name 2,181 6,690 (15.6 %) 70
name + country 1,993 5,936 (13.8 %) 64
name + region 1,516 3,932 (9.2 %) 27
name + region + elevation 118 394 (0.9 %) 21

Adding the country barely helps: most duplicates are in the same country. The region roughly halves them, but there are still 27 places called Jagdhütte in Carinthia alone. Elevation fixes most of the rest. It can't fix everything, because 4,311 of the huts have no elevation in OpenStreetMap. The worst case in the last row is casella in Liguria: 21 small huts with the same name and no elevation.

So there is no single suffix that always works, and adding all of them to every URL would make /p/matterhorn look like /p/matterhorn-wallis-4478.

Add a suffix only while the name is shared

The rule I settled on: a place gets the shortest slug that is unique, trying these in order:

  1. the plain name: /p/matterhorn
  2. name and region: /p/oberaletschhuette stays plain, but a shared name becomes something like /p/rosskopf-tirol
  3. name, region and elevation: /p/rosskopf-tirol-2257
  4. name, region and OpenStreetMap id: /p/jagdhuette-kaernten-w1202594655

In code this is a generator that yields the candidates for one place, given all current places with the same name slug:

def candidates(p, group):
    """Slugs for POI p in order of preference; `group` = all current POIs with the same name slug."""
    base, region = p["base"], slugify(p["region"])
    others = [o for o in group if o["id"] != p["id"]]
    if not others:
        yield base
    if not any(slugify(o["region"]) == region for o in others):
        yield f"{base}-{region}"
    if p["ele"] is not None and not any(slugify(o["region"]) == region and o["ele"] == p["ele"] for o in others):
        yield f"{base}-{region}-{p['ele']}"
    yield f"{base}-{region}-{p['id']}"

The OpenStreetMap id is the last resort because it's always unique, but nobody can read it. On today's data the 42,866 places split like this:

Slug Places
plain name 36,176
+ region 2,758
+ elevation 3,474
+ OpenStreetMap id 458

Regions from Natural Earth

The region has to be something people recognise. I cut the Natural Earth admin-1 boundaries down to the units that make sense in the Alps: the Bundesland in Austria and Germany, the Kanton in Switzerland, the département in France (Savoie and Haute-Savoie are better known than the big régions), the regione in Italy with South Tyrol and Trentino split, and the statistical regions in Slovenia. Names use the German form where that is common (Wallis, Tessin, Aostatal), otherwise the local one. The places on the map fall into 63 of these regions.

Each place gets its region by a point-in-polygon lookup with Shapely. The boundaries are simplified, so a hut right on a border or a lake shore can fall outside every polygon. Then the nearest polygon wins:

def lookup(self, lat, lon):
    """(cc, region name); points just outside every polygon (coast, simplified border) take the nearest."""
    pt = Point(lon, lat)
    for i in self.tree.query(pt):
        if self.geoms[i].contains(pt):
            break
    else:
        i = self.tree.nearest(pt)
    return self.props[i]["cc"], self.props[i]["name"]

The region is also shown on the preview card and in the detail view, next to the type: "Biwak · Haute-Savoie, Frankreich".

Stable: a registry that never forgets

The scheme above depends on the data. If someone adds a second Matterhorn to OpenStreetMap tomorrow, the plain rule would rename the real one to matterhorn-wallis, and every link shared so far would break.

So the build keeps a slug registry, data/slug-registry.json, which is committed to git. It maps every slug ever handed out to the OpenStreetMap id and the name:

"matterhorn": ["n26863664", "Matterhorn"],
"oberaletschhuette": ["n11262553552", "Oberaletschhütte"],

The assignment then follows three rules:

primary = {}
for p in pois:
    fitting = [s for s in by_id.get(p["id"], []) if s == p["base"] or s.startswith(p["base"] + "-")]
    if fitting:
        primary[p["id"]] = min(fitting, key=lambda s: (len(s), s))

for base in sorted(groups):
    for p in groups[base]:
        if p["id"] in primary:
            continue
        slug = next(s for s in candidates(p, groups[base]) if s not in registry)
        registry[slug] = [p["id"], p["name"]]
        primary[p["id"]] = slug

A place that disappears from OpenStreetMap keeps its slug reserved. Opening it starts a search for the old name instead of showing someone else's hut.

Because the registry is a sorted JSON file with one slug per line, a build that changes slugs shows up as a readable git diff. That made one mistake easy to spot: the first version folded ü to u, so the Oberaletschhütte was /p/oberaletschhutte. Two weeks after launch I switched to ue, which changed 7,182 slugs. I rebuilt the registry from scratch, so the old slugs are gone instead of redirecting.

Two fallbacks

A plain name that is shared, like /p/rosskopf, doesn't return an error. It opens the search with exactly the places of that name, with their type, elevation and region, and each of them links to its own slug. The same works one level down for /p/rosskopf-tirol.

A slug that ends in an OpenStreetMap id always resolves, even if the name part is wrong or outdated. /p/old-name-n11262553552 answers with a 301 to /p/oberaletschhuette.

Limits

The same /p/ URLs also let the map open peaks directly, as described in Loading 33,000 Peaks on Demand with a z10 Tile Grid.

November 07, 2026 12:00 AM UTC

November 06, 2026


"Michael Kennedy's Thoughts on Technology"

Catch the import. Name the culprit. Keep threads free: Introducing gilguard

Catch the import. Name the culprit. Keep threads free: gilguard

TL;DR: gilguard is a tiny, zero-dependency package for free-threaded Python (3.13t, 3.14t, 3.15t). Import it first. If any later import quietly turns the GIL back on, you get an error naming the extension module, the package that shipped it, and the line of your code that pulled it in. python -m gilguard audits an entire environment in one go. It’s on PyPI now, and it’s the second piece of a larger free-threaded Python effort I’ve been working on.

The quiet failure mode of free-threaded Python

Free-threaded Python is officially supported as of 3.14, and I’m all in. Threads that actually run Python code in parallel? Yes please. But the migration has a failure mode that I think is going to bite a lot of teams, and there isn’t much written about it.

Here’s the scenario. You move a service to 3.14t. You give the hot path a thread pool. You deploy, pull up the dashboard, and the throughput graph does… nothing. It’s the exact same shape as last week, as if the whole migration had been a dream.

An hour later you find the reason, one line in a startup log that scrolled past in half a second:

RuntimeWarning: The global interpreter lock (GIL) has 
been enabled to load module 'somepackage._speedups', 
which has not declared that it can run safely without the GIL.

That line is CPython being careful. Every C extension has to declare that it’s safe to run without the GIL (via the Py_mod_gil slot). When the interpreter loads one that hasn’t, it turns the GIL back on for the rest of the process and carries on. Your threads still run, they just take turns again. And here’s the kicker: once the GIL is on, nothing can turn it back off for the life of that process.

The warning names a private module you’ve never heard of. It doesn’t tell you which package you installed shipped it, or which of your imports dragged it in. So now you’re bisecting imports.

What is gilguard?

gilguard turns that one line into an exception, raised at the import statement that caused it. Make it the first import in your program and you’re done. You don’t call anything or configure anything:

import gilguard  # must be first

import fastapi
import pandas  # <- raises here if anything in pandas' import tree needs the GIL

When something in that tree forces the GIL back on, this is what you see instead of a warning you’d miss:

gilguard.GILEnabledError: Importing 'gilneeder._speedups' re-enabled the GIL.

  Module:       gilneeder._speedups
  File:         .../site-packages/gilneeder/_speedups.cpython-314t-x86_64-linux-gnu.so
  Distribution: gilneeder 1.2.0
  Import chain: gilneeder -> gilneeder._speedups
  Your code:    /home/me/app.py:5
  Imported by:  .../site-packages/gilneeder/__init__.py:1

This extension module does not declare free-threading 
support (Py_mod_gil), so CPython turned the GIL back 
on for the rest of the process. It cannot be turned off again.

Options:
  - Upgrade gilneeder to a release that ships free-threaded (cp314t) wheels.
  - Drop or replace gilneeder.
  - If you have verified it is thread-safe, run with PYTHON_GIL=0 to keep the GIL off (at your own risk).
  - Run `python -m gilguard` to list every installed package that does this.

(gilneeder is one of gilguard’s test fixtures, a tiny C extension built to misbehave so the real ones don’t have to.)

Everything you’d otherwise be asking a debugger for is right there: which module flipped it, what file it lives in, and which distribution and version shipped it (so you know what to upgrade or report). You also get the import chain from your code down to the culprit, which matters when the offender is a dependency of a dependency of something you installed for one function, and the line in your code where it all started.

You might notice that GILEnabledError derives from BaseException rather than Exception, putting it alongside KeyboardInterrupt. Here’s why. Lots of packages have an optional speedup import like try: import fastthing / except ImportError: import slowthing. If gilguard raised a normal exception, that except would eat it, the package would quietly fall back to its pure-Python path, and your app would carry on with the GIL already back on. The report would end up inside someone else’s except block, where nobody would ever see it. With BaseException, it sails right past those handlers. To catch it, you have to ask for GILEnabledError by name (or use a bare except:, and you know better).

Not ready to fail hard? Set GILGUARD_ACTION=warn and you get the same report as a warning while the program keeps running. Set it to off and the same code runs on a standard build.

How do I find every offender in an environment?

A single process can only tell you about the first module that flips the GIL. After that it’s already on, and the rest have nothing left to flip. So gilguard also ships a scanner:

python -m gilguard
GIL    gilneeder                via gilneeder._speedups  [gilneeder 1.2.0]

8 checked: 7 ok, 1 re-enable the GIL, 0 failed to import.

It gives every installed package its own fresh interpreter (in parallel, across your CPUs), so none of them can hide behind the one that went first. It exits 1 when it finds anything, so it drops into CI as a single step without any extra scripting. There’s --json for tooling and --strict if a package that fails to import should fail the build too. Pass package names to check just those, which is how you re-check one after an upgrade to see if the fix took.

The scan has one blind spot: extensions a package loads lazily, on first use of a feature, don’t show up at import time. For those, run your test suite with gilguard loaded as a plugin:

pytest -p gilguard

pytest loads -p plugins before it touches conftest.py or any test module, so the guard is watching before your code imports anything. The two checks cover different gaps. The scanner sees every installed package but only at import time, and the test suite sees lazy loads but only on the code paths your tests run. Use both. Tests and CI has a GitHub Actions job that does exactly that.

What does a clean run actually mean?

“The GIL stayed off” is easy to over-read. Here are the limits. A clean run means none of the imports that actually ran turned the GIL back on. It does not mean your code or your dependencies are thread-safe, and it does not mean your program gets faster with more threads. Those are separate problems with their own tests. gilguard catches one specific failure, and that’s all it tries to do.

Once you have a name, the docs have a whole page on what to do with it: upgrade, report upstream (with exactly which lines to paste into the issue), replace, or, if the extension is yours, the one slot that declares support, with the one-line equivalents for Cython, pybind11, and PyO3.

Why I built this

When I announced chameleon-rs ten days ago, I said free-threaded Python means we need to start expecting more from our web apps. What I didn’t say is that chameleon-rs was the first piece of a larger free-threaded effort, and just the first one I am able to talk about publicly. gilguard is the second.

It’s a much smaller piece, a few hundred lines of plain Python with no dependencies. But it’s the kind of building block everything else depends on. There’s no point making a template engine or a web framework thread-safe and fast if one dependency turned the lock back on at startup and nobody noticed for a month. Before you can make free-threaded Python fast, you have to be able to trust that you’re actually running free-threaded.

My goal with all of this work is simple to state: free-threaded Python faster, safer, better, and adopted sooner. gilguard is my contribution to the “safer” and “sooner” parts. I’ll write about the rest as soon as I’m able to.

Try it

uv pip install gilguard

Then import gilguard at the top of your entry point, or run python -m gilguard against your current environment and see what’s lurking. I’d love to hear what it finds. The docs are short and the code is on GitHub. If you use an AI coding agent, the docs also ship an agent skill that teaches it the rules (first import, don’t catch the error, what a pass means), so it can wire gilguard into your project and CI without you explaining any of this.

November 06, 2026 11:29 PM UTC


Mike Driscoll

An Intro to Python Logging Handlers (Video)

Learn about Python’s log handlers, part of the built-in logging module, in this video.

This video is a part of my Python Logging Video Course. Check it out today!

The post An Intro to Python Logging Handlers (Video) appeared first on Mouse Vs Python.

November 06, 2026 07:08 PM UTC


Tryton News

Security Release for issue 15206

Clément Mercier using Claude has discovered that it is possible to retrieve the ephemeral data from the wizard execution of another user.

Impact

CVSS v3.0 Base Score: 2.6

Workaround

There is no known workaround.

Resolution

All affected users should upgrade trytond to the latest version.

Affected versions per series:

Not affected versions per series:

Reference

Concerns?

Any security concerns should be reported on the bug-tracker at https://bugs.tryton.org/ with the confidential checkbox checked.

1 post - 1 participant

Read full topic

November 06, 2026 01:00 PM UTC

Security Release for issue 15205

Clément Mercier using Claude has discovered that there is no restriction on running wizards without any model or group.

Impact

CVSS v3.0 Base Score: 8.1

Workaround

Add at least one group to the listed wizards in the issue via “Administration / User Interface / Actions / Wizards”.
This must also be done after the update in order to allow users to execute the said wizards.

Resolution

All affected users should upgrade trytond to the latest version and ensure that no unwanted users have been created on the system.

Affected versions per series:

Not affected versions per series:

Reference

Concerns?

Any security concerns should be reported on the bug-tracker at https://bugs.tryton.org/ with the confidential checkbox checked.

4 posts - 3 participants

Read full topic

November 06, 2026 01:00 PM UTC


LernerPython blog, from Reuven Lerner

Pandas 3.1 `select` method: Retrieving specific columns

Pandas 3.1 `select` method: Retrieving specific columns

Want to retrieve specific columns from your Python Pandas data frame? In the past, you would say

df[['x', 'y']]

But now, with version 3.1, you can use the “select” method:

df.select('x', 'y')

The post Pandas 3.1 `select` method: Retrieving specific columns appeared first on LernerPython.

November 06, 2026 07:00 AM UTC


Core Dispatch

Core Dispatch #12

Welcome back to Core Dispatch! This edition covers October 9 through November 6, 2026. Python 3.16.0 alpha 1 is out, with alpha 2 due this Tuesday, November 10. October kept the release team busy: we shipped 16 Python releases, though only five were planned. Ten of the eleven extras were security releases, some of which we cut during the core team sprint. Huge thanks to everyone who helped get them out. The October 26 releases block the compromised eMudhra intermediate CA keys in ssl and include additional security fixes.

On the PEP front, two new proposals cover wheel-variant providers (PEP 850) and list.discard() (PEP 851). The pretty print protocol (PEP 813) is back for discussion after a substantial revision at the core team sprint. The proposed roadmap for a supported JIT (PEP 836) and the proposal to drop LLVM as a JIT build requirement (PEP 774) have both been withdrawn. The Steering Council has made no further decision about the JIT.

On CPython's main branch, notable merges speed up UUID operations, numeric strptime() parsing, and buffered asyncio reads. Free-threaded builds get safer memoryview operations and a fix for a crash in asyncio.

Community updates include proposals for better performance tooling, ecosystem testing with snake_pit, and the updated AI-policy triage process.

Please test your projects with Python 3.16.0 alpha 1 and file any CPython issues you find.

Upcoming Releases

Official News

PEP Updates

Steering Council Updates

Merged PRs

Discussion

Core Dev Musings

Upcoming CFPs & Conferences

Community

One More Thing

"We are not going to establish a social credit system on discuss.python.org!"

— Emma Smith

"Yeah we messed up and left your time machine in Monday by accident. I’m asking research for a loaner so we can go back and get it."

— Brandt Bucher

Credits

November 06, 2026 12:00 AM UTC

November 05, 2026


Glyph Lefkowitz

Why You Should Care About The Political Project of Twisted

Twisted was originally the network component of a text-based role-playing game engine. In the late 90s when it was first developed, the idea of a video game being able to send you actual emails and instant messages1 seemed pretty neat, rather than the valence that it would have today (nightmare). We never ended up making an actual game, so we eventually removed the “game engine” piece, and just kept the protocol implementations of the email and the AOL Instant Messenger, and the DNS and eventually SSH and so on. The residual pieces of that project have not seen maintenance for a decade.

So it’s not really a way to make a text-based game any more.

Then, Twisted was the premier longest-lived and best-maintained event-driven I/O library for Python. If you wanted to make high-performance servers, it was the best and most obvious way to do it. Even if it still acquits itself relatively well in this regard, its claim to fame, and to indispensability, has been somewhat obviated by the inclusion of asyncio in the standard library. Even if it has some stuff to recommend it — better-than-average testing facilities, for example — you can get better-documented and better-integrated low-level asynchronous networking tools without even installing any packages.

So it’s not really the premier choice for an event-loop these days.

Twisted does still contain an SSH server and client, an email server and client, a DNS server and client, a web server and client (now including websockets). But you can cobble these together by installing a variety of packages from other sources.

So... it’s a bunch of protocols? And I guess it doesn’t use AI? But... why would anyone care about that? Why contribute to such a thing?

What is Twisted doing, and who is it doing it for? What is the political project of Twisted, nigh on 30 years into its existence?

Most of all, why should you care about it, or want to contribute?

If you’re asking what you could use Twisted for today, it’s an excellent choice if you’re implementing a new network service and you need more than one protocol. If you need to do HTTP, DNS, packet capture, and a custom SSH server, it would be very hard to arrange a collage of other Python packages to manage this. It’s still much better at deterministic testing than most of the standard library. It’s a bit easier to hook into a GUI.

But that’s not what Twisted is doing.

What I want Twisted to do for the world is still an unfinished project. It’s honestly kind of bad at doing what I want it to, but it could be great. The overarching goal of having and maintaining all this stuff is to, as much as possible, demystify networked software operation and let you build Internet facing systems at a human scale.

It’s all in a memory-safe language with good error handling, so you can avoid the scariest security pitfalls. It’s all integrated into a single paradigm with built-in tools for testing. It’s designed to scale down to be runnable on the average laptop, and not require large elaborate server farms or distributed clusters to run. Via the twist command line utility, it provides a Swiss army knife of simple tools that you can use out of the box.

In a world where all of our compute is in massive distant opaque datacenters, where a tiny cartel of gigantic tech companies control nearly the whole supply chain, one of the few things that you still can have agency over is your own server on the Internet.

It’s still possible to get a domain name for a few bucks a year, a free-tier or very low-cost VPS, then stick a glue record in and run your own DNS. From that point forward, you can run your own email, your own web server, your own terminal server. But doing this requires deep expertise, and that expertise requirement is massively inflated by the operational concerns that go with running twenty different pieces of software from twenty different projects, probably in twenty different languages, with completely inconsistent aesthetics.

The reason that Twisted is event-driven is because as much as it can be a pain to wrap your head around async/await (or worse yet, Deferred directly), it is much easier for a single person to understand the concurrency requirements of an async I/O multiplexed system than to understand what is going on with an application that requires multiple thread pools. It is possible to understand concurrent code that manages parallel tasks in that idiom. The reason that Twisted integrates so many different protocol implementations and attempts2 to unify them with a common authentication layer is to reduce the overhead required to manage a comprehensive suite of network services.

Even the not-using-AI thing is not an aside due to some accidental personal preference about tools — it’s a core part of building an engine that you can personally understand if you dive into it, that’s built with a consistent style and layered in a way that you can take apart if you need to. It’s not built with AI because we’re not building for AI, we’re building for people and the entire point is that people are supposed to be able to understand it.

We used to have a tag line, which is somewhat less emphasized these days because its meaning is not immediately clear without this background, but was once quite prominently featured on the home page: “Twisted: Building the Engine of Your Internet”. That’s what I want to bring to you: an engine for you to build your Internet, that is small enough that you can make it personal, that doesn’t require massive investment in management tooling just to get started.

Now, for various reasons, I no longer use twisted.names to host my own DNS (although I did for many years!), and we never wrote enough of a twisted.mail suite for it to be usable for email hosting on its own. But, if you’re reading this article, it was probably delivered to you directly via Twisted’s own twisted.web HTTP server, using twisted.protocols.tls to configure our own OpenSSL TLS termination. I have run with extremely high reliability for years and next to zero management overhead, in a single simple docker container. Which I then administer and upload files to via Twisted’s own twisted.conch SSH server.

If that seems like an appealing project and you’d like to volunteer to help out, drop me a line and maybe I can point you at some tasks to get started. Twisted falls far short of this aspiration despite 30 years of work, and we could use all the help we can get.


Acknowledgments

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


  1. Yes, it was the 90s, we didn’t have “texts” yet, it was all “IMs”. ↩

  2. — with varying degrees of success — ↩

November 05, 2026 10:51 PM UTC


PyCharm

What’s New in PyCharm 2026.3

At the center of this release is JetBrains Air – a new layer for agentic development. It lets you run, track, and verify multi-agent workflows directly inside your IDE, with code intelligence keeping agents on track and complete review tools in your hands. This release also brings workspaces that adapt terminals and consoles to individual subprojects, initial support for Python 3.15 lazy imports, interactive FastAPI dependency graphs, and deeper Pydantic inspections, and so much more.

Check out an overview video by our developer advocate William Vincent:

Now, let’s take a closer look at the key highlights.

JetBrains Air in PyCharm – One place to run your agents and verify their work 

As of this release, JetBrains IDEs ship with Air, a new layer for agentic development. Work with the coding agents of your choice, and run, track, and verify multi-agent workflows inside your IDE, where code intelligence helps agents make fewer wrong turns and gives you the tools to review every change before it ships.

Your agents, your models, your subscriptions – with Junie Lite for free

Air works with Claude Agent, Codex, Gemini, GitHub Copilot, Junie, OpenCode, and other ACP-compatible agents. It detects the agents already installed on your machine so you can connect them in a click, and you can add more from the agent registry.

Your existing setup carries over, too: provider subscriptions, API keys, and even a custom base URL all work, with no JetBrains AI subscription required. Run Claude Agent on your Claude subscription in the terminal or in a full-screen IDE tab, use other agent subscriptions in the chat or terminal, and switch between subscriptions at any moment, even mid-session.

Air includes free AI with the Junie Lite configuration, so you can use an agent without a subscription. You only need a JetBrains Account.

A central workspace to review changes and guide the agent

Review an agent’s changes with the surrounding code and your project tools in view, and send feedback straight back to the agent. Anchor a note to a specific line in the diff, or comment directly from the code, so the agent gets concrete context about the change you want. Work in the chat or the terminal, whichever suits you.

Still using AI Assistant? A notification will offer you a trial of Air. Moving to Air hides AI Assistant, if you wish to return to AI Assistant follow the steps outlined here. Air automatically migrates your IDE skills, MCP configuration, agents installed through ACP, authentication, and chat history. Chat mode history isn’t migrated.

Workspace support

A console and terminal for each subproject

Each subproject has its own Python console and terminal running in its own environment, so you run code against the dependencies that subproject actually uses. In uv workspaces, the console uses the workspace environment and adds the subproject root to sys.path.

Subproject creation and conversion

You can add a new subproject or convert an existing folder from the Project tool window context menu. The subproject is initialized using uv, Hatch, or Poetry, and automatically added as a workspace member to your root pyproject.toml in uv workspaces.

Python 3.15 lazy imports

PyCharm now supports the lazy import syntax introduced in Python 3.15. A lazy import defers loading a module until it’s first used, which can noticeably cut startup time in applications and scripts with heavy dependencies. PyCharm parses and resolves lazy imports like regular ones, so you can adopt the syntax without giving up code insight on the names you import.

Learn more about lazy imports in our blog post.

FastAPI support [Pro]

An endpoint can depend on a chain of Depends() and Security() calls spread across many files. PyCharm draws that chain as a graph for any endpoint. Each dependency is marked as yield or return, and circular dependencies, which raise a RuntimeError at startup, are flagged in the editor. In the other direction, a dependency shows which endpoints rely on it, directly or through other dependencies.

You can open the graph from the gutter icon next to an endpoint or click the endpoint count above a dependency to see its usages.

Python interpreter widget

The redesigned Python interpreter widget in the status bar is built around the tools that manage your environments, such as uv, Poetry, Hatch, and conda. It detects the ones installed on your machine and lists the environments each one manages for your project.

Environments created from the command line also appear under their tool. To start working in one, open the widget, select the tool, and pick the environment. To recreate it with a different Python version, choose Recreate Environment under Current Environment. If the Python version you choose isn’t installed, you’ll be prompted to download it.

Pydantic support

PyCharm now provides deeper editor support for Pydantic models. Field constraints, validators, and configuration are read from the model’s declaration and used across the editor.

What you see here only scratches the surface of what is included in PyCharm 2026.3. From editing standalone files in the new Home window to full SQLAlchemy ORM support, automatic project configuration copy in Git worktrees, and remote development directly from the IDE via SSH – there is plenty more to explore across code insight, testing, environments, and version control.

Head over to the What’s new in PyCharm 2026.3 page to see the complete list of features, performance improvements, and fixes.

We would love to hear how JetBrains Air and the new workspace tooling fit into your day-to-day work – share your thoughts in the comments below or submit feedback directly in our issue tracker.

November 05, 2026 03:03 PM UTC


Python Software Foundation

How the Python ecosystem responded to the eMudhra intermediate compromise

The news of the eMudhra Intermediate CA compromise was published on October 20th, 2026. Responding to an incident like a Certificate Authority (CA) compromise at an ecosystem-scale is far from “routine”; the assumption that HTTPS is secure is baked into many aspects of Python applications and packaging. This meant it was critical to mount a response that was both comprehensive and customized to each affected project and service, ranging from the Python language, pip, and the Python Package Index (PyPI).

This is where security staffing at the Python Software Foundation made a difference for Python. Staffing meant that Seth Larson, the Security Developer-in-Residence, was on standby able to respond and begin work on the issue immediately. Pre-existing relationships with affected project maintainers afforded plenty of trust, and the work was completed in a way that respected the time of volunteer maintainers for affected packages by reaching out proactively with clear next steps. Thanks to Alpha-Omega for funding the Security Developer-in-Residence position at the Python Software Foundation.

The incident was mitigated across multiple components within two days. Thanks to the work of their respective maintainers: fixes were made available for certifi, Python, pip, uv, sigstore, virtualenv, conda, pixi, manylinux, gh-action-pypi-publish, and many other projects. Vulnerable versions of certifi, pip, and sigstore were patched across all projects and services that the Python Software Foundation stewards, including PyPI, Python’s release process and build bots, and python.org. Containers have been rebuilt with the correct set of certificates.

Advisories, CVE records, banners, and discussion threads were created to alert the community about the work necessary to mitigate this incident and to be a location where relevant discussions and questions were answered by experts. Subscribe to the security-announce mailing list for future advisories about Python, pip, and other exceptional circumstances like this incident. Information about which projects are affected is available on discuss.python.org and around the web for more projects.

How did this incident affect Python as an ecosystem?

This section details the current situation of multiple Python projects. An aspect of positive security culture is “blameless post-portems”, we can only improve the future and the best way to do this is to show up and support the projects and maintainers that you depend on. Please note that all decisions below have historical context and sound reasons for being: Python is about the same age as TLS and is much older than the expectation that your system has up-to-date and secure CA certificate bundles.

The key component requiring a prompt response to the incident from the Python ecosystem is “certifi” and the advisory GHSA-6q48-6c7w-9gw2. Certifi is a repackaging of Mozilla’s CA bundle as a Python package and used by many popular projects including pip and requests. The package only includes root CAs, not intermediates, and therefore needed to remove the root CA from the CA bundle (“emSign Root TLS CA - G3”) to distrust all the impacted intermediate certificates.

Keeping a long story short: certifi is a critical dependency for being able to bootstrap a Python virtual environment into being able to install packages from PyPI. pip bundles its dependencies which includes “Requests” which depends on certifi. Python bundles pip so new virtual environments always contain pip through a mechanism called “ensurepip”.

This arrangement put all three of these components, Python, pip, and certifi, in the critical path for permanently remediating this incident as a user through software updates. There also isn’t an easy answer for older versions of Python which would permanently contain an older version of pip due to compatibility concerns. To address this concern, Python has added a “last-resort” denylist to reject the compromised intermediate certificates during a TLS handshake, fixed versions and more information tracked via CVE-2026-115557 which has been backported to older versions of Python.

What happens next?

We’re continuing our efforts to alert users through advisories and CVE and using banners on the PyPI and discuss.python.org and will be closely monitoring the uptake of patched versions of “certifi” across the ecosystem. The immediate, synchronous response has concluded, now we turn to what should be prioritized so a future incident like a CA compromise might not require as urgent a response from volunteer maintainers.

Right from the beginning of the incident it was identified that TLS handshakes made by browsers were secure, but tools like pip, git, uv weren’t secure. Why is this? Browsers were able to secure their certificate stores quickly and without requiring a software update by using Certificate Revocation Lists (CRL). CRLs allow providers of CAs to provide information to verifiers outside of the software update process, such as when certain certificates should no longer be used due to revocation or compromise.

These technologies may be integrated into Python in the future to provide a quicker and robust response to future incidents involving CA certificates and allow distributions to configure these facilities system-wide. One potential project to watch is upki, which has already been adopted by Ubuntu, provides certificate revocation functionality similar to that of browsers on platforms which previously did not offer such facilities.

November 05, 2026 09:05 AM UTC


LernerPython blog, from Reuven Lerner

Python 3.15 comprehension unpacking: Why you can’t transform items

Python 3.15 comprehension unpacking: Why you can't transform items

The comprehension-unpacking syntax in Python 3.15 is great, but you cannot transform the unpacked elements.

Given:

[[10, 20, 30], [40, 50, 60], [70, 80]]

We can get the individual numbers, but cannot run str on each. For that, we still need a nested comprehension.

The post Python 3.15 comprehension unpacking: Why you can’t transform items appeared first on LernerPython.

November 05, 2026 07:00 AM UTC


Wing Tips

Getting Started with Autonomous Development in Wing Pro 12.1

This Wing Tip describes how to get started with autonomous development in Wing Pro 12.1. You provide a high-level specification for a new application or a major feature. Wing's Tasks Manager fleshes it out, plans the work, splits it into tasks, and keeps a team of AI agents working on them for hours or days, as needed. The agents write the code, unit tests, and documentation, and commit their work in logical units. You answer questions, review results, and ask for revisions in the Tasks tool, which summarizes work done and decisions made.

Autonomous development shifts your focus from coding details to product design and oversight. You explain your vision. Your team of AI agents takes care of the implementation, end to end, far faster than you could develop the code on your own.

This Wing Tip is part of a series of Wing Tips on working with AI agents in Wing. For more detail on each step, see the How-To Autonomous Development with Wing Pro.

Getting Started

We recommend using a reasonably isolated environment, such as a virtual machine, when using AI agents. At the very least, prevent agents from accessing the keys to your server infrastructure, reading sensitive data, or pushing to your main repository.

(1) Install an AI agent and set up an account for it that covers the amount of work you expect to do. For sustained use, a subscription is generally much cheaper than paying for usage on an API key. Claude Code and Codex are best for autonomous development, since they check the safety of the commands they run. Antigravity, Copilot, and Cursor also work but don't provide the same level of safety. See Setting Up an AI Agent and AI Agent Safety for details.

(2) Install git or Mercurial, if you don't already have one of them. Wing uses it to track, review, and commit each task's changes. You don't need to learn how to use it, since Wing and the agents do that for you.

(3) Create a new project with New Project in the Project menu. Choose New directory and enter a Name, then choose your agent under AI Agent and a Python environment under Python. Leave Create a Git repository checked and click Create Project. Setting up the project for an agent turns on the Tasks Manager and lets it commit autonomously:

Setting Up an AI Agent in the New Project Dialog

Shown above: Setting up a new project for an AI agent.

(4) Check the Tasks Manager's settings with the settings icon at the right of the Manager's bar, at the top of the Execute tab in the Tasks tool. The defaults are a good place to start. We recommend leaving automatic commits on, so work can go on while you are away. You can still look over every committed task on the Review tab and ask for changes.

The Tasks Manager settings dialog

Shown above: The Tasks Manager's settings.

(5) Write a specification or tasks on the Plan tab of the Tasks tool, and mark them Ready by clicking their Draft buttons. A task can be anything from a bug report to a high-level specification for a whole application. Describe what the code should do and any technical constraints that matter from the start. The Manager researches the rest, asks you questions, and writes out the tasks:

A specification on the Plan tab, marked Ready

Shown above: A specification on the Plan tab, marked Ready.

You can watch progress on the Execute tab, and click a queue's name to see its session in the AI Agent tool. Problems that agents notice along the way, and follow-up tasks the Manager proposes, appear in the Incoming view on the Plan tab for you to make Ready or dismiss.

(6) Answer questions on the Questions tab when the Manager or a task needs your input. Other tasks keep running while it waits. You can also talk to the Manager directly on the Execute tab, to ask about progress or give it instructions such as "ask me first about anything that changes a file format."

Questions about the specification on the Questions tab

Shown above: Questions about the specification, waiting on the Questions tab.

(7) Review finished work on the Review tab. Each task reports a summary of its changes and the decisions made in it. Use Revise to ask for changes to any decision or the task as a whole. If you are committing work yourself, use Start Review to view and comment on the code, or Commit to commit it without further review.

The Review tab with a task report and the Start Review and Commit links

Shown above: A finished task's report on the Review tab, with automatic commits turned off.

Usage Tips

For better results, build tests and cross-checks into your process:

  • Ask the Manager early on to set up a way for agents to run your application as a user would, so they can see the results of their work.
  • Add tasks at key points that review the code for security flaws, gaps in test coverage, or anything else that matters to you. Problems they find automatically become new tasks.
  • Let the Manager commit on a separate branch, and review that branch before merging it into your main branch.
  • Use the application yourself, and add tasks for the bugs and improvements you find.

If you're unfamiliar with any of this, just tell the Manager what you need, in the Execute tab of the Tasks tool. It can help you set up your development environment and get started with autonomous software development.

Further Reading

See The Tasks Manager Agent in the Wing Pro manual for details. For examples of complete projects built this way, see the Autonomous Development Demos.



That's it for now! We'll be back soon with more Wing Tips for Wing Pro.

As always, please don't hesitate to email support@wingware.com if you run into problems or have any questions.

November 05, 2026 01:00 AM UTC


Graham Dumpleton

A first look at Muru

In my last post I talked about where Muru came from and what I want it to be, but didn't show it doing anything. Time to fix that.

Muru has three ways of running an application. There is a development mode for when you are working on an application on your own machine, a daemon for hosting applications on a server, and a mode for running an application in a container. The same process model sits underneath all three, so what you see while developing is what you get in production. That makes development mode a good place to start, since it is the simplest of the three and still shows off some of what makes Muru different.

Installing it

Muru comes as two pieces. The muructl command is what you install and interact with. It is a single binary written in Rust, with no Python dependencies of its own. The second piece is muru, a runtime library which runs inside your application's Python interpreter. You don't install that one yourself. The first time muructl runs an application on a particular Python version, it fetches the matching runtime library into your user cache directory. Nothing gets installed into your project.

The easiest way to install muructl is as a tool with uv:

uv tool install muructl

Using pipx install muructl, or pip install muructl into some environment, works just as well. Muru is still in alpha, with only pre-releases available, and supports Linux and macOS on CPython 3.11 or later, including the free-threaded builds.

For a project to try it with, create a directory with a virtual environment:

mkdir hello && cd hello
uv init --bare
uv venv

A first application

Not wanting to complicate things by bringing a web framework into it just yet, we will start with a plain WSGI application. Save this as app.py:

def application(environ, start_response):
    body = f"Hello from {environ['PATH_INFO']}\n".encode()
    start_response("200 OK", [("Content-Type", "text/plain"), ("Content-Length", str(len(body)))])
    return [body]

To run it, give muructl run the module and the name of the WSGI callable within it:

muructl run app:application

There is no need to activate the virtual environment first, as muructl will find the .venv directory in the project by itself. Running this the output is:

muru.host[13737]: preloaded app:application in 1 ms
muru.host[13737]: app:application is served as WSGI
muru.host[13737]: worker 0 started as pid 13773
muru.host[13737]: worker 0 (pid 13773) is ready
muru: serving app:application on http://127.0.0.1:8080 with 1 worker of 4 threads
muru: development mode: error pages show tracebacks, nothing is persisted, the run reloads on change and only loopback is served; production runs under the daemon (muructl daemon start)
muru: dashboard on http://127.0.0.1:8081/
muru: admin socket /tmp/muru-501/runs/8080/admin.sock
muru: watching /Users/graham/hello for changes to **/*.py

Even with an application this small, there is more going on than you might expect. A host process was started which imported the application once. A worker process was then forked from the host, meaning that it starts out as a copy of the host with the application already loaded. Finally, the edge, which is the part of Muru that accepts HTTP connections, started listening for requests, passing them through to the worker.

Importing the application once and forking workers from it isn't a new idea, but doing it safely is harder than it looks. If importing the application opens a database connection or starts a background thread, that doesn't survive being forked, and what you get are mysterious problems later on. Muru checks for this when it does the import, before any workers are forked. How that check works deserves a post of its own, so I will come back to it.

Note too that development mode only listens on the loopback interface. It is not meant to be exposed to the outside world, which is what the daemon is for.

What the access log tells you

Open http://127.0.0.1:8080/hello in a browser, or use curl, and a line is added to the terminal for the request:

07:50:49.268 200 GET /hello 18B 0.2ms queue=0.0ms app=0.1ms worker=0 id=01M6JRM25MKE6AS8RXBZ8AS1MG

Much of this is what you would expect from any access log, with the status, the method and path, the size of the response, and the total time. The parts which are a bit different are queue and app. The first is how long the request waited for a worker thread to become free to handle it, and the second is how long your code took.

On a laptop with one user these are tiny numbers and not very interesting. In production they are anything but. A request that takes a long time because your code is slow, and one that takes a long time because every thread was busy and it had to wait its turn, are very different problems. The first means you have code to fix, the second means you don't have enough capacity. If you can't tell the two apart, you will often end up fixing the wrong thing. Being able to see that distinction, right down to individual requests, is a small example of what I meant in the last post about Muru knowing what Python web applications need.

The line also says which worker handled the request, and gives the request a unique id. The same id is returned to the client in an X-Request-ID response header, so a response can be matched up to the log line for it. Anything the application prints is likewise tagged with the worker that wrote it, so when there are multiple workers you can tell which output came from where.

When the application fails

Applications have bugs, so let's add one. Change app.py to:

def application(environ, start_response):
    raise RuntimeError("not today")

Make a request and, because this is development mode, the error page sent back includes the traceback:

500 Internal Server Error
reason: app_error
request id: 01M6JRM54RKE6AS8RXBZ8AS1MG

The application raised an exception.

Traceback (most recent call last):
  File "/Users/graham/hello/app.py", line 2, in application
    raise RuntimeError("not today")
RuntimeError: not today

The same traceback appears in the terminal, tagged with the worker, along with an access log line showing the 500 status and reason=app_error.

If the client says it wants JSON, as an API client would, it instead gets back an application/problem+json response with the same details:

curl -H 'Accept: application/json' http://127.0.0.1:8080/
{"type":"about:blank","title":"Internal Server Error","status":500,"detail":"The application raised an exception.","reason":"app_error","request_id":"01M6JRM553KE6AS8RXBZ8AS1MG","traceback":"Traceback (most recent call last):\n ..."}

Either way, the worker itself is fine. An exception raised by your code results in a failed request, but never a dead process.

Reloading is a release

You may have noticed that I didn't restart anything after changing app.py. That is because development mode watches the project directory, and when a .py file changes it reloads automatically. This is on by default.

Reloading on code changes is something every development server does, so on its own it isn't remarkable. What is different is how Muru does it. Rather than restarting the worker in place, a reload starts a whole new host and worker beside the existing ones. Only once the new worker is ready is traffic switched across to it, after which the old one is shut down. The terminal shows it happening:

muru: reloading, app.py changed
muru.host[13786]: preloaded app:application in 2 ms
muru.host[13786]: app:application is served as WSGI
muru.host[13786]: worker 0 started as pid 13787
muru.host[13786]: worker 0 (pid 13787) is ready
muru: switched to release r2
muru.host[13737]: received SIGTERM, stopping

Each set of host and workers is a release, numbered as they are created. Where this gets interesting is when the change you make breaks things badly enough that the application can't even be imported. Add import nosuchmodule to the top of app.py and save it:

muru: reloading, app.py changed
muru.host[13858]: cannot load app:application: ModuleNotFoundError: No module named 'nosuchmodule'
muru: release r3 failed to start: the host exited with exit status: 1 before its workers were ready; release r2 keeps serving

The new release failed to start, so traffic never moved to it, and the previous release carried on serving requests the whole time. Fix the code and save again, and the next release starts and takes over. The failed attempt still counts as a release, so this will be r4.

In development mode this mostly saves you from staring at a dead server after a typo. The reason I find it worth dwelling on is that it is exactly the same mechanism the daemon uses when deploying a new version of an application to a server. There, a new release starts beside the old one, traffic moves across once it is ready, and if it never becomes ready the old release just keeps serving. That is the basis of zero-downtime deploys in Muru, and of things like blue/green deploys, canaries and rolling back to a previous release. Development mode is giving you a small taste of that every time you save a file.

Real frameworks

Nothing about this is specific to hand-written WSGI applications. For a Flask application you point muructl run at the Flask application object:

muructl run app:app

and for Django at the WSGI application it already generates for you:

DJANGO_SETTINGS_MODULE=mysite.settings muructl run mysite.wsgi:application

Neither framework needs to know anything about Muru.

What's next

That covers the basics of running an application in development mode. Rather than walk through every option, the posts which follow will concentrate on the places where Muru does things differently to the other ways of hosting a Python web application, still in development mode for now, before moving on to the daemon.

November 05, 2026 12:00 AM UTC