skip to navigation
skip to content

Planet Python

Last update: September 12, 2026 09:48 PM UTC

September 12, 2026


Graham Dumpleton

Trying out wrapture

Since introducing wrapture at the end of August I have been putting out a post every day or so, first working through how it is used in unit tests and then how the same bindings trace a running application. With the last of the tracing posts now done, this is the point where I wanted to stop, take stock, and pull everything together in one place for anyone who wants to try it out.

Where things stand

The short version is that wrapture has been bumped to version 1.0.0b1. That move from alpha to beta is deliberate. I am happy with the APIs as they are and I am not seeing a need to change them, so what the beta series needs now is not more features from me but people using it on real code and reporting back. Reports of it working, or not working, on something I never thought to test, and of what confused you or what you found missing, are what will decide whether anything changes before a release candidate. They go to the issue tracker on GitHub.

One area where feedback would be especially useful is the OpenTelemetry export. The events wrapture records are mapped through to spans, attributes and metrics, and the intent was for that mapping to follow the OpenTelemetry semantic conventions. As I explained in the introductory post, the code was written by an AI under my direction rather than by me, so I cannot claim to have checked every attribute against the conventions myself, and can only say I hope the information is being mapped through okay. If you spend your days looking at traces in an OpenTelemetry backend and find that what arrives is not named or shaped the way the conventions say it should be, or the way your backend expects, I would like to hear about it while it is still cheap to change.

The companion instrumentation packages have been bumped to 1.0.0b1 at the same time. Until 1.0.0 is final a plain pip install wrapture picks up the latest pre-release automatically, so there is no need to pin a specific version to try it.

The links you need are:

The split between the core collection and the separate packages is that the core package only covers targets which can be exercised in-process, with no backend product or service needed to test against. Anything that needs a real server to test against, which the database and AWS packages do (their test suites run the real thing in a container), lives in a package of its own with its own release cadence. I also plan to have packages for Redis and MongoDB. Beyond that it will depend on what people are interested in seeing instrumentation for, with the only other thing I can see at the moment being the LangChain packages.

The posts so far

The posts are collected on the testing and tracing with wrapture guide page, which is the list that will keep growing, but for the record the reading order is as follows. First the starting point, on what wrapture is, why I built it, and how it was written:

Then the unit testing side, where the difference from unittest.mock is that the real code still runs and everything that flows through it is recorded:

And the tracing side, where the same bindings observe a running application instead, from a live call tree in a terminal through to spans in an OpenTelemetry backend:

Learning it by doing

Reading about a library only gets you so far, so alongside the posts there is now a wrapture-workshops repository on GitHub containing 24 workshops you can work through to learn wrapture in an interactive workshop format. Each takes one thing you might want to do with wrapture and walks you through doing it in a live JupyterLab session, with the instructions in a side panel whose actions drive the session and check your work as you go. The early workshops track the blog posts, one per post, and the later ones go into areas the posts have not covered yet, such as using wrapture with pytest properly, converting an existing mock based test suite, async code, patching third party libraries, distributed tracing across two processes, and writing an instrumentation package of your own.

You need nothing installed to try them. The workshops can be run in a hosted environment on mybinder.org, a free public service that builds the repository into a temporary JupyterLab running in your browser. Building takes a minute or two. A session is discarded when it ends, so finish a workshop in the session you started it in. If you would rather run them locally, the repository README has the steps for doing that under any JupyterLab.

Each workshop installs wrapture into a virtual environment of its own inside the workshop directory, the way a project would, so nothing is left behind in the JupyterLab environment. The workshops are pinned to a released version of wrapture, so the documentation may at times describe something newer than what a workshop uses.

Beaten to the punch

My intention was always to write this summary post once the testing and tracing posts were done, but Simon Willison got there first with Don't sleep on wrapture, which was his second post on wrapture after covering the initial announcement back in August. I am not complaining. His reach on social media is a great deal larger than mine, so a lot more people will have seen his summary than would have seen this one, and that has been reflected in the way the number of stars on the GitHub repository jumped after his post went out. The Python Bytes team also featured wrapture on episode 494 of their podcast, which helped as well. Thanks to all of them.

Either way, this post still serves a purpose, since it is the one place with all the links, the package list and the workshops together, and it will be the post I point people at when they ask where to start.

A side note on the workshops

I do hope people try the workshops on mybinder, and not only for what they teach about wrapture. I have worked on Educates for many years as a way of hosting online interactive workshops, and it remains the platform I would reach for when a workshop needs a full Kubernetes-backed environment. Being able to deliver workshops inside JupyterLab, with instructions in a side panel that drive the session and check what you have done, is something completely new which I only wrote in the past week, as a JupyterLab extension called jupyterlab-workshop. A workshop is nothing more than a directory with a manifest and some Markdown pages, and it runs wherever JupyterLab runs, so mybinder can host it with no container or cluster of my own behind it. I will do some followup posts on that in the coming week or so, since it deserves more than a paragraph at the end of a post about something else.

September 12, 2026 07:39 AM UTC


Mike C. Fletcher

Hardware Accelerated Video Capture for PyOpenGL

The pyopengl-video library is a small hack that lets you pass FrameBufferObjects (FBOs) directly to the local platform video encoding library (or allows you to use your existing video buffer as an FBO, depending on the platform). The purpose of the library is to allow an off-screen renderer to generate h264 in mp4 video previews of that off-screen rendering. I find that useful to allow an agent to capture e.g. gameplay demos or the like so that you can see what it is working on while you are not at your desk.

On Linux with nVidia uses NVENC. For linux with Intel or AMD uses vaapi. Windows uses D3D11 and COM. Requires PyOpenGL 4.0.0a5 or above. So far no OS-X solution.

September 12, 2026 01:05 AM UTC


Graham Dumpleton

OpenTelemetry export in wrapture

Everything in the last four posts rendered a trace for a person to read or wrote it to a file for later. The other destination is a tracing backend, fed while the application runs, and OpenTelemetry is the one that the ecosystem has converged on. wrapture treats it as a first-class destination rather than something you bolt on: the wrapture.otel subpackage ships in every wheel, and the otel extra brings the SDK and the OTLP exporter with it.

$ pip install "wrapture[otel]"

A plain install pays nothing for this, since nothing in base wrapture imports the subpackage until a config asks for it.

One table

Export is switched on by a top-level [otel] table in the same config file the Flask shop has been using. The presence of the table opts in, a service name identifies the process, and each signal's tuning nests beneath it. I shortened the metrics export interval so the demonstration would not have to wait a minute for a data point.

[otel]
service_name = "webshop"

[otel.metrics]
export_interval = 2

[[instrument]]
name = "flask"
ignore_paths = ["/health"]

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

[[observe]]
target = "shop:Gateway"
name = "charge"
redact = ["card"]

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

Where the spans go is decided by the standard OpenTelemetry environment variables, so with a collector listening on the usual port nothing more is needed. For a look without a collector, the console exporters print the spans and metrics to standard output instead, which is what I used here:

$ OTEL_TRACES_EXPORTER=console OTEL_METRICS_EXPORTER=console \
    python -m wrapture -m flask --app webshop run --port 5003

One event becomes one span. A request becomes a SERVER span, a call or a block becomes an INTERNAL span beneath it, and the tree the printer drew is the tree the backend receives. For the quote of an item that is not in the catalog, the view's span arrives with error status and the exception recorded on it (trimmed here to the parts that matter; the real output includes the stack trace and the resource attributes):

{
    "name": "quote",
    "context": {
        "trace_id": "0x02f1fd262a7817d4f8b42fb0f6a30db3",
        "span_id": "0x5b94b62db087bbc9"
    },
    "kind": "SpanKind.INTERNAL",
    "parent_id": "0xfba0a55ab3a322f0",
    "status": {
        "status_code": "ERROR",
        "description": "KeyError"
    },
    "attributes": {
        "wrapture.path": "webshop:quote",
        "wrapture.kind": "call",
        "wrapture.arg.item": "missing"
    },
    "events": [
        {
            "name": "exception",
            "attributes": {
                "exception.type": "KeyError",
                "exception.message": "'missing'",
                "exception.escaped": "True"
            }
        }
    ]
}

And the request span it is parented under:

{
    "name": "GET /quote/<item>",
    "context": {
        "trace_id": "0x02f1fd262a7817d4f8b42fb0f6a30db3",
        "span_id": "0xfba0a55ab3a322f0"
    },
    "kind": "SpanKind.SERVER",
    "parent_id": null,
    "status": {
        "status_code": "ERROR"
    },
    "attributes": {
        "http.request.method": "GET",
        "url.path": "/quote/missing",
        "http.route": "/quote/<item>",
        "http.response.status_code": 500,
        "wrapture.data.endpoint": "quote",
        "wrapture.data.remote": "127.0.0.1"
    },
    "events": [
        {
            "name": "exception",
            "attributes": {
                "exception.type": "KeyError",
                "exception.message": "'missing'",
                "exception.escaped": "False"
            }
        }
    ]
}

A few things in there are worth pointing at. The request span is named GET /quote/<item>, by the route pattern rather than the URL, because the Flask instrumentation annotates the request with its matched route once routing has run, and the exporter reads that as the semantic-convention http.route. A backend then groups by endpoint rather than seeing every URL as a distinct operation. The captured arguments and anything added with annotate() become span attributes under wrapture.arg.* and wrapture.data.*, with the card number already redacted before it got anywhere near the exporter. And the KeyError appears on both spans, once as the exception that escaped the view and once as the one noted against the request after Flask caught it, so the request span shows the 500, the error status and the reason together rather than a status with no explanation.

One trace across two processes

A trace within one process is only half of what a tracing backend is for. The trace-propagation example in the wrapture repository is two processes: a client that places orders against a quote service over HTTP, and the service itself, both observed by wrapture and both writing JSON Lines files. Every tree wrapture records carries a W3C trace id, minted at its root, and on the client side an instrumentation for urllib puts that id into the traceparent header of each outbound request. On the server side the WSGI middleware parses the header at the boundary, so that process's trees join the client's trace instead of minting their own.

The join needs no backend at all. Printing the first eight characters of the trace id from every line of both files, with the file it came from:

0b2ad016 server.jsonl backend:app
8f19b8c6 client.jsonl frontend:fetch_quote
8f19b8c6 client.jsonl frontend:fetch_quote
8f19b8c6 client.jsonl frontend:fetch_quote
8f19b8c6 client.jsonl frontend:place_order
8f19b8c6 client.jsonl urllib.request:OpenerDirector.open
8f19b8c6 server.jsonl backend:app
8f19b8c6 server.jsonl backend:quote
b1a416b0 client.jsonl frontend:fetch_quote
b1a416b0 client.jsonl frontend:fetch_quote
b1a416b0 client.jsonl frontend:place_order
b1a416b0 client.jsonl urllib.request:OpenerDirector.open
b1a416b0 server.jsonl backend:app
b1a416b0 server.jsonl backend:quote
...

Each order is one id across both files, client half and server half of one distributed trace. The repeated fetch_quote lines are the two blocks the client marks inside that function, which record under its path, and the lone server-only line at the top is a request that arrived with no traceparent header, which minted an id of its own at the boundary. The whole public surface the client instrumentation needed for this was wrapture.trace_headers(), which returns the pairs an outbound message made right now should carry, and is empty when nothing is being recorded, so injecting it is always safe.

Switching on [otel] in both processes changes nothing about the ids. The exporter claims the identity wrapture minted rather than minting one of its own, so the JSON Lines files, the outbound headers and the exported spans all read the same trace id, and the server's request span is created with the arrived identity as a remote parent. In the console output from the same run, the server's GET /quote/widget span carries the client's trace id and names the client's urllib.open span as its parent:

{
    "name": "GET /quote/widget",
    "context": {
        "trace_id": "0xcdde803e61b96f52e2eb3820c7004df0",
        "span_id": "0x9bb64b3bad9a6848"
    },
    "kind": "SpanKind.SERVER",
    "parent_id": "0x670e42eb0ac7690a",
    ...
}
{
    "name": "urllib.open",
    "context": {
        "trace_id": "0xcdde803e61b96f52e2eb3820c7004df0",
        "span_id": "0x670e42eb0ac7690a"
    },
    "kind": "SpanKind.INTERNAL",
    "parent_id": "0x0e06d5674120b1db",
    ...
}

In a viewer, each order is one distributed trace with the service's request span attached beneath the outbound call that made it. One invariant governs the header handling and it is worth stating because it is the thing that makes this safe to switch on in a service that sits between other people's systems: never break a trace you do not understand. A header wrapture parses but nothing claims is forwarded verbatim, so an upstream product sees this service as a transparent hop, and headers wrapture does not parse are never touched at all.

Metrics for free

The traces signal exports events individually. The metrics signal aggregates the same events instead, and both were on above since the default is all signals. Request durations go into the semantic-convention http.server.request.duration histogram, attributed by method, route and status code, and observed calls go into a per-path wrapture.call.duration histogram whose error series split out by exception type. From the same run, the attribute sets on the request histogram's data points were:

{
    "http.request.method": "POST",
    "http.route": "/order",
    "http.response.status_code": 200
}
{
    "http.request.method": "GET",
    "http.route": "/quote/<item>",
    "http.response.status_code": 500,
    "error.type": "KeyError"
}

So per-endpoint latency and error rate read straight off the histogram with no code involved. The reason the bound path is safe as a metric attribute where a raw URL would not be is that the config chose the bindings, so the set of values is closed; requests are attributed by route pattern for the same reason, never by URL. The design is the Aggregate collector from the previous post with the aggregation handed to the SDK: bounded memory, no values captured, nothing retained.

What it costs

The usual objection to instrumenting Python is the overhead, so this is worth a paragraph. Exporting through wrapture costs about the same as instrumenting with the OpenTelemetry SDK directly, and on a call that raises it costs noticeably less, because the SDK's record_exception formats the stack trace through traceback.format_exception, which on current Pythons parses each frame's source to draw caret underlines that no backend renders, and wrapture's sink formats the same frames without them. The design point behind the rest is that the sink does not use the SDK's tracer at all. Everything a span needs is known when its event closes, so the sink builds the finished span at that moment and hands it to the SDK's own processor, skipping the tracer's mutable span object with its validated attribute store and locks, which is where most of the per-span cost used to go. The measured figures, with the methodology, are in the cost section of the OpenTelemetry export page, and I would rather point there than quote numbers that will be out of date by the time anyone reads this.

The thread from the beginning

When I introduced wrapture I said there were two interests behind it, correctness in testing and instrumenting programs for tracing, and that underneath they wanted the same thing: a way to see the real calls as they happen. This is where the second one ends up. The three bindings on the shop have not changed since the first testing post. In a test they feed a tape and the assertions read off it. In production they feed a backend, with a request as one tree, a trace id that survives crossing to another service, and metrics aggregated from the same events. The only thing that changed along the way is who was listening.

The OpenTelemetry export page has the rest of the table, including sampling, the logs signal and how wrapture's pipelines coexist with an application that already uses the OpenTelemetry API on its own account.

September 12, 2026 12:00 AM UTC


Armin Ronacher

P(doom)

September 12, 2026 12:00 AM UTC

September 11, 2026


LernerPython blog, from Reuven Lerner

Newsprint: Turning e-mail newsletters into a personal PDF

I really enjoy reading e-mail newsletters. They’re clever, informative, and funny, and provide me with lots of food for thought — as well as professional information that is crucial to […]

The post Newsprint: Turning e-mail newsletters into a personal PDF appeared first on LernerPython.

September 11, 2026 03:03 PM UTC


Bob Belderbos

Why Learn to Code If AI Can Code? 6 Reasons

Stanford's Chris Piech runs Code in Place, an intro programming class with 17,000 students and over 1,000 teachers. They've run it both before and after Cursor and Claude Code arrived, and enrollment basically doubled. It turns out that more people, not less, want to learn to code now that AI can write it.

September 11, 2026 12:00 AM UTC


Graham Dumpleton

Finding slow code with wrapture

The /order endpoint of the Flask shop is slow. The view calls the order service, the service calls the gateway and then the ledger, and the question is which of those the time is going to. To give the question a real answer for this post I put a time.sleep(0.03) in Ledger.record, and the rest of the post pretends I did not know that.

The usual move is a stopwatch. A perf_counter() before and after the service call, a log line with the difference, another pair around the gateway, another around the ledger. Each of those is a code change in a layer that should not know it is being measured, the numbers arrive as separate log lines that you correlate by eye, and none of them are tied to the request they belong to, so one slow request among fast ones is invisible in the average. A profiler has the opposite problem: it sees every frame in the process, most of them framework internals, and cannot tell one request from the next.

The tree with times on it

The config from last time already prints an elapsed time on every closing line, so the first order through the server is most of the answer:

POST /order (webshop.wsgi_app)
  order()
    shop:OrderService.place(amount=500, card='<redacted>', tenant='acme')
      shop:Gateway.charge(amount=500, card='<redacted>')
      shop:Gateway.charge -> {'id': 'ch_500', 'amount': 500} [8us]
      shop:Ledger.record(entry="<dict {'id': 'ch_500', 'amount': 500}>")
      shop:Ledger.record -> 'led_ch_500' [35.1ms]
    shop:OrderService.place -> {'id': 'ch_500', 'amount': 500} [35.9ms]
  order -> '<Response 29 bytes [200 OK]>' [36.3ms]
webshop.wsgi_app -> '200 OK' [37.3ms, body 10us over 1 chunk]

Reading up from the bottom, the request took 37.3ms, the view 36.3ms, the service 35.9ms, and the ledger 35.1ms, with the gateway at 8us. The figures are from one run, and they vary, but the shape does not. The ledger accounts for essentially all of the service, which accounts for essentially all of the view. The service and the view are slow because of what they call. The ledger is slow in its own right.

That distinction, slow itself versus slow because of a child, is the one a wall-clock timer around the service call cannot express, and it has a name. Self time is an operation's duration minus the time its observed children account for, and wrapture computes it from the parent links as events close. In a test, tape.tree(times=True) prints both figures and tape.self_time() gives it for one event, so the same observation can be turned into an assertion that will catch the next regression:

import wrapture

from shop import Gateway, Ledger, OrderService
from webshop import app


def test_where_the_time_goes():
    place = wrapture.binding(OrderService, "place", capture=wrapture.redact("card"))
    charge = wrapture.binding(Gateway, "charge", capture=wrapture.redact("card"))
    record = wrapture.binding(Ledger, "record")

    with wrapture.instrumentation("flask"), wrapture.timeline(place, charge, record) as tape:
        client = app.test_client()
        response = client.post("/order", json={"amount": 500, "card": "4111-1111-1111-1111", "tenant": "acme"})
        assert response.status_code == 200

        print()
        print(tape.tree(times=True))

        order = place.events.assert_once()[0]
        ledger = record.events.assert_once()[0]
        assert tape.self_time(order) < 0.1 * order.duration
        assert tape.self_time(ledger) > 0.9 * order.duration

The wrapture.instrumentation("flask") context applies the same Flask instrumentation the config file named, scoped to the block, and the timeline records what the three bindings see. Running it with pytest -s prints the tree:

shop:OrderService.place(amount=500, card='<redacted>', tenant='acme')  -> {'id': 'ch_500', 'amount': 500}  [31.0ms, self 173us]
  shop:Gateway.charge(amount=500, card='<redacted>')  -> {'id': 'ch_500', 'amount': 500}  [7us]
  shop:Ledger.record(entry={'id': 'ch_500', 'amount': 500})  -> 'led_ch_500'  [30.8ms]

The service spent 173us of its 31.0ms doing anything itself. No external profiler can produce that number for an arbitrary handful of methods, because a profiler only sees whole call stacks; wrapture can, because the events know their parents.

Across many requests

One request is an anecdote. The Aggregate collector keeps one row per bound location, with how many operations began and completed, how many raised, and the total, self, fastest and slowest times, sorted by self time, which is the column profilers rank by. It retains no events, so its memory is bounded by the number of bindings however much traffic flows, and it asks for no argument or result values, so the recording skips capture entirely while it is the only thing listening. It can be registered as a sink in code, but the shape I wanted was a report for the whole run of the server, which is a window in the config file:

[[window]]
name = "stats"
report = "stats.txt"

[[window.collect]]
type = "aggregate"

A window with no trigger and no duration is one run for the whole process, opened when the config applies and closed at interpreter exit, one report. I ran the server under that config, sent it thirty requests from a loop (ten orders for one tenant, ten declined orders for another, and ten quotes), stopped it, and read the file:

aggregate "aggregate" run 1, 2026-09-01 14:57:29 to 14:57:31 +10:00 (1.6s), pid 87241
7 paths, 120 operations begun, 120 completed, 20 raised

calls    total     self  per-call     min     max  errors  path
   10  358.3ms  358.3ms    35.8ms  30.7ms  39.5ms          shop:Ledger.record
   30  385.2ms   11.9ms    12.8ms   534us  40.5ms          flask.app:Flask.wsgi_app
   20  369.8ms    5.7ms    18.5ms   296us  40.1ms          webshop:order
   20  364.1ms    5.7ms    18.2ms   105us  39.9ms      10  shop:OrderService.place
   10    2.2ms    2.2ms     223us    63us   1.6ms          flask:render_template
   10    3.5ms    1.3ms     354us   188us   1.8ms          webshop:quote
   20    106us    106us       5us     4us    11us      10  shop:Gateway.charge

The ledger is the top row by a wide margin. The order view and place have large totals and small self times, which is the same story the single tree told, now over twenty orders with a minimum and maximum attached. The errors column shows the ten declined cards twice, once where the gateway raised and once where the service let it escape. The same report can be produced every hour on the hour with totals reset, from the same file, by giving the window a schedule; the scheduled tracing page covers that, and I will leave it there.

Slow for whom

An endpoint is often slow for one tenant, one account or one request id, and the middleware cannot know which header carries that. annotate() merges values into the in-flight event's data, and it is unconditionally safe to call, doing nothing when nothing is recording, which makes it reasonable to leave in application code permanently. In the shop a before_request hook is the natural place, since the request event is already open by the time it runs:

@app.before_request
def tag_tenant():
    wrapture.annotate(tenant=request.headers.get("X-Tenant"))

This is the one edit to the application in this series, and it is the same annotate() the testing series used to attach what the code knows to an event. The tag rides on the request event, so with a jsonlines sink in the config beside the printer it is in the file, and the slow requests can be sliced by who they were for:

$ jq -c 'select(.kind=="request" and .data.tenant=="acme") | {tenant: .data.tenant, path: .data.path, ms: ((.duration*1000*10|round)/10)}' trace.jsonl
{"tenant":"acme","path":"/order","ms":35.7}
{"tenant":"acme","path":"/order","ms":32.5}
{"tenant":"acme","path":"/order","ms":35.9}

The other tenant's orders were all declined at the gateway and never reached the ledger, so they sit around a millisecond. The same expression selects the request to assert on in a test, through events.matching(), and a Filter around a printer narrows the live view to one tenant's requests.

The cheaper cousin

Everything above retained a duration. Sometimes the answer is just a number, and the Counter collector counts operations as they begin and keeps nothing else, which makes it cheap enough to leave running under a whole test suite. Bind a database layer's execute once, register a counter, and give every test a query budget in a fixture, and the classic N+1 regression fails with a number attached rather than slipping through as a test that merely got slower. The collectors section of the ad-hoc tracing page has that example in full.

So far every trace has been rendered for a person or written to a file. The remaining step is feeding the same events to a tracing backend while the shop runs.

September 11, 2026 12:00 AM UTC

September 10, 2026


Talk Python to Me

#562: DuckLake: The Lakehouse That's Just SQL and Parquet

How many files does your query read before it reads any data? On some data lakes, you go through JSON and metadata files first, just to learn which Parquet files matter. DuckLake asks one SQL question instead. The metadata lives in a real database. The data stays in plain Parquet. That's the entire format. Pedro Holanda joined DuckDB in 2018, when it was still a research prototype at CWI. He's the lead DuckLake developer. Guillermo Sanchez Dionis works on DuckLake and the new Quack protocol. With Quack as the catalog, DuckLake handles 200 transactions a second under heavy contention. No other open table format comes close.

September 10, 2026 02:59 PM UTC


Django Weblog

PyCharm &amp; Django Fundraiser Extended to September 14

The second half of our annual JetBrains fundraiser has been extended through September 14, 2026. Thank you to JetBrains for the extra time. You still have time to renew your PyCharm license or give it a try. You get PyCharm at 30% off, and JetBrains donates 100% of your purchase or renewal to the DSF. This is one of the DSF's bigger fundraisers of the year, and we appreciate everyone who takes a look.

The Executive Director search also closes on September 14, and we are curious to see who reaches out.

JetBrains has been a steady sponsor for years, and it was great to see the chatter around the Django Developers Survey 2026 results we released with them last month. The full report is on the JetBrains site. If you are shopping for an IDE, AI-focused or not, they have a solid product.

If you would like to help with our fundraising goals, we would love to hear from you or your company. The board is happy to talk with individuals too, if you have ideas. Whatever you can do to support us, we appreciate it.

Ways to help

September 10, 2026 11:00 AM UTC


Hugo van Kemenade

Soft-deprecating re.match()

Quick, without looking it up, what does re.match() do? Which of these return a match?

import re
re.match("pi", "pi")
re.match("pi", "pie")
re.match("pi", "api")
re.match("pi", "magpie")

How does it compare to re.search() and re.fullmatch()?

Whilst you’re (quickly) thinking about it, let’s introduce soft deprecation.

Soft deprecation #

Python’s backwards compatibility policy (PEP 387) introduced soft deprecation in 2023:

A soft deprecation can be used when using an API which should no longer be used to write new code, but it remains safe to continue using it in existing code. The API remains documented and tested, but will not be developed further (no enhancement).

A soft deprecation does not imply future removal of the API, nor does it issue a warning. It’s a docs-only recommendation to not use an API, ideally with a suggested replacement.

It’s a completely separate decision whether, if ever, to turn a soft deprecation into a regular “hard” deprecation (where removal may follow); soft deprecations don’t “graduate” into regular deprecations or removals.

re.match() #

Now the answer:

>>> import re
>>> re.match("pi", "pi") # ✅ Matches
<re.Match object; span=(0, 2), match='pi'>
>>> re.match("pi", "pie") # ✅ Matches
<re.Match object; span=(0, 2), match='pi'>
>>> re.match("pi", "api") # ❌ No match
>>> re.match("pi", "magpie") # ❌ No match
>>>

So re.match() only matches at the beginning of a string! This can be surprising: why is the start of the string special?

re.search() #

If you don’t want to anchor at the start, and want to match anywhere in the string, use re.search():

>>> import re
>>> re.search("pi", "pi") # ✅ Matches
<re.Match object; span=(0, 2), match='pi'>
>>> re.search("pi", "pie") # ✅ Matches
<re.Match object; span=(0, 2), match='pi'>
>>> re.search("pi", "api") # ✅ Matches
<re.Match object; span=(1, 3), match='pi'>
>>> re.search("pi", "magpie") # ✅ Matches
<re.Match object; span=(3, 5), match='pi'>
>>>

re.fullmatch() #

If you want to anchor both the start and the end, and check the entire string matches, use re.fullmatch():

>>> import re
>>> re.fullmatch("pi", "pi") # ✅ Matches
<re.Match object; span=(0, 2), match='pi'>
>>> re.fullmatch("pi", "api") # ❌ No match
>>> re.fullmatch("pi", "pie") # ❌ No match
>>> re.fullmatch("pi", "magpie") # ❌ No match
>>>

Introducing re.prefixmatch() #

Because of the surprising half-anchored behaviour, we’ve introduced a new alias for re.match() in Python 3.15, named re.prefixmatch():

Quoting from the Zen Of Python (python3 -m this): “Explicit is better than implicit”. Anyone reading the name prefixmatch() is likely to understand the intended semantics. When reading match() there remains a seed of doubt about the intended behavior to anyone not already familiar with this old Python gotcha.

Soft-deprecating re.match() #

And with a more explicit replacement, we’ve soft-deprecated re.match() in Python 3.15:

We do not plan to remove the older match() name, as it has been used in code for over 30 years. It has been soft deprecated: code supporting older versions of Python should continue to use match(), while new code should prefer prefixmatch().

Use re.prefixmatch() if you only really meant to use the half-anchor; otherwise use re.search() or re.fullmatch().

Comparison #

Function Start anchor End anchor Added in With special characters
re.search() 1.5 re.search("pi", string)
re.match() 1.5 re.search("^pi", string)
re.search(r"\Api", string)
re.prefixmatch() 3.15 re.search("^pi", string)
re.search(r"\Api", string)
re.fullmatch() 3.4 re.search("^pi$", string)
re.search(r"\Api\z", string)

The functions without special characters are generally a bit faster.

Lint #

You can avoid re.match() in your project with Ruff:

# pyproject.toml
[tool.ruff]
lint.extend-select = [
 "TID251", # flake8-tidy-imports: banned-api
]
lint.flake8-tidy-imports.banned-api."re.match".msg = "Use re.fullmatch() or re.search() instead"

Or run:

ruff check . --isolated --select TID251 \
 --config 'lint.flake8-tidy-imports.banned-api."re.match".msg = "use re.fullmatch() or re.search() instead"'

See also #


Header photo: Double-exposure bike and pedestrian stencils (CC BY-NC-SA 2.0 Hugo van Kemenade).

September 10, 2026 08:08 AM UTC


EuroPython

EuroPython 2026: Videos Published

Hi all Pythonistas! 👋

EuroPython 2026 took over the ICE Congress Centre in Kraków from 13 - 19 July and it wouldn’t have been possible without all of our attendees, speakers, volunteers, and sponsors. You allowed us to showcase the Python community at its best once again,

September 10, 2026 06:43 AM UTC


Graham Dumpleton

Tracing Flask with wrapture

For a web application the natural unit of tracing is the request: one HTTP request, its method, path and status, and every observed call made while handling it, as one tree. The config file from last time cannot give you that on its own, and it is worth being clear about why before showing what does.

A WSGI application looks like any other callable, but it routes the interesting facts around the return value. The status and headers travel through the start_response callback rather than being returned. The body is an iterable that the server consumes after the call has returned, so a streaming application does most of its work after a call event would already have closed. And when a view raises, the framework catches the exception and turns it into a 500 response before any wrapper on the application ever sees it. A binding on the application callable would record a call that returned an iterable and raised nothing, which is true and useless.

The shop behind Flask

Here is the shop from the earlier posts behind a small Flask application. A /quote/<item> route renders a template, a /order route places an order through the OrderService from before, and a /health route exists because every deployed service has one.

from flask import Flask, jsonify, render_template, request

from shop import CardDeclined, OrderService

CATALOG = {"widget": 25, "gadget": 120}

app = Flask("webshop")
service = OrderService()


@app.get("/health")
def health():
    return "ok\n"


@app.get("/quote/<item>")
def quote(item):
    price = CATALOG[item]
    return render_template("quote.html", item=item, price=price)


@app.post("/order")
def order():
    data = request.get_json()
    try:
        charge = service.place(data["amount"], data["card"], tenant=data["tenant"])
    except CardDeclined as exc:
        return jsonify(error=str(exc)), 402
    return jsonify(charge)

Nothing in it mentions wrapture. The quote view will raise a KeyError for an item that is not in the catalog, which Flask will turn into a 500, and that is the request I most want to see.

One entry

The Flask knowledge lives in an instrumentation package rather than in the config. With wrapture-instrumentation installed alongside wrapture, the config gains a single [[instrument]] entry naming Flask, and keeps the observe entries for the shop's own methods from last time:

[[instrument]]
name = "flask"

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

[[observe]]
target = "shop:Gateway"
name = "charge"
redact = ["card"]

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

[[sink]]
type = "printer"

The development server runs under the runner exactly as the script did, with everything after -m flask belonging to Flask:

$ python -m wrapture -m flask --app webshop run --port 5001

Then from another shell, a quote, an order, a declined order and the item that does not exist:

$ curl http://127.0.0.1:5001/quote/widget
$ curl -X POST -H 'Content-Type: application/json' \
    -d '{"amount": 500, "card": "4111-1111-1111-1111", "tenant": "acme"}' \
    http://127.0.0.1:5001/order
$ curl -X POST -H 'Content-Type: application/json' \
    -d '{"amount": 250, "card": "4000-0000-0000-0000", "tenant": "globex"}' \
    http://127.0.0.1:5001/order
$ curl http://127.0.0.1:5001/quote/missing

In the server's log, interleaved with Flask's own access log lines which I have removed here, each request arrives as one tree. The quote:

GET /quote/widget (webshop.wsgi_app)
  quote(item='widget')
    flask:render_template(template_name_or_list='quote.html', context='<context>')
    flask:render_template -> '<17 chars>' [1.5ms]
  quote -> '<p>widget: 25</p>' [1.6ms]
webshop.wsgi_app -> '200 OK' [2.3ms, body 5us over 1 chunk]

The request line opens the tree, the view sits beneath it labelled by its endpoint, the template render sits beneath the view with the template's name and its context masked (it is arbitrary application data, and the render is captured only as its size), and the closing line carries the status as the request's result along with the time to the last byte of the body. The order, with the shop's own methods nesting beneath the view because their bindings fire while the request is in flight:

POST /order (webshop.wsgi_app)
  order()
    shop:OrderService.place(amount=500, card='<redacted>', tenant='acme')
      shop:Gateway.charge(amount=500, card='<redacted>')
      shop:Gateway.charge -> {'id': 'ch_500', 'amount': 500} [7us]
      shop:Ledger.record(entry={'id': 'ch_500', 'amount': 500})
      shop:Ledger.record -> 'led_ch_500' [4us]
    shop:OrderService.place -> {'id': 'ch_500', 'amount': 500} [205us]
  order -> <Response 29 bytes [200 OK]> [471us]
webshop.wsgi_app -> '200 OK' [922us, body 4us over 1 chunk]

The declined card, where the view caught the exception and answered 402, so the failure is on the gateway and the service but not on the request:

POST /order (webshop.wsgi_app)
  order()
    shop:OrderService.place(amount=250, card='<redacted>', tenant='globex')
      shop:Gateway.charge(amount=250, card='<redacted>')
      shop:Gateway.charge !! CardDeclined [6us]
    shop:OrderService.place !! CardDeclined [74us]
  order -> (<Response 38 bytes [200 OK]>, 402) [265us]
webshop.wsgi_app -> '402 PAYMENT REQUIRED' [673us, body 4us over 1 chunk]

And the one I wanted:

GET /quote/missing (webshop.wsgi_app)
  quote(item='missing')
  quote !! KeyError [4us]
webshop.wsgi_app -> '500 INTERNAL SERVER ERROR' !! KeyError [3.2ms, body 6us over 1 chunk]

The request line says two things at once. It answered 500, and the KeyError was the reason. That second half is the part a reader would not guess, because as far as the WSGI middleware recording the request is concerned, the application returned normally. Flask caught the exception on its way out of the view and handed it to handle_exception, which built the 500 response and returned it, so the request completed with a status and no exception. The only place the failure can be seen is inside that handler, where the exception arrives as an argument, and that is where the instrumentation looks. A binding on handle_exception notes the exception against the nearest enclosing request event, using the same note_exception() that the testing series used for a failure the code handled itself, aimed past the handler's own call with current_event(kind="request"). The view's event carries the KeyError as the exception that escaped it, the request's event carries it as a note, and both show up on their lines because two scopes failed for the same reason.

Keeping the noise out

Health checks and static assets make up most of the traffic on a lot of services and none of the interest. In the log above every /health probe printed its own tree, which after a day of a load balancer polling it is most of the file. The instrumentation takes a list of paths not to record:

[[instrument]]
name = "flask"
ignore_paths = ["/health"]

A matching request runs and answers as normal but records nothing at all, and the "at all" matters. Declining the request event alone would leave the view, any lifecycle callbacks and any template render it made on the trace as anonymous roots with no request above them, the same problem tree=True solved on a plain binding in the first post. The setting silences everything beneath an ignored request for its whole extent, so with it in place the health probe leaves only Flask's access log line behind and the next quote prints as before:

127.0.0.1 - - [01/Sep/2026 14:51:43] "GET /health HTTP/1.1" 200 -
GET /quote/gadget (webshop.wsgi_app)
  quote(item='gadget')
    flask:render_template(template_name_or_list='quote.html', context='<context>')
    flask:render_template -> '<18 chars>' [1.5ms]
  quote -> '<p>gadget: 120</p>' [1.7ms]
webshop.wsgi_app -> '200 OK' [2.3ms, body 5us over 1 chunk]

The other switch worth knowing about is lifecycle = false. Flask extensions register before_request and after_request callbacks liberally, for loading users, cleaning up sessions and stamping headers, and the instrumentation observes every one of them by default in the order Flask runs them. For an application with several extensions that is faithful but noisy, and switching it off leaves the callbacks running unobserved.

What the instrumentation is

There is no magic in the [[instrument]] entry. It names an Instrumentation class whose hooks run when Flask is imported, and those hooks apply bindings to three choke points in Flask, using the same bindings as everywhere else. Constructing a Flask instance installs the recording WSGI middleware on its wsgi_app attribute, so every application the process creates is covered however it was made, application factories included. Registering a route substitutes an observed version of the view function, since Flask captures views into its dispatch table the moment @app.route runs, before any binding on the module could have seen them. And handle_exception gets the binding that notes the failure described above. The flask-app example in the wrapture repository is that class written out in full, as a local file next to a config, for anyone who wants to do the same for a framework that has no package yet. The packaged version adds the lifecycle callbacks, error handlers, blueprints and template rendering on top.

The request as an event

Everything the tree shows is on the request event itself, which is what a sink or a test reads. The result is the status line, so every existing filter and assertion that works on a return value works on a request. The duration is wall time from the call to the close of the body, time to last byte, with the synchronous phase and the body's own share recorded separately. The HTTP details, method, path, query string with sensitive parameters already masked, scheme, remote address and the bytes actually served, sit in the event's data, and the instrumentation adds the matched route pattern and endpoint once routing has run, which are the low-cardinality keys a backend groups by. The WSGI request tracing page has the full event, the mode="wsgi" binding form for applications with no framework package, and the redaction rules; ASGI applications get the same treatment on the page beside it.

With a request as one tree and timings on every line, the next question is the one every web application eventually asks, which is where the time is going.

September 10, 2026 12:00 AM UTC

September 09, 2026


Python GUIs

Clean up on exit — Stopping threads when closing a PyQt6 application — How to properly shut down background threads and workers when your application window is closed

I'm using QThreadPool and worker threads in my PyQt application. When I click the X button to close the window, the threads keep running in the background. What's the best way to clean everything up on application exit?

September 09, 2026 06:00 AM UTC

Fixing Crashes When Using NumPy Arrays with QImage in Qt Threads — How to safely pass image data between threads when streaming video or updating displays

I'm using a threaded runner to stream a live video feed by converting a NumPy array to a QImage, then to a QPixmap, and displaying it on a QLabel. But I'm frequently encountering crashes when the label is resized too quickly or the scroll area is scrolled. Could this be a problem with the QImage memory buffer getting cleared before the QPixmap can update? Is this fixable, or is it a fundamental issue with threads in Python/Qt?

September 09, 2026 06:00 AM UTC

How to Check if a QLineEdit is Empty in Python — Empty strings are falsey in Python

A reader asked:

I just want to know, how do I check whether a QLineEdit is empty or not?

The QLineEdit class doesn't have an isEmpty() method which you can call to find out if the line edit is empty, but we don't need one! Instead we can get the current text using .text() and then check if the returned value is an empty string.

Checking QLineEdit Text with .text()

In the code below lineedit is our already created QLineEdit widget.

python
text = lineedit.text()
if text == '': # if the line edit is empty, .text() will return an empty string.
     # do something

Using Python's Falsey Empty Strings

We can simplify this further. In Python empty strings are falsey -- they are considered False values in conditional expressions. So instead of checking the string is empty, we can check if it is true (non-empty) or false (empty).

python
if lineedit.text():
     # do something if there is content in the line edit.

Or, to check if it is empty:

python
if not lineedit.text():
     # do something if the line edit is empty.

Complete Example: Detecting Empty QLineEdit with Signals

Below is a small demo application which updates a label to indicate if the QLineEdit has text in it or not. In this we use Qt signals to send the current text to a slot method every time it is updated.

python
import sys

from PyQt5.QtWidgets import QApplication, QLabel, QLineEdit, QVBoxLayout, QWidget


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

        self.lineedit = QLineEdit()
        self.lineedit.textChanged.connect(self.text_changed)

        self.label = QLabel()

        vlayout = QVBoxLayout()
        vlayout.addWidget(self.lineedit)
        vlayout.addWidget(self.label)

        self.setLayout(vlayout)

    def text_changed(self, s):

        # s contains the text of the line edit, we could also test self.lineedit.text()

        if s:
            self.label.setText("Not empty")

        else:
            self.label.setText("Empty")


app = QApplication(sys.argv)

w = Window()
w.show()

app.exec_()
python
import sys

from PyQt6.QtWidgets import QApplication, QLabel, QLineEdit, QVBoxLayout, QWidget


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

        self.lineedit = QLineEdit()
        self.lineedit.textChanged.connect(self.text_changed)

        self.label = QLabel()

        vlayout = QVBoxLayout()
        vlayout.addWidget(self.lineedit)
        vlayout.addWidget(self.label)

        self.setLayout(vlayout)

    def text_changed(self, s):

        # s contains the text of the line edit

        if s:
            self.label.setText("Not empty")

        else:
            self.label.setText("Empty")


app = QApplication(sys.argv)

w = Window()
w.show()

app.exec()

python
import sys

from PySide2.QtWidgets import QApplication, QLabel, QLineEdit, QVBoxLayout, QWidget


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

        self.lineedit = QLineEdit()
        self.lineedit.textChanged.connect(self.text_changed)

        self.label = QLabel()

        vlayout = QVBoxLayout()
        vlayout.addWidget(self.lineedit)
        vlayout.addWidget(self.label)

        self.setLayout(vlayout)

    def text_changed(self, s):

        # s contains the text of the line edit

        if s:
            self.label.setText("Not empty")

        else:
            self.label.setText("Empty")


app = QApplication(sys.argv)

w = Window()
w.show()

app.exec_()


python
import sys

from PySide6.QtWidgets import QApplication, QLabel, QLineEdit, QVBoxLayout, QWidget


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

        self.lineedit = QLineEdit()
        self.lineedit.textChanged.connect(self.text_changed)

        self.label = QLabel()

        vlayout = QVBoxLayout()
        vlayout.addWidget(self.lineedit)
        vlayout.addWidget(self.label)

        self.setLayout(vlayout)

    def text_changed(self, s):

        # s contains the text of the line edit

        if s:
            self.label.setText("Not empty")

        else:
            self.label.setText("Empty")


app = QApplication(sys.argv)

w = Window()
w.show()

app.exec_()


Run the above and you'll see the label update as you add and remove text in the QLineEdit.

Empty QLineEdit widget in PyQt/PySide

QLineEdit with content in PyQt/PySide

This approach works across all Python Qt bindings including PyQt5, PyQt6, PySide2 and PySide6. By leveraging Python's truthiness checks on strings, you can validate QLineEdit input cleanly without needing a dedicated isEmpty() method. For more advanced input validation techniques, you may also want to look at input validation in Tkinter or explore the full range of PyQt6 widgets available for building your applications.

September 09, 2026 06:00 AM UTC


Bob Belderbos

Database-Driven RBAC with FastAPI and Azure Entra ID

This came out of a coaching project: a FastAPI service backing a geotechnical database. We use Azure Entra ID for authentication, but we needed role-based access control (RBAC) granularly per endpoint and across different user roles. It turned into an interesting sprint where we designed it so that role changes did not require code changes.

September 09, 2026 12:00 AM UTC


Graham Dumpleton

Zero-code tracing with wrapture

The previous post traced the shop with three bindings and a sink, all applied from the program's own entry point. That is fine when the program is yours. It is less fine when the application is one you inherited and would rather not touch, when someone else owns the deployment, or when you simply do not want observation code living inside the thing being observed. For all of those the entry point edit is one edit too many.

The same setup can live in a file next to the project instead, with nothing in the program saying so.

The file

A wrapture.toml says what to observe and where the events go. For the shop from last time, with the card number redacted as before, that is one [[observe]] entry per method and one sink:

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

[[observe]]
target = "shop:Gateway"
name = "charge"
redact = ["card"]

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

[[sink]]
type = "printer"

The target is always an exact module or module:path, never a pattern, and the members within it come from name for exact members or match for a glob over the target's own immediate members. That is deliberate. A pattern's blast radius is one level of one named container, stated on the line above it, so match = "*" on shop:OrderService can never accidentally wrap something in another module.

The program itself is main.py, and it now contains no mention of wrapture at all:

import orders

orders.run()

The python -m wrapture runner applies the config and then runs the program as __main__, the same -m convention as pdb, cProfile and coverage:

$ python -m wrapture main.py
shop:OrderService.place(amount=500, card='<redacted>', tenant='acme')
  shop:Gateway.charge(amount=500, card='<redacted>')
  shop:Gateway.charge -> {'id': 'ch_500', 'amount': 500} [8us]
  shop:Ledger.record(entry={'id': 'ch_500', 'amount': 500})
  shop:Ledger.record -> 'led_ch_500' [7us]
shop:OrderService.place -> {'id': 'ch_500', 'amount': 500} [285us]
shop:OrderService.place(amount=250, card='<redacted>', tenant='globex')
  shop:Gateway.charge(amount=250, card='<redacted>')
  shop:Gateway.charge !! CardDeclined [6us]
shop:OrderService.place !! CardDeclined [90us]
shop:OrderService.place(amount=120, card='<redacted>', tenant='globex')
  shop:Gateway.charge(amount=120, card='<redacted>')
  shop:Gateway.charge -> {'id': 'ch_120', 'amount': 120} [4us]
  shop:Ledger.record(entry={'id': 'ch_120', 'amount': 120})
  shop:Ledger.record -> 'led_ch_120' [4us]
shop:OrderService.place -> {'id': 'ch_120', 'amount': 120} [115us]

That is the same trace as before, from a program whose source has not changed. The ordering is what makes it work. The config is applied before the target runs, but applying it imports nothing: each observe entry registers a post-import hook for its target module, and the bindings land at the moment the application itself imports shop, in the application's own import order. A from shop import OrderService somewhere in the program still picks up the observed class, because the observation is already in place when that line runs, and the program's import order is never changed by observing it.

Keeping the trace

A printer is for watching. For a program that runs longer than you are willing to sit and look at it, the sink is a file. Swapping the [[sink]] entry for a JSON Lines one is the only change:

[[sink]]
type = "jsonlines"
path = "trace.jsonl"

Each completed event is written as one JSON object per line, when the event closes, so every line carries the outcome and the timing. The declined charge from the second order looks like this:

{
  "seq": 5,
  "parent_id": 4,
  "depth": 1,
  "kind": "call",
  "path": "shop:Gateway.charge",
  "thread_id": 140704287927360,
  "thread_name": "MainThread",
  "started": 1150729.898723529,
  "duration": 0.000004197005182504654,
  "arguments": {
    "amount": 250,
    "card": "<redacted>"
  },
  "exception": {
    "type": "CardDeclined",
    "message": "card ending 0000 declined"
  },
  "trace": {
    "w3c": {
      "trace_id": "12cd461196239288a8b50e265b6a0f1a",
      "sampled": true
    }
  }
}

The seq and parent_id fields are enough to rebuild the tree, and a field that is absent means it was not captured, so a call that returned None and a call whose result was never recorded stay distinguishable. The format is the one that jq, pandas and most log tooling read directly, which means the questions I would otherwise have scrolled a terminal to answer become one-liners. Every call to the gateway, with what it was given and what came back:

$ jq -c 'select(.path | endswith("Gateway.charge")) | {seq, arguments, result, exception}' trace.jsonl
{"seq":2,"arguments":{"amount":500,"card":"<redacted>"},"result":{"id":"ch_500","amount":500},"exception":null}
{"seq":5,"arguments":{"amount":250,"card":"<redacted>"},"result":null,"exception":{"type":"CardDeclined","message":"card ending 0000 declined"}}
{"seq":7,"arguments":{"amount":120,"card":"<redacted>"},"result":{"id":"ch_120","amount":120},"exception":null}

And everything that raised, which shows the exception at the gateway and again at the order that let it escape:

$ jq -c 'select(.exception) | {path, exception}' trace.jsonl
{"path":"shop:Gateway.charge","exception":{"type":"CardDeclined","message":"card ending 0000 declined"}}
{"path":"shop:OrderService.place","exception":{"type":"CardDeclined","message":"card ending 0000 declined"}}

Two properties make this safe to leave running against something real. The application never waits on the file: lines go onto a bounded queue drained by a background thread, and if the queue fills the line is dropped and counted rather than making the observed call block. And the sink captures values as bounded summaries, so an unserialisable argument becomes a short description rather than an error, and no live object is retained. For a process that runs for days the path can carry a date or time variable and rotate on an interval; the output paths section of the documentation has that. The file is also what the exporters read afterwards, so a trace recorded overnight can be rendered for Perfetto the next morning.

No launcher at all

The runner still owns the command line, and sometimes that is not available either. A service manager, a container entry point or a WSGI server starts the process and you do not get to put python -m wrapture in front of it. For that case the same config can be injected at interpreter startup through autowrapt, a package of mine from some years ago that exists precisely to run registered code once site initialisation completes. Two opt-ins gate it, both outside wrapture:

$ pip install autowrapt
$ AUTOWRAPT_BOOTSTRAP=wrapture python main.py

The output is identical to the runner's. Installing autowrapt is what makes interpreter startup do anything at all, and the environment variable names wrapture as the thing to bootstrap. Absent either, the entry in wrapture's package metadata is inert, and wrapture itself has no dependency on autowrapt. Underneath, both doors lead to the same place: the post-import hook machinery in wrapt, which autowrapt was originally built on, is what lets wrapture apply a config to modules that have not been imported yet.

The positioning matters here. Injection is a development, staging and break-glass tool. The unwritten rule for autowrapt has always been that it is not installed on production systems in normal circumstances, precisely because of what it enables, and that installation gate is the feature. Production tracing is the code-level path from the previous post, or a config applied deliberately by the application at startup. Two consequences follow from the mechanism. A config that is missing, or that cannot be applied, warns and lets the process start untraced, because an error at bootstrap would be fatal to an interpreter that has not even started, and the environment variable reaches every Python process launched under it, not only the one you meant. And the bootstrap imports no application code, so bindings still land as the application imports its own modules.

Operating a traced process

Once injected, the process is still operable. The bootstrap keeps its record of what was applied on wrapture.bootstrap.applied, and from a console, a debugger or a signal handler that record answers what is installed and lets you switch it off and on without a restart. Running the shop under python -i so the interpreter drops to a prompt afterwards:

$ AUTOWRAPT_BOOTSTRAP=wrapture python -i main.py
...
>>> import wrapture.bootstrap
>>> applied = wrapture.bootstrap.applied
>>> print(applied.report())
sink: Printer()
applied:
  shop:OrderService.place
  shop:Gateway.charge
  shop:Ledger.record
>>> applied.suspend()
>>> import orders; orders.run()
>>> applied.resume()
>>> orders.run()
shop:OrderService.place(amount=500, card='<redacted>', tenant='acme')
  shop:Gateway.charge(amount=500, card='<redacted>')
...

While suspended, the wrappers stay in place and the calls pass straight through, so the second orders.run() printed nothing; after resume() the third printed the full trace again. revert() takes the whole intervention down, restoring the patched locations. The config section of the ad-hoc tracing page covers everything the file can say beyond what I have used here, including capturing log messages as events beside the calls, and naming instrumentation that a package ships for a framework.

That last one is where this goes next, because the shop is not really a program that runs three orders and exits. It is a web application, and a web application has a unit of work that a plain binding cannot see.

September 09, 2026 12:00 AM UTC

September 08, 2026


PyCoder’s Weekly

Issue #751: Profiling, From pandas to Polars, NotImplemented, and More (2026-09-08)

September 08, 2026 07:30 PM UTC


Django Weblog

Call for volunteers: Fundraising Working Group

The Django Software Foundation is looking for people to join the Fundraising Working Group.

This is a particularly interesting time to get involved.

The DSF has raised its 2026 fundraising goal to $500,000. That funding is what allows us to continue supporting the Django Fellows, Django Girls, community events, Djangonaut Space, infrastructure, and the many other things that keep the Django ecosystem going. It also gives the DSF the room to do something new: hire its first Executive Director.

You can read more about the DSF's fundraising goals for 2026 in this post.

Getting there will take more than asking people to donate. We need to think about how we build relationships with companies that depend on Django, how we make sponsorships meaningful, how we find new ways for organisations to support the project, and how we communicate the value of investing in Django.

That is where the Fundraising Working Group comes in.

The DSF is hiring an Executive Director who will bring dedicated, day-to-day leadership to the Foundation, including sponsorship development and partner relationships. The Fundraising Working Group will have an opportunity to work closely with the person in this role as we build out our fundraising efforts.

Who are we looking for?

We'd love to have people who have done this before.

If you have experience with fundraising, sponsorships, partnerships, business development, sales, donor relationships, or building relationships with companies, there is plenty of scope to put that experience to work. We need people who can help identify opportunities, open doors, develop ideas, and turn them into actual fundraising initiatives.

But you don't need to be a fundraising expert to join.

Maybe you've never worked on fundraising before, but you know how companies make decisions about supporting open source. Maybe you have ideas for how Django could engage organisations that rely on it. Maybe you are good at building relationships, telling a compelling story, organising initiatives, or simply getting things moving.

Those perspectives are useful too.

We're looking for a group that can bring both experience and fresh ideas; people who can help drive the work as well as people who are excited to learn and contribute.

The working group meets monthly and works asynchronously between meetings. You can read more about how the group operates in the Fundraising Working Group charter.

Interested in joining?

Apply to join the Fundraising Working Group

Whether you have years of fundraising experience or are completely new to it but ready to help, we would love to hear from you.

September 08, 2026 07:27 PM UTC


Python Bytes

#495 Banned

Topics include EuroPython 2026 videos are online, The State of Django 2026: Boring is so back, htmx 4.0.0 has been released, and Functionally Zen.

September 08, 2026 06:48 PM UTC


LernerPython blog, from Reuven Lerner

Claude Code always produces something. That’s the hard part.

I've been using Claude Code several hours a day for months, and I'm having a blast. But an agent does what you tell it, not what you meant — which is why validating your results now matters more than the results themselves.

The post Claude Code always produces something. That’s the hard part. appeared first on LernerPython.

September 08, 2026 01:04 PM UTC


Graham Dumpleton

Live tracing with wrapture

When I wrote about unit testing with wrapture the pattern in every test was the same: create a binding on a method, open a timeline(), run the code, and read the recorded calls off the tape. What I did not say at the time is that nothing about a binding is specific to testing. A binding observes a call site and emits events, and what happens to those events is decided by whoever is listening. In a test the listener is a tape. Take the tape away and register something else, and the same binding narrates a running program as it goes.

That is the whole idea behind the tracing side of wrapture, and this post is the minimal version of it: the shop from the testing series, three bindings, and one sink.

The shop

The code is the order service from the earlier posts, grown just enough to have something worth watching. A card number now travels with the order, the gateway declines cards ending in four zeros, and each order belongs to a tenant.

class CardDeclined(Exception):
    pass


class Gateway:
    def charge(self, amount, card):
        if card.endswith("0000"):
            raise CardDeclined(f"card ending {card[-4:]} declined")
        return {"id": f"ch_{amount}", "amount": amount}

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


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


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


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

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

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

That lives in shop.py. A second module, orders.py, places three orders, one of which will be declined:

from shop import CardDeclined, OrderService

ORDERS = [
    (500, "4111-1111-1111-1111", "acme"),
    (250, "4000-0000-0000-0000", "globex"),
    (120, "5555-4444-3333-2222", "globex"),
]


def run():
    service = OrderService()
    for amount, card, tenant in ORDERS:
        try:
            service.place(amount, card, tenant=tenant)
        except CardDeclined:
            pass

The question to answer is a simple one. When an order is placed, what actually happens? Which methods run, with what, and what comes back? A log line would answer that only in the places where someone had already thought to add one, and this code has none.

Three bindings and a sink

The entry point applies a binding to each of the three methods that matter and registers a Printer, which is the simplest sink wrapture ships: it prints each event to standard error as it happens.

import wrapture

from shop import Gateway, Ledger, OrderService
import orders

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

wrapture.add_sink(wrapture.Printer())

orders.run()

There is no timeline() anywhere in that. The bindings are applied for the life of the process, the sink is registered for the life of the process, and events flow from one to the other. Running it, the output is:

shop:OrderService.place(amount=500, card='4111-1111-1111-1111', tenant='acme')
  shop:Gateway.charge(amount=500, card='4111-1111-1111-1111')
  shop:Gateway.charge -> {'id': 'ch_500', 'amount': 500} [8us]
  shop:Ledger.record(entry={'id': 'ch_500', 'amount': 500})
  shop:Ledger.record -> 'led_ch_500' [6us]
shop:OrderService.place -> {'id': 'ch_500', 'amount': 500} [239us]
shop:OrderService.place(amount=250, card='4000-0000-0000-0000', tenant='globex')
  shop:Gateway.charge(amount=250, card='4000-0000-0000-0000')
  shop:Gateway.charge !! CardDeclined [5us]
shop:OrderService.place !! CardDeclined [60us]
shop:OrderService.place(amount=120, card='5555-4444-3333-2222', tenant='globex')
  shop:Gateway.charge(amount=120, card='5555-4444-3333-2222')
  shop:Gateway.charge -> {'id': 'ch_120', 'amount': 120} [4us]
  shop:Ledger.record(entry={'id': 'ch_120', 'amount': 120})
  shop:Ledger.record -> 'led_ch_120' [3us]
shop:OrderService.place -> {'id': 'ch_120', 'amount': 120} [104us]

Each operation gets a line when it begins, indented by how deeply it is nested, and a closing line with the outcome and how long it took. A -> marks a return value and !! marks an exception, so the declined card is visible at a glance, and so is the fact that Ledger.record never ran for that order. These are the real arguments and the real results, the same -> and !! markers that tape.tree() uses in a test, only arriving live rather than being reconstructed afterwards.

The first thing I noticed in that output is something the trace should not contain. The card numbers are in it, in full, because the bindings captured the arguments as given. The same redact() capture policy the testing series used for keeping secrets off a tape works here, since the binding is the same object:

wrapture.binding(OrderService, "place", capture=wrapture.redact("card")).apply()
wrapture.binding(Gateway, "charge", capture=wrapture.redact("card")).apply()

With that in place the opening lines read card='<redacted>' and everything else is unchanged. I have left it on for the rest of the post, since a trace that is going to be looked at, streamed to a file, or sent anywhere, is exactly the place a card number should not be.

What it costs when nobody is listening

The obvious worry about leaving bindings applied in a program is what they cost when nothing is being traced. The recording gate in wrapture is not "is there a timeline" but "is anything listening". A tape scoped to a test is one kind of listener, a process sink is another, and when neither is present an applied binding constructs no event at all. The wrapped method runs with only wrapt's own dispatch on top, which the documentation puts at about half a microsecond per call on the machine it was measured on. That is what makes it reasonable to bind the interesting methods once, in the entry point, and let the sink decide whether anything is recorded.

Seeing less

Three orders is a readable trace. Three thousand is not, and the answer is rarely to bind fewer things, because the point of binding the layers is to have them there when a question comes up. The tools for narrowing sit either at the sink or at the binding.

At the sink, combinators wrap a sink and gate what reaches it. Depth(1, ...) forwards only the roots of each tree, which turns the trace into one opening and one closing line per order:

wrapture.add_sink(wrapture.Depth(1, wrapture.Printer()))
shop:OrderService.place(amount=500, card='<redacted>', tenant='acme')
shop:OrderService.place -> {'id': 'ch_500', 'amount': 500} [149us]
shop:OrderService.place(amount=250, card='<redacted>', tenant='globex')
shop:OrderService.place !! CardDeclined [33us]
shop:OrderService.place(amount=120, card='<redacted>', tenant='globex')
shop:OrderService.place -> {'id': 'ch_120', 'amount': 120} [46us]

At the binding, when= takes a predicate that is consulted before any event exists. A falsey answer means no event is constructed, no arguments are captured and nothing is delivered, which is the cheap way to narrow a hot call site. Here it records orders for one tenant only:

def acme_only(instance, args, kwargs):
    return kwargs.get("tenant") == "acme"

place = wrapture.binding(OrderService, "place", when=acme_only,
                         capture=wrapture.redact("card")).apply()

Running the three orders again gives this:

shop:OrderService.place(amount=500, card='<redacted>', tenant='acme')
  shop:Gateway.charge(amount=500, card='<redacted>')
  shop:Gateway.charge -> {'id': 'ch_500', 'amount': 500} [7us]
  shop:Ledger.record(entry={'id': 'ch_500', 'amount': 500})
  shop:Ledger.record -> 'led_ch_500' [6us]
shop:OrderService.place -> {'id': 'ch_500', 'amount': 500} [245us]
shop:Gateway.charge(amount=250, card='<redacted>')
shop:Gateway.charge !! CardDeclined [5us]
shop:Gateway.charge(amount=120, card='<redacted>')
shop:Gateway.charge -> {'id': 'ch_120', 'amount': 120} [4us]
shop:Ledger.record(entry={'id': 'ch_120', 'amount': 120})
shop:Ledger.record -> 'led_ch_120' [4us]

The globex orders are gone, but their gateway and ledger calls are not. A when= decline skips exactly one event, the declined operation's own, and whatever records beneath it still records, now with nothing above it, so each inner call turns up as an anonymous root with no place to explain it. Sometimes that is exactly what you want, since a binding whose only job is to intervene in a call should not silence what runs beneath it. When the intent is "nothing from here down", tree=True says so:

place = wrapture.binding(OrderService, "place", when=acme_only, tree=True,
                         capture=wrapture.redact("card")).apply()
shop:OrderService.place(amount=500, card='<redacted>', tenant='acme')
  shop:Gateway.charge(amount=500, card='<redacted>')
  shop:Gateway.charge -> {'id': 'ch_500', 'amount': 500} [7us]
  shop:Ledger.record(entry={'id': 'ch_500', 'amount': 500})
  shop:Ledger.record -> 'led_ch_500' [6us]
shop:OrderService.place -> {'id': 'ch_500', 'amount': 500} [251us]

Now the decline covers the whole extent of the declined operation, and the trace is one tenant's orders and nothing else. The skipped calls are not simply lost, either. Each binding counts the operations it declined on filtered_calls, and after this run place, charge and record report 2, 2 and 1 respectively (the second globex order raised before reaching the ledger), so a trace shorter than expected can be explained rather than guessed at.

Where this leaves things

The whole intervention is a few lines in the program's entry point: bind the methods that matter, register a sink, and the program describes what it is doing as it runs, with real arguments and real results, and costs next to nothing when nothing is listening. The sink protocol itself is three notifications, so a sink that counts, samples, filters, or writes somewhere of your own is a small class, and the ad-hoc tracing page of the documentation covers that side, along with the other combinators and the collectors that keep numbers rather than events.

Those few lines in the entry point are still lines in the program, though. For code you cannot or would rather not edit, they can move out of the program entirely, into a file that sits next to it.

September 08, 2026 12:00 AM UTC

September 07, 2026


Bob Belderbos

5 design patterns used in my new habit tracker app

There are plenty of habit trackers, but I wanted to build mine with constraints, a calendar view and habit streaks. So I built commitgraph: a small Django + HTMX app. Here are five Python design and testing patterns from building it.

September 07, 2026 12:00 AM UTC


Armin Ronacher

Astra for Coding: Why Are We Doing This Again?

September 07, 2026 12:00 AM UTC

September 06, 2026


Glyph Lefkowitz

... but what about video games?

Playing video games also uses a GPU. Is using a local LLM for coding any worse than that?

September 06, 2026 10:57 PM UTC