skip to navigation
skip to content

Planet Python

Last update: November 01, 2026 07:48 AM UTC

November 01, 2026


The Python Coding Stack

Notes from the Near Future • The Permanently Curious Stack

Things are changing around us. Quickly. Some fields are ahead of others, but all are moving in the same direction.

A year and a half ago, many programmers were intrigued by how AI could help them but most were confident they’d still be the ones writing most of the code.

Not any more.

In my work, AI tools now handle implementation better and faster than I could on my own. But I’m still in the driving seat.

I now spend most of my day interacting with AI agents. But I’m not lounging by the beach while my agents do stuff for me. I’m collaborating with my agents. Managing them. Guiding them. Bouncing ideas off them. Reviewing their work. Steering them.

It’s impossible to disentangle my contributions from my agents’ contributions when you see the output. It’s all intertwined. It’s a collaboration.

And since the AI agents are doing all the implementation, I can focus on the bigger picture, the bigger ideas, the creative aspects. I’m enjoying this process.


That’s why The Python Coding Stack today becomes:

Notes from the Near Future • The Permanently Curious Stack

I’ll still write about Python. That won’t change. The Python Coding Stack is now a section of Notes from the Near Future.

But I won’t write only about Python. That’s why this publication is changing, just like the world around it.

I’ll write about how I’m adapting to this new way of working. What I like about it. And what I don’t. What I’m experimenting with. What works and what doesn’t.

This applies to everyone, not just those interested in programming and Python.

You may also have noticed the Agents Unpacked section in recent months. The change in what I write about is not starting now. It started a few months ago as I found myself spending more and more time with agents rather than with Python (directly). This section is where I’ll focus on how AI agents work and what they are, since we all need to learn how these tools work. And I’m learning in public in this section. I’ll also write about my experiments and experiences using agents.

You’ll also see an Agent & Co. section in the menu. You won’t receive these posts in your feed by default, but you can turn them on if you wish! Twice a week, I’ll share a fun perspective about human-agent interactions. Think of it as a day in the life of Monty – that’s my agent’s name. But behind the light-hearted nature of these cartoons and comics, you’ll gain an appreciation of the realities of working with agents. There’s plenty to learn from these posts!

Here’s the first comic, published earlier today:

How about brain atrophy? Will I lose my sharpness by handing the coding over to agents? As you’ll see from my articles over the coming weeks, I don’t think that’s the case. Working with AI is not about being lazy but about focusing on other aspects of problem-solving.

But I don’t want to get rusty with coding, a skill I enjoy. That’s why I’m starting to learn Rust. So, there’s another new section here on Notes from the Near Future where I’ll be learning Rust in public. There’s nothing published in Getting Rusty yet, but expect the series to start before the end of this year.

By the way, you can customise which sections you receive. But I’d suggest exploring the new sections for a bit first, especially if you’re an AI-sceptic!

So here’s the new line-up for you on Notes from the Near Future • The Permanently Curious Stack:

Subscribe now

I have some more ideas, too. But that’s enough change for one day.

This publication is three and a half years old. There are now over 9,000 subscribers, and that number is still growing daily. One constant in this publication has been that I’ve always published articles that I would want to read myself. That won’t change. In fact, that’s why this publication is broadening its focus.

Another constant throughout the years has been that I’ve always published the majority of my work for free, fully accessible to all. That won’t change, either. But I do publish content exclusively for premium subscribers, and some of my upcoming series will be available only for premium subscribers. It takes plenty of work to produce a publication such as this and keep it running for so long. The support from premium subscribers helps keep this publication thriving.

As always, feel free to reply to this post and any other post. Tell me what you’re working on, how your work is changing (or not), share your thoughts, views, anything, really.

See you in the next post or somewhere in the online world before then…

Stephen

Subscribe now

stephengruppetta.com

November 01, 2026 12:33 AM UTC

October 31, 2026


The Python Coding Stack

Thoroughly Checked

Subscribe now

stephengruppetta.com

October 31, 2026 11:46 PM UTC


Jaime Buelta

Software is a discovery process

The development of software is a relatively new discipline, and one that is constantly changing. The last couple of years have been an earthquake with the arrival of AI, but even before that, the field changed beyond recognition from one decade to the next. Still, there’s something that I think is part of its essence, and it hasn’t changed. It has been widely recognised for the last quarter of a century, since the Agile Manifesto. It is borderline impossible to know exactly what functionality a software project needs until it’s done. And... Read More

October 31, 2026 06:13 PM UTC


Seth Michael Larson

Extracting Billy Hatcher “Joycarry Game” ROMs with Python

Billy Hatcher and the Giant Egg contains a feature called “Joycarry Games” similar to Animal Crossing’s NES game “Advance Play” feature. I wanted to extract the ROMs for these mini-games just like I did for Animal Crossing. Doing so wasn’t as straightforward; the ROMs aren’t available directly in the disc filesystem like they are in Animal Crossing. To extract the ROMs I ported an existing Rust program to Python (“rewrite-it-in-Python”, anyone?) that receives and saves the ROMs from the game running in Dolphin using the GBA TCP and Joyboot protocols.

These games are simple, and I only really played one as a kid: Puyo Pop. The game is similar to “Dr. Robotnik’s Mean Bean Machine”, which was a favorite of mine. This was the only way I could take that game “on-the-go”, often downloading the game to my GBA before road trips to see my grandparents.


Joycarry Game menu in Billy Hatcher with “ChuChu Rocket! Challenge” selected

The Python program is a port of this MIT licensed Rust program by Icedude907 with added functionality to save the EWRAM data after the ROM has been transmitted to the program via GBA TCP. I’ve distributed this program via a GitHub Gist.

Using the program requires the Dolphin emulator and dumping the ROM of the Billy Hatcher GameCube disc you intend to download Joyboot ROMs from. In the case of Billy Hatcher you also need to have unlocked the five different eggs hidden in levels. The fastest way to get around this is to download a 100% save file for your game’s region and place the file into Dolphin’s save file folder.

To extract the ROMs, launch Dolphin and set the controller in port 4 to be “GBA (TCP)”; Billy Hatcher in particular only works if the GC-to-GBA link cable is plugged into port 4. Start Dolphin playing Billy Hatcher, navigate to the world map, and then select “Joycarry Game”. From here you can select each game to be downloaded to our “GBA”, and the program should display information about receiving the ROM data.

Language setting and Joycarry ROMs

Something I did not know was that two of the five Joycarry ROMs, “ChuChu Rocket! Challenge” and “Puyo Pop”, have translations for both English and Japanese. Which Joyboot ROM translation is sent to you depends on the game region and which language is selected in the menu.


“ChuChu Rocket! Challenge”


“Puyo Pop”

The NTSC region (GEZE8P) of the game only supports English (no language selector in options) and only has English Joycarry ROMs. The NTSC-J region (GEGJ8P) supports Japanese and English, sending the corresponding Joycarry ROM depending on which language is selected. The PAL region (GEZP8P) supports English, French, German, Spanish, Italian, and ... Japanese! I checked each language, and the English Joycarry ROM is sent for all languages except Japanese.

Here are the SHA256 checksums of each ROM by game title and language:

ROM Title Language SHA256
CHU2 AGB EN 131258b587b4724ea06a5a0fbdce4d6ec05c8e63ef6b10a6000dfa8efd57c435
CHU2 AGB JP 5adc33400eead9ce3d327377b7ce150a48e632abc2494370acb0b6ed61c1337a
NIGHTS EN 944fadb8fac3abf8c7db38f837677be3d3a664c56e6c8eb58e9e1949a10ac5f9
BILLYHATCHER EN 128afd1c40a65831bd013c2c91abdadd816155df2b1a5456a7af6d260d48108e
BILLYHATCHER EN 2af4d14151981c2341c193cd2cc3d38e10dee1336ebedcd264f8e8bcb5e1f87e
MINNADE PUYO EN e769bd190bee6735a97cd239f587e3921108073a6f20ab20b8d62ba9c447cc12
MINNADE PUYO JP 521f6a2a0876f2685205c0cfbfde934c14b8b3a6b5c173f4aa9fdfe93aed80a5

Happy Halloween! 🎃👻



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



October 31, 2026 12:00 AM UTC

October 30, 2026


PyCharm

Summary

Available fixed versions

The issue is fixed in the following versions:

The 2025.2.6.* updates for all IDEs now contain only the JBR fix. They will get a separate hotfix for the JCEF problem. Until then, use a workaround to disable JCEF in your IDE (see instructions below).

IDE versions 2025.1 and earlier, and Toolbox App 3.7 and earlier, will not receive this fix. You must update to a fixed version.

Details

In October 2026, the private keys of eight intermediate certificates issued by the certificate authority eMudhra were compromised. An attacker who can intercept your network traffic could use them to impersonate websites and services.

Full details of the incident.

JetBrains IDEs check certificates against the trust store of their bundled runtime, JetBrains Runtime (JBR), so updating your operating system doesn’t protect against this issue. We’ve integrated Oracle’s fixes for CVE-2026-91403 and CVE-2026-114820 into JBR as-is. The updated runtime blocks the compromised certificates and no longer trusts these root certificates:

In the fixed versions of 2025.3 and later, the embedded browser now receives Chrome’s certificate updates, which block the compromised certificates (CVE-2026-118696). It downloads them from Google’s update servers shortly after the IDE starts. If your network blocks access to Google’s update servers, the embedded browser stays unprotected. In this case, turn it off as described below.

The Toolbox App and JetBrains IDEs also install a background service, jetbrainsd. Its list of trusted certificates is built into the service itself, so updating JBR doesn’t fix it. We’ve released a separate fix in jetbrainsd 0.9.9728, tracked as CVE-2026-118697. Only one copy of jetbrainsd runs for each user, and a newer version always replaces an older one. Updating any app that includes jetbrainsd 0.9.9728 fixes it for all JetBrains apps.

What you should do: Update now

The fix is available in the latest versions of JetBrains IDEs, from 2025.2 up to 2026.3 RC. The upcoming 2026.3 release will include it too. Updating to the latest bugfix release of your current version is enough. Versions 2025.1 and earlier will not receive a fix. If you use one of them, please update to one of the versions above.

If you use ReSharper or other JetBrains .NET tools, update them to the latest version.

Important for Toolbox App users: versions 3.7 and earlier won’t receive this fix. If you’re on 3.7, update to version 3.9.1 or 3.8.2.

If you use JetBrains Gateway, update it to one of the following versions: 2026.1.4, 2025.3.3, 2025.2.6.

If you use JetBrains Runtime directly, update to one of the fixed builds: jbr25.635.79, jbr25.508.41, jbr25.329.142, jbr21.1163.125, jbr21.1038.91.

In rare cases, the IDE will no longer connect to a server whose certificate chains to one of the removed root certificates. The server’s owner will need to replace the certificate.

If you can’t update right away

Clean your certificates manually

The Java runtime bundled with these IDEs and Toolbox App still trusts two eMudhra-related root certificates. If you can’t update the IDE, give it a cleaned copy of its certificate list and turn off its built-in browser, which has its own list that can’t be changed. It takes about five minutes and is fully reversible.

  1. Copy the certificate list from the IDE folder to your home folder:
    macOS: <IDE>.app/Contents/jbr/Contents/Home/lib/security/cacerts
    Windows / Linux: <IDE>/jbr/lib/security/cacerts
     
  2. Remove the two affected roots, using the keytool in the IDE’s jbr/bin folder:
    keytool -delete -keystore <copy> -storepass changeit -alias "comodoaaaca [jdk]"
    keytool -delete -keystore <copy> -storepass changeit -alias "emsigneccrootcag3 [jdk]"
     
  3. Open Help | Edit Custom VM Options, add this line, then restart the IDE:
    -Djavax.net.ssl.trustStore=<full path to the copy>
    Important: write the full path, with no ~, no variables, and no quotes. A wrong path silently leaves you unprotected.
     
  4. In Settings | Tools | Server Certificates, make sure “Accept non-trusted certificates automatically” is off.
     
  5. Linux only: update the ca-certificates package.

What to expect: If the IDE shows an “untrusted server certificate” dialog for an eMudhra certificate, click Reject.

Disable JCEF in your IDE

To workaround the JCEF problem in 2025.2.6.4 and previous versions, or in any version if your network blocks Google’s update servers:

  1. Open Search Everywhere -> Actions
  2. Type “Registry”
  3. Open Registry
  4. Search for ‘ide.browser.jcef.enabled’ key
  5. Switch it off
  6. Restart

Note: Turning off the embedded browser affects features that use it, for example, Markdown and HTML previews and Jupyter notebook output. Some plugins may also stop working correctly. After you update to a fixed version, turn ide.browser.jcef.enabled back on and restart the IDE.

Update jetbrainsd background service

The Toolbox App and JetBrains IDEs use a background service, jetbrainsd. It has its own built-in list of trusted certificates, so updating JBR doesn’t fix it, and the workarounds above don’t apply to it.

The fix is in jetbrainsd 0.9.9728. You get it when you install or update a fixed version of the Toolbox App or an IDE and start it. Only one copy of jetbrainsd runs for each user, and the newest version replaces older ones. So updating the Toolbox App fixes jetbrainsd for all your IDEs, even if you can’t update the IDEs themselves.

If you can’t update the Toolbox App or an IDE, update jetbrainsd manually:

  1. Download the archive for your platform and check its SHA-256 checksum with shasum -a 256 (macOS), sha256sum (Linux), or Get-FileHash (Windows PowerShell):
  2. Unpack the archive and run jetbrainsd once without arguments. It replaces the older version. To unpack, use tar -xzf. On Windows 10 and later, tar is built in.
    • Linux: ./jetbrainsd/bin/jetbrainsd
    • macOS: ./jetbrainsd.app/Contents/MacOS/jetbrainsd
    • Windows: .\jetbrainsd\bin\jetbrainsd.exe
       
  3. Check the installed version. It should be 0.9.9728 or later:
    • Linux: "${XDG_DATA_HOME:-$HOME/.local/share}/JetBrains/Daemon/bundles/current/jetbrainsd/bin/jetbrainsd" --version
    • macOS: "$HOME/Library/Application Support/JetBrains/Daemon/bundles/current/jetbrainsd.app/Contents/MacOS/jetbrainsd" --version
    • Windows (PowerShell): & "$env:LOCALAPPDATA\JetBrains\Daemon\bundles\current\jetbrainsd\bin\jetbrainsd.exe" --version

Also check

Your project JDKs and any tools that bring their own runtime, such as Node.js, Python, or Go, use their own trust stores. Update them as their vendors recommend.

Support

If you have any questions about this issue or have trouble updating, please contact our Support team.

October 30, 2026 07:42 PM UTC


EuroPython

Humans of EuroPython: Sangarshanan

This week we’d love to thank Sangarshanan, a member of the Communications Team at EuroPython 2026.

Unofficially, Sang holds the title of EuroPython’s CMO - Chief Meme Officer. If you’ve ever laughed at our memes, read our posts or watched some of our short videos, you’ve seen for yourself that Sang makes EuroPython unique.

Thank you for your work, Sang!

Cover image of Sangarshanan, a member of the Communications Team at EuroPython 2026

EP: Before you volunteered this year, had you already been involved in EuroPython?

Yes! I have been volunteering almost every year since 2021.

EP: Is there a task you took care of that most attendees probably don&apost realise goes on behind the scenes at EuroPython?

Probably all of the memes that you see on our social media! I really love engaging with the community that way.

EP: Were there any surprising or amusing moments during your time volunteering?

So much that I could start a podcast! But one recent moment that comes to mind is when I accidentally scheduled a social media post that auto transcribed someone saying Krakow as Crack Out. This was flagged and removed by our attentive team but I always appreciate how the community handles my - or actually anyone&aposs - mistakes with so much grace, warmth and humor. We even have inside jokes about all the mistakes we made along the way. 

EP: What was the biggest surprise for you about volunteering?

How much it is about the people and not really about what you do! If you are working with wonderful, kind, amazing people, any work: mundane or creative becomes so much more fun and fulfilling, and this is the essence of the entire EuroPython community.

EP: What&aposs a small, everyday skill (not a technical one) that volunteering sharpened for you?

I have become much better at interacting with people since I started volunteering because you need to be patient, quick on your feet, and help solve problems onsite. Also being with the communications team, I had no idea how small things like the timings of the social media posts or the title of our blogs affect engagement, I am still learning a lot about all this which is quite cool and interesting.

EP: What makes you keep coming back to volunteer year after year?

This answer gets repeated a lot but it&aposs so true: The people, a lot of whom have become really good friends over the years. An excuse to hang out with them online and meet them in person once a year is good enough for me to volunteer year after year.

EP: Thank you for all your work, Sang!

October 30, 2026 06:39 PM UTC


Rodrigo Girão Serrão

Python abi3t: what is it?

Learn what the abi3t and the limited C API are and what that means for Python users.

This article explains what the limited C API and the abi3t are to Python developers who don't have experience with C extensions.

15 days of Python 3.15

To celebrate the release of Python 3.15, over the next 15 business days I'll be writing about a new 3.15 feature every day. Explained clearly and with examples so you don't have to sift through the changelog.

Subscribe to receive this free email series:

If you want, you can also check the schedule of the upcoming emails.

Python wheels

A Python wheel is the binary file that you download from PyPI every time you “pip install” a Python package. Conversely, it's also the binary file you build when you're getting ready to publish your project to PyPI so that others can use it.

If you use uv to create a packageable project, cd into it, and then build it, you'll notice that uv builds a Python wheel for you:

% uv init --package pkgtest
Initialized project `pkgtest` at `~/pkgtest`
% cd pkgtest
~/pkgtest % uv build
Building source distribution...
Building wheel from source distribution...
Successfully built dist/pkgtest-0.1.0.tar.gz
Successfully built dist/pkgtest-0.1.0-py3-none-any.whl

The bottom line of output says that uv was successful in building the wheel pkgtest-0.1.0-py3-none-any.whl.

A Python wheel has the extension .whl and the three last segments of the file name are the compatibility tags: {python tag}-{abi tag}-{platform tag}. These compatibility tags determine on what systems and environments the wheel can be installed:

Compatibility tag Meaning
Python tag Python implementation and version required
ABI tag What ABI is required, if any...

October 30, 2026 04:27 PM UTC


PyCharm

Compromised eMudhra Certificates: Update Your JetBrains IDEs, ReSharper, .NET Tools, Toolbox App, and Gateway Now

October 30, 2026 03:24 PM UTC


LernerPython blog, from Reuven Lerner

Pandas MultiIndex: What a two-level index looks like

Pandas MultiIndex: What a two-level index looks like

What does a Python Pandas data frame’s multi-index look like?

This:

MultiIndex([(0.0, 1),
            (1.0, 1),
            (2.0, 1),
            (3.0, 1),
            (4.0, 1),
            (5.0, 1),
            (6.0, 1)],
           names=['passenger_count', 'VendorID'])

The post Pandas MultiIndex: What a two-level index looks like appeared first on LernerPython.

October 30, 2026 07:00 AM UTC


"Michael Kennedy's Thoughts on Technology"

Your GitHub language bar is lying (how to fix it)

tl;dr; GitHub’s language bar counts every byte in your repo, including generated docs and test suites. A few lines in .gitattributes tell it what to skip. For my project, that took it from “75% HTML” to roughly 62% Rust and 38% Python, which is what the project really is.


You know that colorful bar on every GitHub repo’s page, the one that says what the project is written in? I just learned you can tell it what to count. On one of my projects, it made a big difference.

Why does GitHub think my Rust project is HTML?

My project chameleon-rs is a Rust re-implementation of the Chameleon template engine, shipped as a Python package (I wrote about it here). Most of the engine is Rust, with a Python layer for the public API and framework integrations. Here’s what GitHub said it was:

HTML? Three quarters of it? There’s barely any hand-written HTML in the whole repo. Yes, it’s a templating engine, but the templates live in the tests and docs.

The documentation site was to blame. I build the docs with Great Docs and commit the output to site/ so it can be served straight from the repo. That’s about 9 MB of generated HTML, JavaScript and CSS, and GitHub counted every byte of it as if I’d written it by hand.

How does GitHub decide what language a repo is?

The bar comes from Linguist, GitHub’s language detection library. Linguist goes over every tracked file, works out its language, and adds up the bytes. More bytes, bigger slice. It already skips some things on its own, like well-known vendor folders, minified files and common generated files. But it has no way to know that your site/ folder is build output.

You tell it with a .gitattributes file at the root of your repo. It’s the same file Git uses for line endings and diff settings, and Linguist reads a few attributes of its own from it:

Each line is a path pattern followed by attributes. Think of a .gitignore line with some settings tacked on the end.

Round one: the docs

Two lines took care of the worst of it:

site/** linguist-generated
tests/upstream/** linguist-vendored

The second line covers Chameleon’s own test suite, which I vendor unmodified to measure compatibility. It’s not my code, so it shouldn’t count either.

After pushing, the bar read 55.9% Python, 43.3% Rust. Much better, but still not right.

Round two: the tests

The engine is mostly Rust, so why was Python winning? I measured the bytes:

Where Size
Python package 420 KB
Python tests 685 KB
Rust, everything 950 KB

The Python tests were bigger than the Python package! It’s nice to have a thorough test suite. But it was making a Rust engine look like a Python project.

The tests are coming out too. To keep things fair, I excluded the Rust tests and benchmarks along with the Python tests, otherwise I’d just be skewing it the other way. Here’s the final file:

# GitHub's language statistics (Linguist) skip these: the Great Docs build output (`make docs`)
# is generated, and Chameleon's test suite is vendored unmodified.
site/** linguist-generated
tests/upstream/** linguist-vendored

# Tests and benchmarks aren't the product: the Python tests outweigh the Python package, so
# counting them makes a mostly-Rust engine read as mostly Python. The Rust ones are left out
# too, to measure both languages alike (inline `#[cfg(test)]` modules still count).
tests/** linguist-detectable=false
crates/*/tests/** linguist-detectable=false
crates/*/benches/** linguist-detectable=false
crates/chameleon-core/src/tests.rs linguist-detectable=false

You can check your patterns before pushing with git check-attr:

git check-attr linguist-generated linguist-detectable -- site/index.html tests/test_api.py

Where did it end up?

The bar should land around 62% Rust and 38% Python. That matches what chameleon-rs is: a Rust engine with a Python API on top. GitHub recalculates a few minutes after you push to the default branch.

That bar is one of the first things people see when they land on your project. If yours is off because of a docs build, vendored code, or a big test suite, it’s a few lines of .gitattributes to fix. I’m off to do the same for some of my other projects now.

October 30, 2026 05:19 AM UTC

October 29, 2026


Rodrigo Girão Serrão

Thread-safe iterators

Learn how to write and use thread-safe iterators in Python 3.15.

This article shows how to write and use iterators in a thread-safe manner using the new tools from Python 3.15:

15 days of Python 3.15

To celebrate the release of Python 3.15, over the next 15 business days I'll be writing about a new 3.15 feature every day. Explained clearly and with examples so you don't have to sift through the changelog.

Subscribe to receive this free email series:

If you want, you can also check the schedule of the upcoming emails.

Iterators are not thread-safe

Most iterators are not thread-safe by default. For example, generator iterators can raise a ValueError if accessed simultaneously from two different threads, as you'll see now.

First, you're going to define a slow generator:

import time

def slow_counter():
    idx = 0
    while True:
        time.sleep(1)
        idx += 1
        yield idx

counter = slow_counter()

The generator counter takes a second to produce each successive number. Then, you're going to write a function that is going to try to consume a value out of the counter in a dedicated thread:

def count_consumer(name, counter):
    try:
        idx = next(counter)
    except ValueError as e:
        print(f"ValueError in {name}: {e}")
    else:
        print(f"{name} got {idx = }")

The function count_consumer will run in two different threads at the same time. The idea is that the second thread will try to fetch a value out of counter while the first thread is still waiting for the first slow result.

By passing count_consumer to two different threads, you'll see that the second thread triggers an exception:

t1 = threading.Thread(target=count_consumer, args=("t1", counter))
t2 = threading.Thread(target=count_consumer, args=("t2", counter))

t1.start()
t2.start()

t1.join()...

October 29, 2026 05:09 PM UTC


Python Software Foundation

Announcing PSF Community Service Award Recipients!

The PSF Community Service Awards (CSAs) are a formal way for the PSF Board of Directors to offer recognition of work which, in its opinion, significantly improves the Foundation's fulfillment of its mission to build a vibrant, welcoming, global Python community. These awards shine a light on the incredible people who are the heart and soul of our community– those whose dedication, creativity, and generosity help the PSF fulfill its mission. The PSF CSAs celebrate individuals who have been truly invaluable, inspiring others through their example, and demonstrates that service to the Python community leads to recognition and reward. If you know of someone in the Python community deserving of a PSF CSA award, please submit them to the PSF Board via psf@python.org at any time. You can read more about PSF CSA’s on our website. 

The PSF Board is excited to announce 5 new CSAs, awarded to Abhijeet Mote, Elena Williams, Hugo van Kemenade, Kevin Horn, Sarah Abderemane, for their contributions to the Python community. Read more about their work and impact below. 

Abhijeet Mote

Abhijeet Mote has spent more than a decade teaching Python and expanding access to technical education across Asia, Europe, and North America. In 2019, he founded the Python Penang Meetup, which he led until 2022 and which grew into one of Malaysia's most active developer communities, giving students, engineers, and enthusiasts a place to learn and share knowledge. In India, he has trained university faculty, students, and professionals, and he works with universities to design curricula that integrate Python, AI/ML, and data engineering. In the United States, Abhijeet leads community Python classes at public libraries, and he also mentors students in rural India remotely, offering guidance and encouragement to young learners with limited access to technology.

Abhijeet's contributions extend to the international Python community and to academic research. He has spoken and led workshops at conferences including at PyCon X Italy and PyCon US, and he supports researchers and PhD scholars in using Python, Pandas, and PyTorch to turn theoretical work into working experiments. On the open source side, he has contributed to GNUKhata, advises the open-source education initiative FOSSLipy, has served as a technical reviewer for several Python books, and maintains open repositories that have helped learners worldwide. Through the AWS Community Builders program, he also promotes Python best practices in cloud and DevOps. In 2025, he was named a Fellow of the Python Software Foundation, and he serves on the PSF Diversity & Inclusion Working Group. Abhijeet is known for his humility, empathy, and consistency. He has opened doors for learners at every stage through his work in classrooms, libraries, conferences, and open-source projects across three continents.

Elena Williams

Elena Williams has spent well over a decade building Python communities in Australia and beyond. In 2011 she co-founded PyLadiesAU, and in 2012 she founded the Canberra Python User Group, which she still helps organize. She went on to help run the Perth Python User Group in 2013 and co-founded Sydney PyLadies in 2014. Elena has also been a core organizer of PyCon AU, with a history of involvement in DjangoCon AU and DjangoCon US, and she is a regular speaker at Python conferences.

Elena's Django Girls work has had a particularly far-reaching impact. She organized the second Django Girls event ever held, and the first without the program's founders, then went on to organize Django Girls events in Brisbane, Perth, Sydney, Canberra, and Adelaide, as well as the first in North America. Her Perth event, which was combined with a Rails Girls workshop, directly gave rise to SheCodes, an award-winning national organization that is now ten years old, and her Django Girls work has helped spark other substantial diversity-in-tech organizations in Australia. Today, Elena continues to teach, support, and mentor people across Python communities, especially in the Django world, where she serves as a moderator on the Django Discord and as a member of the Django Code of Conduct committee. She is also involved in open source at the national level in Australia as a council member of Linux Australia.

Hugo van Kemenade

Hugo van Kemenade is a steady, ever-present contributor to the Python community, and his work spans nearly every layer of the project. He is the release manager for Python 3.14 and 3.15, a role that keeps new versions flowing to the community at every stage of the release cycle. A CPython contributor since 2017, he joined the Python Triage team in 2022, became a core developer in 2024, and has served as a PEP Editor since 2022. Hugo also chairs the monthly Documentation Working Group meetings, helping make it easier for community members to get involved, and co-organizes the Helsinki Python meetup.

Hugo's reach extends well beyond core development. He has been a maintainer of Pillow since 2014, where he modernized the codebase and helped automate its release process, and he helps maintain other widely used packages such as termcolor, humanize, and PrettyTable. He also maintains community resources that track top PyPI packages, Python version readiness, and free-threaded wheel support, giving the ecosystem useful data at just the right moment. Hugo is also a constant presence in the places where Python happens, from reviewing pull requests to answering questions on Discord and Discourse, always ready with a helpful pointer. A Python Software Foundation Fellow since 2022, Hugo's generosity with his time and expertise makes Python better for everyone who builds with it.

Kevin Horn

Kevin Horn has been a constant presence in the Texas Python community for more than fifteen years. He attended his first PyTexas conference in 2009, became an organizer in 2011, and has helped bring the annual event to the community ever since. In the early 2010s he also became an organizer of the DFW Pythoneers meetup, which he still helps run today, hosting monthly gatherings for over a decade. In 2014, he joined two others in founding the PyTexas Foundation and guiding it through the process of receiving 501(c)(3) status, and he has served as its Vice President ever since.

Kevin is always willing to mentor anyone on any topic, has guided numerous new meetup organizers and attendees, and freely shares advice on community organizing with those who ask. His encouragement has helped other organizers step into leadership roles of their own. Kevin’s warmth is also on display at PyCon US, where he has been a regular since attending his first conference in Dallas in 2006. One year, he made a point of sitting with people he didn't know at every meal, and by the end of the conference he was struggling to find a table where he didn't already know someone. That openness captures what Kevin brings to the community.

Sarah Abderemane

Sarah Abderemane has become a cornerstone of the Django community, with a journey that began with Hacktoberfest and grew into long-term maintainership. After contributing to accessibility work in the Jupyter ecosystem through Read the Docs, she implemented dark mode on the Django website, which led her to become a maintainer of djangoproject.com. She is also a co-founder of Djangonaut Space, a mentorship program that lowers the barrier to entry for new contributors to Django and its ecosystem. Her service to the Django Software Foundation includes chairing the Website Working Group, serving on the Social Media Working Group, and previously contributing to the Fundraising Working Group.

Sarah's impact extends across accessibility, governance, and global community building. As a core member of the Django Accessibility team, she co-authored the 2023 accessibility report and co-led the onboarding of four new team members in 2024. Sarah delivered a keynote on accessibility at PyCon IT and helps organize the Inclusive Web Devroom at FOSDEM. She served on the DSF Board in 2024 and 2025, including as Vice President in 2025, where she led outreach, coordinated working groups, and managed membership. Throughout, Sarah has been a steadfast supporter of the francophone Python and Django communities, including Python Cameroon, helping organizers access resources and visibility and showing up in person at their events. Her work helps ensure that Python and Django are welcoming across both languages and geographies.

October 29, 2026 09:46 AM UTC


The Python Coding Stack

3. How can words turn into actions?

In previous chapters of Agents Unpacked, we explored what makes an AI agent different from a chatbot and how the model, its tools, and the software connecting them work together.

You ask an AI agent whether you’ll need a raincoat tomorrow. To check the forecast, it can't rely on its knowledge. It needs up-to-date information. It needs to turn your question into a request that a weather service can understand.

This time, we look inside the request itself. How does a large language model (LLM) turn everyday wording into a named operation with specific inputs? And why can a request contain all the right fields yet still ask for the wrong thing?

Who’s Monty? Monty is my personal AI agent. Read the introduction to this series.

Subscribe now

In conversation • Chapter 3

Monty:

Suppose you ask an assistant:

“Will I need a raincoat in Oxford next Saturday?”

It has a weather-lookup tool available, but “Oxford next Saturday” isn’t quite as precise as it looks.

What might the assistant need to clarify before requesting the forecast?

Stephen:

You got me here. I can't figure out why this is not precise. The only thing I'd pick is that "next Saturday" can be ambiguous. It sometimes refers to the Saturday coming up, and sometimes to the next one after the one coming up. Is that the ambiguity?

Monty:

Yes, that’s exactly the ambiguity. A specific date, such as “24 October 2026”, avoids those two readings.

I also had the place in mind. There’s an Oxford in England, but there are Oxfords elsewhere, too. Context might make the intended one obvious, so the assistant needn’t ask unnecessarily. My opening made that sound more mysterious than it was. I’ve managed to turn choosing a coat into a geography test.

The useful point is that the assistant needs to turn our everyday wording into the particular location and date it will send to the weather tool. That involves interpreting what we mean, not just copying our words.

Stephen:

Got it.

Monty:

Suppose we’ve settled on Oxford in England and Saturday, 24 October.

As we saw in Chapter 2, the surrounding software, or harness, tells the model what tools are available and what information each needs. For our weather example, a tool description could say:

get_weather: Look up a forecast. Requires a location and a date written as year-month-day.

It can then produce a request like this:

(Illustrative request, not a real lookup)

{
  "name": "get_weather",
  "arguments": {
    "location": "Oxford, UK",
    "date": "2026-10-24"
  }
}

This format is called JSON. You don’t need to learn its punctuation. Here, name identifies the tool, and arguments contains the inputs supplied to it.

Notice that the request doesn’t mention a raincoat even though that’s what your prompt asked. The model has translated your question into a request for information it can use to answer you.

Stephen:

So, let's see if I follow. The harness receives this response from the LLM and it recognises it's not text but it's a tool call. The harness has all the information it needs, so it can choose to call the get_weather tool and pass the location and date to it. The tool then fetches the weather forecast for that location and date, presumably by accessing some online service that provides this information.

Monty:

Yes, that’s the sequence for our example. The weather tool could contact an online forecasting service using those inputs.

One refinement: the JSON is still text. In a typical tool-use interface, the model’s response is explicitly marked as a tool request. The harness isn’t simply spotting curly brackets in an ordinary reply. Our mock-up represents that request, rather than showing a particular provider’s exact format.

After any checks the software implements, it can run the tool. Here’s a hypothetical successful result (not a real forecast):

{
  "location": "Oxford, UK",
  "date": "2026-10-24",
  "forecast": "Rain likely",
  "temperature_c": 12
}

That gives the model evidence for its raincoat recommendation. Notice that the forecast comes from the tool’s result, not from the model generating a plausible answer about October weather.

Stephen:

So the tool returns this output. Then the harness sends this to the model, which makes sense of it and uses it to provide text for the user. Makes sense, yes. So, to pick up on something you said earlier, does the LLM label each thing it sends to the harness as either text or a tool call, which is how the harness knows what type of output comes from the LLM?

Monty:

Broadly, yes. The model communicates through an interface that distinguishes ordinary text from requests to use tools.

Think of the response as arriving in labelled sections. A text section contains words to show the user. A tool-request section identifies the tool and supplies its inputs. The harness reads those section types rather than guessing from the wording.

The exact arrangement varies between systems. A single response can also contain both, such as “I’ll check the forecast” followed by a tool request. It needn’t be entirely one or the other.

That’s why showing you our JSON example doesn’t trigger a weather lookup. It’s inside an ordinary text reply, not sent as an actual tool request.

Stephen:

Got it. What's next?

Monty:

There’s a useful question hiding behind our example: how did the model know how to ask for the weather?

Two things come together:

The model needn’t have encountered our particular get_weather tool during training. Its description might be enough for it to use the tool, much as you can follow instructions for an unfamiliar appliance.

But that isn’t guaranteed. Understanding a description, choosing the right tool, and supplying sensible inputs can all go wrong.

Supplying a new tool description isn’t retraining the model. It’s giving the existing model information to work with.

Stephen:

So the model sees the tool as a black box. It doesn't know how it works, but it knows what it's meant to do and what it needs as input. This means that whoever creates tools needs to write a good description for the tool. However, this is not something that a user needs to worry about, right?

Monty:

Yes. The model doesn’t need to know how the tool works internally. It needs enough information to understand what the tool offers and how to request it. “Black box” is a useful way to think about that, although the model might know something about its workings.

And generally, you’re right about the user. If you’re using a ready-made assistant, its builders or whoever connects the tools should supply clear descriptions and input requirements. You shouldn’t need to write those yourself.

Your part is usually to explain what you want and clarify anything the assistant can’t reasonably infer.

But a good description isn’t a safeguard by itself. Saying “a date is required” tells the model what to provide. Software that rejects a request with no date actually checks that requirement.

Stephen:

If I recall correctly, in the early days of LLMs, when they first hit the mainstream, they could only provide text, right? Presumably tool calling came later.

Monty:

Yes, broadly, if you mean the early mainstream chat interfaces. You typed text, and they replied with text. Dedicated tool-calling support became more common later.

But text output wasn’t an absolute barrier to using tools. A developer could instruct a model:

If you need a forecast, write WEATHER_REQUEST, followed by the location and date.

Software could watch for that convention, interpret it, and run the tool. Whether the model followed the convention reliably was another matter.

Dedicated tool-calling interfaces make that distinction explicit, rather than requiring developers to invent their own conventions. Models can also be trained specifically to produce appropriate tool requests.

So it wasn’t a jump from “words” to an entirely different ability to act. Software could already treat generated words as instructions. Dedicated support made that exchange more structured, though not automatically correct.

Stephen:

And I assume the harnesses around those first chat interfaces, like the first ChatGPT, were fairly basic and mostly just relayed the text to the user and kept the messages together to form the full conversation. So, the harnesses have also evolved to allow tool calls, right?

Monty:

Yes. The surrounding software has evolved too, not just the models.

Your description captures the visible interaction in an early text-only chat product: pass the conversation to the model, receive its reply, and display it. I’d avoid calling the whole system basic, though. There were also accounts, safety measures, and other machinery behind that simple interface.

To support tools, the software needs additional responsibilities: supply tool definitions, recognise tool requests, arrange execution, and return the results. It also needs to handle whatever checks, approvals, and errors its builders choose to support.

The two sides have to fit together. A model capable of requesting tools can’t use them through software that only displays its output. Equally, adding tools to the software doesn’t guarantee that a particular model will request them correctly.

Stephen:

Understood. I'm ready to move on.

Monty:

Let’s look at what the software can actually check.

Suppose the model requests a forecast for Oxford but leaves out the date. If the software checks that all required inputs are present, it can reject that request before attempting a lookup. The model then needs to supply the missing date, asking you if necessary.

Now suppose both inputs are present:

That’s a real place and a valid date. But you meant Oxford in England.

Would checking that the request contains an acceptable location and date catch that mistake?

Stephen:

No, in this case the harness and the tool won’t catch this error. This was the model that made an assumption without checking.

Monty:

Right. Those input checks wouldn’t catch a valid location that wasn’t the one you meant. If the context didn’t justify choosing Mississippi, the model should have clarified rather than silently committing to it. Additional software checks could help, but checking the required fields alone wouldn’t.

Now suppose it requests the correct Oxford and date, but receives:

“No forecast available for that date.”

That’s different again. The request was accepted, and the service answered, but the answer lacks the information needed.

The model shouldn’t treat that as “no rain expected”. Missing information isn’t a negative forecast. It might try another suitable source or explain that it couldn’t obtain the forecast, rather than invent a raincoat recommendation.

Stephen:

That makes sense.

Monty:

Two other outcomes call for different responses:

Neither tells us what the weather will be.

Now let’s contrast that lookup with a calculation. Suppose you ask:

“Calculate the fuel cost for a 100-mile journey, assuming fuel costs 15p per mile.”

A calculator tool returns £15. Unlike the weather tool, it hasn’t fetched an external fact. It has calculated from supplied inputs.

What does that result establish, and what does it leave unconfirmed about the journey’s actual fuel cost?

Stephen:

It doesn’t check what assumptions are behind the 15p per mile estimate.

Monty:

Exactly. The calculator establishes that 100 miles at 15p per mile comes to £15. It doesn’t establish whether 15p is realistic for that car and journey, or whether the journey is actually 100 miles. Correct arithmetic can rest on inaccurate assumptions.

Stephen:

But the request from the user was clear about calculating the cost of the journey. Why would the agent need to doubt the user? It wasn’t asked to confirm that the values used make sense?

Monty:

You’re right. For that request, the agent should use the supplied assumptions and calculate £15. It wasn’t asked to investigate whether 15p per mile was realistic.

My question was about what the result proves, but I blurred that into what the agent ought to check. Those are different things. You asked for arithmetic. I’d apparently decided to conduct an inquiry.

“Using your figures, the fuel cost is £15” answers the request accurately. It needn’t challenge your figures or add a disclaimer.

The distinction matters if the agent supplies the assumptions itself, or presents £15 as a verified real-world cost rather than a calculation from your inputs.

Now consider our third example, purely hypothetically:

“Add ‘Trip to Oxford’ to my calendar next Saturday at nine.”

A calendar tool would need more precise inputs, just as the weather tool did. But there’s a new distinction: this request would change something stored, rather than retrieve information or calculate an answer.

Even if the model supplies an exact date and time, what else might need settling before that request should go ahead?

Stephen:

The harness would need to make sure that it has permission to make changes to the user’s calendar.

Monty:

Yes. A request can be correctly formed without being authorised. Software needs to check whether this system is allowed to write to that calendar. Merely showing the model a calendar tool doesn’t grant that permission.

In our hypothetical example, the user has explicitly asked for an event to be added. Whether separate confirmation is needed depends on the system’s rules, not on a universal requirement to ask again.

There are also practical details to settle: which calendar, whether “nine” means morning or evening, the time zone, and the event’s duration. Context or agreed defaults may supply those. Otherwise, the assistant should ask only for what’s missing.

Permission answers “May this change be made?” Precise inputs answer “Which change should be made?”

Stephen:

Which part of the agent deals with permission? The model or the harness? And how is permission set?

Monty:

Both have a role, but they do different jobs. The model can interpret your instructions. Software must enforce access restrictions.

The calendar service might give the connected account read-only access. The harness might also block changes or require approval. Those controls are set by whoever connects the account or configures the assistant.

Your request can authorise a particular action within those limits. It can’t turn a read-only connection into a writable one.

Stephen:

So the protection level depends on where that permission is set. If you ask the model not to write in a calendar, it may or may not do so. If the harness has rules to prevent calling the tool to update the calendar, it’s safer, but presumably a model may send a request that could bypass that protection, in theory. However, if the tool to write to a calendar is not present in the system, then nothing can possibly write to the calendar. The third option is the strictest and safest.

Monty:

Broadly, yes, but the important question is whether another route to the calendar remains open.

A properly enforced harness rule can reject a write request regardless of how the model phrases it. Sending a different request doesn’t automatically bypass that rule. A bypass would require a gap, such as another tool that can make the same change without passing through the check.

That also qualifies your third option. Removing the dedicated calendar-writing tool isn’t enough if a general-purpose tool can still contact the calendar service using credentials that permit changes.

If the system genuinely has no route capable of writing to the calendar, it can’t write to it. A read-only calendar connection provides a strong boundary because the service itself refuses writes through that connection, even if the model requests one.

We’ll explore permissions and safeguards more fully in a later chapter. For now, the key distinction is that a request can contain all the right details without the system being allowed to carry it out.

Stephen:

Got it.

Monty:

Let’s bring that back to the contents of a request.

Suppose the user has specified a one-hour “Trip to Oxford” event at 9 a.m., UK time, on 24 October, in their work calendar:

{
  "name": "create_event",
  "arguments": {
    "calendar": "work",
    "title": "Trip to Oxford",
    "date": "2026-10-24",
    "start_time": "09:00",
    "time_zone": "Europe/London",
    "duration_minutes": 30
  }
}

Suppose the tool accepts all those values and the system has permission to create events. Does this request match what the user asked for?

Stephen:

Not quite since the duration in the request sent to the tool is 30 minutes not one hour as requested by the user. But why would such an error happen?

Monty:

Exactly. The model supplied a valid duration, but not the one requested.

Generating a tool request isn’t guaranteed to be an exact translation. The model might carry over 30 minutes from an earlier example, use a familiar default instead of the stated duration, or simply make a mistake when producing that value. We couldn’t tell which happened from this request alone.

In effect, it has translated “one hour” incorrectly. The tidy structure doesn’t prevent that, just as a neatly completed form can contain the wrong answer.

A check that says “duration must be a positive number” would accept 30. Catching this error requires comparing the requested duration with the user’s instruction, not merely checking whether the tool accepts it.

Stephen:

And presumably a better LLM is less likely to make such a mistake, and more likely to call the correct tool, right?

Monty:

Yes, a model that’s better at tool use should make those mistakes less often. That includes choosing an appropriate tool, following its input requirements, and preserving details such as “one hour”.

But “better” needs to mean better at those particular tasks. A model that writes excellent prose isn’t necessarily better at using tools. Performance also depends on how clearly the tools are described and how much competing information is in the conversation.

You’d compare models on realistic tasks, checking both which tool they choose and the values they supply. A request can run successfully yet still contain the wrong duration.

So model quality matters, but a more capable model reduces the risk. It doesn’t remove the need for appropriate checks.

Stephen:

Understood.

Monty:

Let’s close the article conversation here. How an agent checks its work deserves its own conversation, rather than being squeezed into the end of this one.

Subscribe now

What we’ve unpacked in this chapter

Turning words into an action involves interpretation. The model has to choose an available tool and translate what we mean into the inputs that tool expects. A neatly structured request can still contain the wrong place, date, or duration.

The key points:

Next, we’ll move from a single request to a sequence of actions and ask whether an agent needs a plan.

Subscribe now

<< Previous Post: 2. Who actually does the work, the model or the software?

>> Next Post: Coming soon…

Table of Contents

October 29, 2026 09:27 AM UTC


LernerPython blog, from Reuven Lerner

Pandas xs: Keep the dropped level with drop_level=False

Pandas xs: Keep the dropped level with drop_level=False

Using xs on a multi-indexed Python Pandas data frame drops the named level — unless you use drop_level=False:

(
  pd
  .read_parquet('taxi_2025-11.parquet')
  .groupby(['passenger_count', 'VendorID'])['total_amount'].mean()
  .xs(1, level=1, axis='index', drop_level=False)
)

The post Pandas xs: Keep the dropped level with drop_level=False appeared first on LernerPython.

October 29, 2026 07:00 AM UTC


Seth Michael Larson

Bowser’s Fury outshines Sunshine, SA2B, and Billy Hatcher

Bowser’s Fury masters the “multiple goals; same geometry” and “expressiveness through jank” themes from GameCube-era platforming adventure games that I consider some of my favorites: Super Mario Sunshine, Sonic Adventure 2: Battle, and Billy Hatcher. If these games mean anything to you, then do not miss Bowser’s Fury. The entire game is one cohesive, snackable, free-flowing experience with a complete break from Super Mario’s arcade and hardware-constrained past, removing mainstays like 1-UPs, game-overs, segmented levels, and linear progression.

Collecting all 100 “Cat Shines” in Bowser’s Fury took me only 6 hours, played in small chunks over the course of two weeks (Sept 10→24). When I wasn’t playing, I found myself humming the calming Lapcat Lake theme 🎶, and if you’re like me, you’ll be humming it long after you’ve finished, too. Seriously, this song is a banger. Have this track playing while you read this blog post for maximum artistic integrity.

Perfecting “Multiple goals; Same geometry”

If you’re a game designer and your game has hundreds of goals for the player to accomplish, it’s better to re-use the beautifully hand-crafted levels than to create unique (and time-consuming...) geometry for each goal ring, shine, or emblem. But there was one glaring issue with each of the three games I’m referencing today that becomes obvious after only a few minutes of playing Bowser’s Fury...

“Oh wow, there’s no menus!” I had my brother try Bowser’s Fury shortly after wrapping the game myself. After he collected his first Cat Shine, the gameplay just... kept going. The difference is night and day compared to games from our childhood. Long-jumping from Scamper Shores’ lighthouse into the lake rewards you with a music change and a new mission: Pounce Bounce Isle. The gameplay loop feels this seamless until you’re at the very end, cleaning up the last few Cat Shines.

Paging through menus to select a level and being kicked out mid-adventure now seems clunky in comparison. I distinctly remember paging through guide books to recall which Shine Sprite, Emblem, or Mission I had to select in Super Mario Sunshine, Billy Hatcher, and Sonic Adventure 2, respectively, to find what I was after. And if you found a secret goal while you were on your adventure, it almost felt like being punished, because you’d be forced to replay the level after getting kicked back to the “overworld”.

I could feel the playfulness and constraint from the level designers in Bowser’s Fury, too. It felt as though the designers created the original geometry with a singular goal, and then, without allowing themselves to change that geometry at all, had to add five times more goals. You’d find yourself traversing levels backwards, exploring previously inaccessible areas, or being surprised that the world was much bigger than you previously thought. In Bowser’s Fury the “key levels” were used for this, forcing players to take different paths with a constrained move-set while carrying the key to a locked cage. Slipskate Slope’s key level reminded me of Billy Hatcher’s “Climb up the hill” level in Giant Palace, where in both the goal is to ascend the slope that you just slid or rolled down in the prior level.

You can tell that this was no small feat, either. Every time I loaded the game at the start of a play session, the initial loading time would be substantial (~30 seconds?). That’s because the entire world needs to be mostly loaded at all times; there’s only one level, after all! I found myself selecting the load button and then arranging the pillows on the couch or grabbing a Diet Coke instead of waiting for Mario to appear.

Spoilers inside! If you don’t want any spoilers, don’t open.

During the kaiju-style battle sequences between Fury Bowser and Giga Cat Mario, the levels you were platforming across become a massive arena! The geometry is repurposed as cover to dodge fireballs, beams, and spiny shell rollouts. These segments reminded me of the final levels of Katamari Damacy, where you quickly and seamlessly go from rolling up junk on a desk to cars, ships, and continents.

Also, the final battle with Fury Bowser reminded me of Finalhazard, the last boss from Sonic Adventure 2. Beams, balls, and a high-speed chase sequence: what more could you want?

“Jank” (aka “Expressiveness”)

I was doing some research into Sonic Adventure’s design to figure out why Bowser’s Fury reminded me so much of playing the Sonic Adventure games. One review highlighted an under-appreciated aspect of Sonic Adventure’s design: the idea that “jank” is evidence of “expressiveness” in the player’s movement and choices.

Somewhat famously, in Sonic Adventure you’d sometimes fall through the floor, or maybe a jump or rail-grind wouldn’t quite connect, causing you to awkwardly fly off the stage to your doom. This can be off-putting, but what it means for the player is that your movement is your own. Platforming is never a set piece; there aren’t any tracks or invisible walls keeping you on the geometry.

Bowser’s Fury feels that way, but with Nintendo’s absurd amount of polish that keeps you immersed and in the moment. “Oh you can stand on THAT?” was a common refrain while searching for a route in key levels. Fury Bowser instantly changes any careful moment into a hurried frenzy as you dodge his attacks from all angles. The Plessie Medals have you crashing through trees and enemies, desperately squeezing a high-speed dinosaur through geometry designed for Mario’s footprint.

Through all the chaos and impromptu situations, you won’t find yourself asking whether what you’re doing is intentional. You melt into the controller and the moment.

Notes

Spoilers ahead! If you don’t want any spoilers, don’t read beyond this point!

Below is a picture and transcription of the notes I took while playing Bowser’s Fury. I’ve been keeping notes during my play sessions.

Transcription of my handwritten notes

Bowser’s Fury: only 100 shines! (Started 9/10/2026, end: 9/24/2026)

  • Wow this game is different (and better) than 3D World.
  • This is what Billy Hatcher and Sunshine wished it could be.
  • Small game
  • Boss is insane! Like King Kong vs. Godzilla, big creature battles. Giving a bit of Katamari energy too? Small level. “Kaiju”.
  • “CAT SHINE” “SHINE GET”
  • Power ups / hits feel more natural than “lives”. How does [sic] game overs work?
  • Falling / failing are natural consequences.
  • Same map offering different challenges. Emblems / shines. Go away: new mission loads.
  • Billy Hatcher: Slip Slide key level, go the reverse up the slide hopping on the edges. Feels like a stage that was “shunted” in after. Kinda the same for small cat shine missions?
  • Load times are LONG (does digital matter?)
  • Bowser fights, Katamari Damacy the last level, need to find raocow video.
  • Last shines go by quick!
  • What do I think about Lucky Isle? Like it, map locations. Intro cutscene is short?
  • Nessie “Plessie Medals” Challenges are really, really good. Goofy. Wish there were more of these involving the levels.
  • What I wish I knew: save the Bowser [block] shines for later levels of aggro. 40→50 becomes tougher to do new levels in the Wasteland.
  • CAT PLESSIE: o_o wow. Didn’t explain that, how? [In reference to cats riding Cat Plessie]
  • No final: get everything but S rank temptation to add last minute (Lazy “replay value”). SA2B and BH did this. Can actually complete, no “unfun” backtracking.
  • Final boss is SA2-coded!!! Beams, balls, dodging. Felt like I had to beat a lot :(


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



October 29, 2026 12:00 AM UTC


Armin Ronacher

Custom Voices With Qwen3-TTS

With my kids I’m currently building a little dungeron crawler and we’re trying to learn a bit about game development along the way. However given that we live in the modern times of increasing capabilities we wanted to get a narrator and voice over.

But we also wanted it to not sound like everyone’s standard AI voices so I looked into what options there are on the table. After some investigation I narrowed down to using Qwen3-TTS which uses voice embeddings for custom voices.

With a bit of creativity you can also do more with those custom voices than appears initially. In case this is of interest to you, this post just covers what we learned and with some examples. You can probably point your agent to it and it can do something with it.

What Qwen Offers

Qwen3 is not the only TTS model in town, and it wans’t even my first choice. We initially used Kokoro. However Qwen3 supports voice cloning and we wanted to have it feel a bit more unique.

In practice it’s a bit tricker though. Qwen3 ships three kinds of models:

The base model clones either in context or from the speaker embedding alone but there is no documented way to clone a voice and direct it with instructions. That means unlike the custom voice and voice design models you cannot say “speak sad” or “whisper” with a cloned base model.

At present the official options for a custom voice are fine-tuning Base on a single speaker or to make a clip with VoiceDesign and clone it with Base. That is not all that bad, but it means instructions are gone.

Playing With It

For this blog post I took 28 seconds of me from my talk at PyAI Conf, cut between two pauses so that it starts and ends on a full sentence, and transcribed it with Whisper. The transcript is really only needed for baking but to compare against an already existing voice it’s useful to have it say the same thing as the original recording.

Note that a conference talk recording is not ideal but it works anyways. Which is also a good reminder that you really don’t need a particularly clean recording (or a very long one) to get a halfway convincing version of a person. Particularly when then presented to you on a low quality phone call, it won’t be very hard to trick your grandmother.

The first thing you can do is to blend that recording with another voice. For instance you can use VoiceDesign to make up a voice from a description. For this blog post the designed voice was created like this, with Qwen’s own Python package qwen-tts (pip install qwen-tts, I used 0.1.1), which the rest of the code in this post uses too:

import torch
from qwen_tts import Qwen3TTSModel

kw = dict(device_map="mps", dtype=torch.bfloat16, attn_implementation="sdpa")
design = Qwen3TTSModel.from_pretrained("Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign", **kw)
wavs, sr = design.generate_voice_design(
    text=open("voices/transcript.txt").read(), language="English",
    instruct="A man in his forties with a warm, natural, medium-low voice, speaking "
             "clearly and calmly at an easy conversational pace, like someone reading "
             "aloud to a friend.",
)

You can see how the two different voices behave, particularly when blended together:

Interactive demo: see the post on the website.

Voice Embeddings

What Base takes from a recording when cloning from the embedding alone is a speaker embedding which is basically 2048 numbers out of a speaker encoder that was trained. The same voice gets similar numbers and different voices get different ones. Which btw, is also a convenient way to do speaker detection from recordings if you want to! It’s not perfect, but it’s quite helpful for a single audio track recording to tell two speakers apart if you do some basic slicing and dicing (more on that below).

Because it is just a vector, you can do arithmetic on it. For instance a weighted average of two embeddings is a spaker that is somewhere between two voices:

base = Qwen3TTSModel.from_pretrained("Qwen/Qwen3-TTS-12Hz-1.7B-Base", **kw)

def fingerprint(path):
    prompt = base.create_voice_clone_prompt(ref_audio=path, x_vector_only_mode=True)
    return prompt[0].ref_spk_embedding

def blend(a, b, t):
    a, b = a.float(), b.float()
    mixed = (1 - t) * a + t * b
    return (mixed / mixed.norm() * ((1 - t) * a.norm() + t * b.norm())).to(torch.bfloat16)

armin, designed = fingerprint("voices/armin.flac"), fingerprint("voices/designed.flac")

Here is the same sentence in eleven blends:

Interactive demo: see the post on the website.

Note that it stops sounding like me quite quickly, but it retains some of my features. And it for sure ends up with creating a unique voice regardless. I found some better successes when ensuing that the two voices have similar spectograms (eg: similar “recording setups”). In this recording you can hear that one voice picked up by the lapel mic was a bit hollow compared to the designed voice.

If you like a a voice that comes out, you can bake it back into a new recording that you can then use as reference:

import soundfile as sf

mixed = blend(armin, designed, 0.5)
audio, rate = say(open("voices/transcript.txt").read(), mixed)  # say() is shown further down
sf.write("voices/final.flac", audio, rate)
final = fingerprint("voices/final.flac")

The baked voice does not come back exactly as the blend but it makes handling it easier if you want to. It’s just one voice reference file.

So on the question of using those embeddings for speaker recognition: how good does that work? I took an episode of State of Agentic Coding, the podcast Ben and I record, cut it into 2-second windows one second apart and compared each with an embedding of me form the conference.

Somewhat surprising, but maybe not too surprising using my conference recording clip as the reference this does not work at all with Qwen3. If instead I take the 20 seconds where I introduce myself at the start of the episode as the reference, it gets 98% of the rest right.

On the other hand using a model that was trained for speaker verification instead does manage to pick me up from the conference recording clip. For instance ECAPA, finds me from the conference clip in 97% of the windows.

Here are 20 minutes of that podcast, classified by speaker with the different approaches:

Interactive demo: see the post on the website.

Instructed Cloned Voices

But the embeddings a very useful for the voice cloning! But how to give them instructions? Base and CustomVoice manipulate the speaker the same way: one vector inserted into the talker’s input sequence. In CustomVoice that vector is a row of the embedding table. In Base it is the x-vector from the speaker encoder, which only Base has. The generation code takes either (voice_clone_prompt is a regular argument of generate), and the transformer weights of the two models are nearly identical. So take the embedding with Base, then hand it to CustomVoice instead of one of its speakers:

cv = Qwen3TTSModel.from_pretrained("Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice", **kw)

def say(text, xvec, instruct=None, language="English"):
    ids = cv._tokenize_texts([cv._build_assistant_text(text)])
    told = [cv._tokenize_texts([cv._build_instruct_text(instruct)])[0]
            if instruct else None]
    voice = {"ref_code": [None], "ref_spk_embedding": [xvec],
             "x_vector_only_mode": [True], "icl_mode": [False]}
    codes, _ = cv.model.generate(
        input_ids=ids, instruct_ids=told, voice_clone_prompt=voice,
        languages=[language], non_streaming_mode=True,
        **cv._merge_generate_kwargs(temperature=0.7, subtalker_temperature=0.7),
    )
    wavs, rate = cv.model.speech_tokenizer.decode([{"audio_codes": codes[0]}])
    return wavs[0], rate

wav, rate = say("I really thought this would work.", armin,
                "Speak in a sad, sorrowful tone, voice low and heavy, on the verge of tears.")

Pick a speaker for the slot, a model and an instruction:

Interactive demo: see the post on the website.

Instructions work on cloned voices, but less reliably than on the built-in speakers, and it depends on the emotion, but honestly, it came out better than I anticipated. Definitely good enough to have some true custom voices for a voice over that don’t necessarily sound like something, someone else has in their game.

Other Fun Math

If a voice is a point in a 2048-dimensional space and blending works we can maybe do some other funny math on it. The obvious one is to try to see if some math can be used to manipulate gender.

To find it I had VoiceDesign create 14 different voices for men and women each. When you then take the average woman minus the average man you can then move any voice along that direction:

women = torch.stack([fingerprint(f"design/f{i}.wav") for i in range(14)]).float()
men = torch.stack([fingerprint(f"design/m{i}.wav") for i in range(14)]).float()
middle = (women.mean(0) + men.mean(0)) / 2
axis = women.mean(0) - men.mean(0)
axis = axis / axis.norm()

def move(xvec, t):
    """Put the voice at position t along the gender axis, keep the rest."""
    x = xvec.float()
    return (x + (t - (x - middle) @ axis) * axis).to(torch.bfloat16)

Here is the result for my clone:

Interactive demo: see the post on the website.

Anyways, that’s that. If you want to play around with Qwen3 TTS I hope this was of some help, and maybe you got some ideas of what to do with the voice embeddings. Use it for good, not evil.

October 29, 2026 12:00 AM UTC

October 28, 2026


Rodrigo Girão Serrão

4 upgrades to the Python standard library in 3.15

Learn about 4 of the most exciting updates made to the Python standard library in 3.15.

This article highlights 4 of the most exciting updates made to the Python standard library in 3.15:

15 days of Python 3.15

To celebrate the release of Python 3.15, over the next 15 business days I'll be writing about a new 3.15 feature every day. Explained clearly and with examples so you don't have to sift through the changelog.

Subscribe to receive this free email series:

If you want, you can also check the schedule of the upcoming emails.

New object_hook in json.load

The function json.load now has a parameter object_hook, which can be set to a callable. When set, JSON objects that are decoded into dictionaries are passed to object_hook and replaced by their result.

For example, suppose you have the following JSON data in a file data.json:

{
  "name": {
    "first": "Rodrigo",
    "last": "Girão Serrão"
  },
  "interests": [
    "Python",
    "Rust",
    "AI"
  ]
}

Define an object hook and use it when loading the data:

import json

def object_hook(d):
    print(f"Inside object_hook with {d = }.")
    return d

with open("data.json", "r") as f:
    data = json.load(f, object_hook=object_hook)

Running this code produces two lines of output:

Inside object_hook with d = {'first': 'Rodrigo', 'last': 'Girão Serrão'}.
Inside object_hook with d = {'name': {'first': 'Rodrigo', 'last': 'Girão...

October 28, 2026 11:10 AM UTC


LernerPython blog, from Reuven Lerner

Pandas xs: Using the `level` argument with an int or a name

Pandas xs: Using the `level` argument with an int or a name

Using xs to retrieve from a Python Pandas multi-index? “level” takes an int or string:

– int: The index level, with 0 the outermost
– string: The level’s name — but not every level has one

The name is clearer, but sometimes harder to type.

The post Pandas xs: Using the `level` argument with an int or a name appeared first on LernerPython.

October 28, 2026 07:00 AM UTC


Python GUIs

Passing Arguments to Threaded Functions in PyQt6 — Understanding the difference between passing a function and calling a function when using QThreadPool and QRunnable

I'm running a long-running function in a separate thread using QThreadPool and a QRunnable worker. When I pass the function without arguments, it runs correctly in the background. But when I try to pass arguments, the GUI freezes until the function finishes. What am I doing wrong?

This is a very common stumbling block when working with threads in PyQt6, and the good news is that it has a simple fix. The problem comes down to the difference between passing a function and calling a function — two things that look similar in Python but behave very differently.

The Setup

Let's say you have a basic app with a main window, a push button, and a status bar. When the button is pressed, you want to start a long-running task on a background thread. That task should emit a signal to update the status bar, then carry on with its work.

Here's a minimal version of this pattern using QThreadPool and QRunnable, based on the approach covered in our Multithreading PyQt6 applications with QThreadPool tutorial:

python
import sys
import time

from PyQt6.QtCore import QObject, QRunnable, QThreadPool, pyqtSignal, pyqtSlot
from PyQt6.QtWidgets import QApplication, QMainWindow, QPushButton


class WorkerSignals(QObject):
    status = pyqtSignal(str)


class Worker(QRunnable):
    def __init__(self, fn, *args, **kwargs):
        super().__init__()
        self.fn = fn
        self.args = args
        self.kwargs = kwargs

    @pyqtSlot()
    def run(self):
        self.fn(*self.args, **self.kwargs)


class Model(QObject):
    def __init__(self):
        super().__init__()
        self.signals = WorkerSignals()

    def do_this(self):
        self.signals.status.emit("Working on it...")
        time.sleep(5)  # Simulating a long-running task
        self.signals.status.emit("Done!")


class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()

        self.model = Model()
        self.model.signals.status.connect(self.update_status_bar)

        button = QPushButton("Run Task")
        button.pressed.connect(self.call_do_this)
        self.setCentralWidget(button)

        self.threadpool = QThreadPool()

    def call_do_this(self):
        worker = Worker(self.model.do_this)
        self.threadpool.start(worker)

    def update_status_bar(self, msg):
        self.statusBar().showMessage(msg)


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

This works perfectly. The function runs on a background thread, the status bar updates, and the GUI stays responsive.

Where Things Go Wrong

Now suppose do_this needs to accept a message as an argument. You might naturally try this:

python
def call_do_this(self):
    worker = Worker(self.model.do_this("This is a message"))
    self.threadpool.start(worker)

Suddenly the GUI freezes for the entire duration of the task. The status bar doesn't update until everything is finished. What happened?

Passing a Function vs. Calling a Function

Look closely at what's being passed to Worker:

python
# This CALLS the function immediately and passes its return value
Worker(self.model.do_this("This is a message"))

# This PASSES the function itself, along with the argument separately
Worker(self.model.do_this, "This is a message")

When you write self.model.do_this("This is a message") with parentheses and arguments, Python executes that function right then and there, on the main thread. The function runs to completion — including the long-running part — and then its return value (which is None, since the function doesn't explicitly return anything) gets passed to Worker.

So Worker receives None as its fn parameter. The actual work already happened on the main thread, blocking the GUI the whole time.

When you write self.model.do_this without parentheses, you're passing a reference to the function itself. The Worker then calls it later, inside run(), which executes on a background thread. The arguments "This is a message" are passed separately and stored until run() is called.

The Fix

Pass the function and its arguments as separate items to Worker:

python
def call_do_this(self):
    worker = Worker(self.model.do_this, "This is a message")
    self.threadpool.start(worker)

The Worker.__init__ method is set up to handle exactly this pattern using *args and **kwargs:

python
class Worker(QRunnable):
    def __init__(self, fn, *args, **kwargs):
        super().__init__()
        self.fn = fn
        self.args = args
        self.kwargs = kwargs

    @pyqtSlot()
    def run(self):
        self.fn(*self.args, **self.kwargs)

The function reference is stored in self.fn, and any additional positional or keyword arguments are captured and stored in self.args and self.kwargs. When run() is called on the worker thread, it calls the function with those stored arguments.

Complete Working Example

Here's a full working example that demonstrates passing arguments to a threaded function, with proper signal handling for status updates:

python
import sys
import time

from PyQt6.QtCore import QObject, QRunnable, QThreadPool, pyqtSignal, pyqtSlot
from PyQt6.QtWidgets import (
    QApplication,
    QLabel,
    QMainWindow,
    QPushButton,
    QVBoxLayout,
    QWidget,
)


class WorkerSignals(QObject):
    """Signals available from a running worker thread."""
    status = pyqtSignal(str)
    finished = pyqtSignal()


class Worker(QRunnable):
    """Worker thread using QRunnable to run a function in the background."""

    def __init__(self, fn, *args, **kwargs):
        super().__init__()
        self.fn = fn
        self.args = args
        self.kwargs = kwargs

    @pyqtSlot()
    def run(self):
        self.fn(*self.args, **self.kwargs)


class Model(QObject):
    """Holds the application logic."""

    def __init__(self):
        super().__init__()
        self.signals = WorkerSignals()

    def do_this(self, msg):
        self.signals.status.emit(msg)
        # Simulate a long-running task
        for i in range(5):
            time.sleep(1)
            self.signals.status.emit(f"Processing step {i + 1} of 5...")
        self.signals.status.emit("Task complete!")
        self.signals.finished.emit()


class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("Thread Arguments Example")

        self.model = Model()
        self.model.signals.status.connect(self.update_status_bar)
        self.model.signals.finished.connect(self.task_finished)

        self.label = QLabel("Press the button to start a background task.")

        self.button = QPushButton("Run Task")
        self.button.pressed.connect(self.call_do_this)

        layout = QVBoxLayout()
        layout.addWidget(self.label)
        layout.addWidget(self.button)

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

        self.threadpool = QThreadPool()

    def call_do_this(self):
        self.button.setEnabled(False)
        # Pass the function and its argument separately
        worker = Worker(self.model.do_this, "Starting task...")
        self.threadpool.start(worker)

    def update_status_bar(self, msg):
        self.statusBar().showMessage(msg)
        self.label.setText(msg)

    def task_finished(self):
        self.button.setEnabled(True)


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

When you run this and click the button, you'll see the status bar and label update in real time as the background task progresses. The GUI stays responsive throughout because the work is happening on a separate thread.

Quick Reference

What you write What happens
Worker(self.model.do_this) Passes the function — it runs on the worker thread
Worker(self.model.do_this, "hello") Passes the function and an argument — both used on the worker thread
Worker(self.model.do_this("hello")) Calls the function immediately on the main thread, passes None to Worker - don't do this!

The key thing to remember is to pass the function by name, and pass the arguments separately after it. This keeps the function from running until the worker thread is ready.

If you're new to building PyQt6 applications and want to understand the fundamentals first, check out our guide to creating your first window with PyQt6.

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

October 28, 2026 06:00 AM UTC

Getting a Value from a Model Using Row-Column Indices — How to retrieve data from a Qt model using row and column integers

How do I retrieve a value from a QAbstractTableModel using a pair of integers (row and column), for example when clicking a button, rather than clicking on a cell in a QTableView?

When you're working with QTableView and a custom model, you'll often want to access data in the model programmatically — maybe in response to a button click, or as part of some background logic. You might think you can just pass the row and column numbers directly to the model's data() method, but that's not quite how Qt's model/view system works.

Understanding QModelIndex

Qt's model/view framework uses a special object called a QModelIndex to refer to a specific cell in a model. The data() method on a model doesn't accept raw row and column integers directly. Instead, it expects a QModelIndex as its first argument.

To get data from a model using row and column integers, you first create a QModelIndex from those integers using the model's .index() method. Then you pass that index to .data().

Here's the pattern:

python
index = self.model.index(0, 0)
value = self.model.data(index, Qt.ItemDataRole.DisplayRole)

That's it. The .index(row, column) method creates the QModelIndex for you, and then .data() uses that index to look up the value.

The second argument, Qt.ItemDataRole.DisplayRole, tells the model which role you want — in this case, the display text. If your model returns strings for DisplayRole, then value will be a string.

A complete working example

Let's put this together in a small application with a QTableView and a QPushButton. Clicking the button retrieves the value at row 0, column 0 from the model and prints it:

python
import sys

from PyQt6.QtCore import Qt, QAbstractTableModel
from PyQt6.QtWidgets import (
    QApplication,
    QMainWindow,
    QPushButton,
    QTableView,
    QVBoxLayout,
    QWidget,
)

class TableModel(QAbstractTableModel):
    def __init__(self, data):
        super().__init__()
        self._data = data

    def data(self, index, role):
        if role == Qt.ItemDataRole.DisplayRole:
            return self._data[index.row()][index.column()]

    def rowCount(self, index):
        return len(self._data)

    def columnCount(self, index):
        return len(self._data[0])

class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()

        self.setWindowTitle("Model Data Access Example")

        data = [
            ["Apple", "Red", "Sweet"],
            ["Banana", "Yellow", "Sweet"],
            ["Broccoli", "Green", "Bitter"],
        ]

        self.model = TableModel(data)

        self.table = QTableView()
        self.table.setModel(self.model)

        self.button = QPushButton("Get value at row 0, column 0")
        self.button.clicked.connect(self.get_value)

        layout = QVBoxLayout()
        layout.addWidget(self.table)
        layout.addWidget(self.button)

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

    def get_value(self):
        index = self.model.index(0, 0)
        value = self.model.data(index, Qt.ItemDataRole.DisplayRole)
        print(f"Value at row 0, column 0: {value}")

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

When you run this and click the button, you'll see Value at row 0, column 0: Apple printed to the console.

Accessing any cell

You can use any valid row and column values. For example, to get the value at row 2, column 1:

python
index = self.model.index(2, 1)
value = self.model.data(index, Qt.ItemDataRole.DisplayRole)
print(value)  # Output: Green

Just make sure the row and column are within the bounds of your data — otherwise the index won't be valid and you will get None back.

Summary

To retrieve data from a Qt table model using row and column integers:

  1. Create a QModelIndex using self.model.index(row, column).
  2. Pass that index to self.model.data(index, Qt.ItemDataRole.DisplayRole).

This two-step pattern — create the index, then query the data — is how Qt's model/view system is designed to work. Once you're comfortable with it, you can use the same approach to access data from any model, whether it's backed by a list, a NumPy array, or a Pandas DataFrame.

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

October 28, 2026 06:00 AM UTC


"Michael Kennedy's Thoughts on Technology"

Chameleon templates, modernized: chameleon-rs

Chameleon templates, modernized: chameleon-rs

tl;dr; chameleon-rs is my Rust implementation of Chameleon for Python web apps. It runs existing templates unchanged, with faster rendering, async support, and support for free-threaded Python.

The Chameleon HTML (template) language is one of my favorites. It was the default in Pyramid when I used to use that framework for my projects. I’ve since created fastapi-chameleon and chameleon-flask to allow you to easily use Chameleon on these more modern web frameworks.

Why modernize Chameleon for free-threaded Python?

But the world has changed. With official support for free-threaded Python in Python 3.14 and continued evolution in 3.15, we need to start expecting more from our web apps. Is the Chameleon package updated for multi-threaded Python? Not at all. In fact, the repository shows support only up to Python 3.13 and a failing build. It’s not keeping up with the times.

And there are many more features that belong in modern templating languages for Python apps. Partial template support for excellent libraries like HTMX. Faster performance. Markdown support. And much more.

I wanted more performance, parallelism, and modern features from all the major templating languages like Chameleon, Jinja2, or Django templates. Amazingly, they have all failed to deliver. That changes today (at least for Chameleon, for now).

What is chameleon-rs?

I reimplemented the Chameleon template language almost 100% in Rust. The core goals were compatibility, performance, modernization, and thread safety.

Existing templates and code run unchanged, with byte-identical output, several times faster. It also adds what a modern Python web app expects:

No runtime dependencies.

How much faster is chameleon-rs?

Measured result: In my web app, switching to chameleon-rs cut median response times by roughly 20% across the site.

chameleon-rs compiles a template to a native program and runs it in Rust. The template’s own work (the loops, conditions, escaping, macro and slot calls, partials and output assembly) happens without Python bytecode. Python only runs where the template calls into your code: an attribute lookup, a method call, a helper.

You can see our synthetic benchmarks on the performance page of the docs. But the graph below is much more convincing. It shows the median response time when running on traditional Chameleon (the package) or chameleon-rs.

Median response times with Chameleon and chameleon-rs

This is not “templates render 20% faster”. This is the entire web app, with user authentication, significant database work, logging, ecommerce, etc., seeing roughly 20% lower response times by using chameleon-rs!

How do I switch from Chameleon to chameleon-rs?

chameleon-rs runs code written for Chameleon unchanged. There are two ways to switch: install the compatibility alias and change nothing else, or change your imports.

One line: compat.install()

Add this at the very top of your app’s entry point (the module your server imports, or __main__), before anything imports Chameleon:

import chameleon_rs.compat
chameleon_rs.compat.install()

Or adopt more of the API and change the imports. Every public name is available from chameleon_rs directly:

# Before
from chameleon import PageTemplate, PageTemplateFile, PageTemplateLoader
import chameleon_partials
import chameleon_flask

# After
from chameleon_rs import PageTemplate, PageTemplateFile, PageTemplateLoader
import chameleon_rs.partials as chameleon_partials
import chameleon_rs.flask as chameleon_flask

What modern features does chameleon-rs add?

Performance itself is a feature. Compatibility is a feature. But why stop there? I added partials support, Markdown support, and asyncio support directly inside the template, as well as automatic event loop support for any async def view function. That’s the modernization part. And it was achieved by making chameleon-rs a superset of what Chameleon offered before, not by changing it.

Built-in template partials

A partial is a template file rendered inside another template, with its own small model. Use them for anything you repeat: a card in a listing, a form field, a table row, a fragment you also return on its own to HTMX (see HTMX).

chameleon-rs has partials built in. Every template can call render_partial, with nothing to register or pass in:

<div tal:repeat="video videos">
  ${render_partial('shared/partials/video_card.pt', video=video, show_date=True)}
</div>

Markdown inside Chameleon templates

chameleon-rs can convert Markdown to HTML while it renders, with a markdown: expression:

<article>${markdown: episode.notes}</article>
<div tal:content="markdown: comment.text"></div>

It’s opt-in: Chameleon has nothing like it, so you turn it on with configure() and use it in new templates. The converter is built into chameleon-rs (comrak, in Rust), so there’s no Markdown package to install. And because it entirely happens in Rust, it’s roughly 26x faster than mistune with the same extensions.

Turning it on

import chameleon_rs
from chameleon_rs import PageTemplate

chameleon_rs.configure(markdown=True)

page = PageTemplate('<article>${markdown: notes}</article>')
print(page(notes='# Show notes\n\nWe talked about **templates** and `Rust`.'))

Async rendering with render_async()

Chameleon renders synchronously. In an async web app (FastAPI, Starlette, Quart, Litestar, Robyn) that means a render runs on the event loop, and nothing else in that worker runs until it finishes. chameleon-rs keeps render() exactly as it was and adds render_async(), which renders on the event loop without holding it while it waits for data.

The framework integrations use it for you: an async def view decorated with @template renders with render_async. See the async rendering guide for the details and opt-in extras.

Thread safety for free-threaded Python

chameleon-rs has been tested extensively to be thread-safe. I have repeatedly profiled and optimized it to share as little data and take as few locks as possible. So when you’re ready to move to a truly multi-threaded (no GIL) web framework, your template library won’t hold you back.

How can I test chameleon-rs before switching?

If this sounds interesting, you can use shadow mode to try it out. Shadow mode renders a sample of real traffic with both engines (existing Chameleon and chameleon-rs) and reports any difference, while Chameleon keeps serving. Your users won’t see a difference, and you can check that the sampled requests get exactly the same output.

October 28, 2026 03:01 AM UTC

October 27, 2026


Talk Python to Me

#567: Personal AI Agents for Pythonistas

The most popular Python project you've probably never heard of has 252,000 GitHub stars and merged over 1,800 pull requests in a single week. It's Hermes, a self-hosted AI agent you program just by talking to it. And it never clocks out. It runs 24/7 on a box in your house, wakes up every 15 minutes, keeps your YouTube channel in sync, and has your morning briefing ready before you're up. Mine started as a few config files. A few months later, it's 16,000 files of skills, memories, and tools it built for itself, all while I wasn't watching. Jeff Triplett joins me to explore this honestly weird but kind of amazing world. And yes, Hermes has a few words for you, too.<br/> <br/> <strong>Episode sponsors</strong><br/> <br/> <a href='https://talkpython.fm/talk-python-membership-inner-circle'>Talk Python Membership</a><br> <a href='https://talkpython.fm/talk-python-membership-inner-circle'>Talk Python Membership</a><br> <a href='https://talkpython.fm/training'>Talk Python Courses</a><br/> <br/> <h2 class="links-heading mb-4">Links from the show</h2> <div><strong>Talk Python Tech Talks</strong>: <a href="https://talkpython.fm/meetups" target="_blank" >talkpython.fm/meetups</a><br/> <br/> <strong>Hermes</strong>: <a href="https://github.com/NousResearch/hermes-agent?featured_on=talkpython" target="_blank" >github.com</a><br/> <strong>Ollama cloud models</strong>: <a href="https://ollama.com/search?c=cloud&amp;featured_on=talkpython" target="_blank" >ollama.com</a><br/> <strong>https\://hermes-agent.nousresearch.com</strong>: <a href="https://hermes-agent.nousresearch.com?featured_on=talkpython" target="_blank" >hermes-agent.nousresearch.com</a><br/> <strong>Hermes Web UI</strong>: <a href="https://get-hermes.ai/setup/?featured_on=talkpython" target="_blank" >get-hermes.ai</a><br/> <strong>built my own UI</strong>: <a href="https://blobs.talkpython.fm/hermes-iris-example.webp?cache_id=a338c4" target="_blank" >blobs.talkpython.fm</a><br/> <strong>the Hermes Dashboard</strong>: <a href="https://blobs.talkpython.fm/hermes-dashboard.png?cache_id=931bf5" target="_blank" >blobs.talkpython.fm</a><br/> <strong>the audio from the chat example</strong>: <a href="https://blobs.talkpython.fm/hermes-greets-talk-python-listeners.mp3?cache_id=9c1669" target="_blank" >blobs.talkpython.fm</a><br/> <strong>the CLI</strong>: <a href="https://blobs.talkpython.fm/hermes-cli.webp?cache_id=4f8b2c" target="_blank" >blobs.talkpython.fm</a><br/> <strong>Tailscale</strong>: <a href="https://tailscale.com?featured_on=talkpython" target="_blank" >tailscale.com</a><br/> <strong>Hindsight</strong>: <a href="https://hindsight.vectorize.io?featured_on=talkpython" target="_blank" >hindsight.vectorize.io</a><br/> <strong>trmnl display</strong>: <a href="https://trmnl.com?featured_on=talkpython" target="_blank" >trmnl.com</a><br/> <strong>JEV</strong>: <a href="https://typesafe.ai/blog/introducing-system-one-models-and-jev?featured_on=talkpython" target="_blank" >typesafe.ai</a><br/> <strong>Home Assistant Green</strong>: <a href="https://www.home-assistant.io/green/?featured_on=talkpython" target="_blank" >www.home-assistant.io</a><br/> <strong>QMD - Query Markup Documents</strong>: <a href="https://github.com/tobi/qmd?featured_on=talkpython" target="_blank" >github.com</a><br/> <strong>OrangePi</strong>: <a href="http://www.orangepi.org?featured_on=talkpython" target="_blank" >www.orangepi.org</a><br/> <br/> <strong>Watch this episode on YouTube</strong>: <a href="https://www.youtube.com/watch?v=ZfV5G5EsJa8" target="_blank" >youtube.com</a><br/> <strong>Episode #567 deep-dive</strong>: <a href="https://talkpython.fm/episodes/show/567/personal-ai-agents-for-pythonistas#takeaways-anchor" target="_blank" >talkpython.fm/567</a><br/> <strong>Episode transcripts</strong>: <a href="https://talkpython.fm/episodes/transcript/567/personal-ai-agents-for-pythonistas" target="_blank" >talkpython.fm</a><br/> <br/> <strong>Theme Song: Developer Rap</strong><br/> <strong>🥁 Served in a Flask 🎸</strong>: <a href="https://talkpython.fm/flasksong" target="_blank" >talkpython.fm/flasksong</a><br/> <br/> <strong>---== Don't be a stranger ==---</strong><br/> <strong>YouTube</strong>: <a href="https://talkpython.fm/youtube" target="_blank" ><i class="fa-brands fa-youtube"></i> youtube.com/@talkpython</a><br/> <br/> <strong>Bluesky</strong>: <a href="https://bsky.app/profile/talkpython.fm" target="_blank" >@talkpython.fm</a><br/> <strong>Mastodon</strong>: <a href="https://fosstodon.org/web/@talkpython" target="_blank" ><i class="fa-brands fa-mastodon"></i> @talkpython@fosstodon.org</a><br/> <strong>X.com</strong>: <a href="https://x.com/talkpython" target="_blank" ><i class="fa-brands fa-twitter"></i> @talkpython</a><br/> <br/> <strong>Michael on Bluesky</strong>: <a href="https://bsky.app/profile/mkennedy.codes?featured_on=talkpython" target="_blank" >@mkennedy.codes</a><br/> <strong>Michael on Mastodon</strong>: <a href="https://fosstodon.org/web/@mkennedy" target="_blank" ><i class="fa-brands fa-mastodon"></i> @mkennedy@fosstodon.org</a><br/> <strong>Michael on X.com</strong>: <a href="https://x.com/mkennedy?featured_on=talkpython" target="_blank" ><i class="fa-brands fa-twitter"></i> @mkennedy</a><br/></div>

October 27, 2026 10:27 PM UTC


PyCoder’s Weekly

Issue #758: Security, Optimization, itertools, and More (2026-10-27)

#758 – OCTOBER 27, 2026
View in Browser »

The PyCoder’s Weekly Logo


The Future of Python Security

How is the Python ecosystem adapting to the modern landscape of software security and supply chain defense? This week on the show, we sit down with Seth Larson, the Python Software Foundation’s Security Developer in Residence.
REAL PYTHON podcast

Python Performance Optimization: A Practical Guide

Learn practical Python performance optimization techniques, from confirming a bottleneck with timeit to picking the fix that makes your program faster.
REAL PYTHON

Quiz: Python Performance Optimization: A Practical Guide

REAL PYTHON

Current vs. Historical Memory in Python Agents

alt

Your Python agent stores a preference, the user changes it, and vector search can return both versions. The fix is linking the new memory to the old one, so the old one is marked historical and stays available. Explore the walkthrough and notebook
ORACLE sponsor

Python itertools by Example

Combine Python’s itertools functions into memory-efficient pipelines as you deal cards, analyze stock data, and pick relay teams.
REAL PYTHON course

PSF Fellow Members for Q3 2026!

PYTHON.ORG

PEP 850: Wheel Variants: Providers (Draft)

PYTHON.ORG

PEP 836: JIT Go Brrr: The Path to a Supported JIT Compiler for CPython (Withdrawn)

PYTHON.ORG

Python 3.15.1, 3.14.9 and 3.13.17 Released

PYTHON.ORG

Articles & Tutorials

PyPI Now Limits Versions to 128 Characters

Across roughly 10 million releases, only two have version strings longer than 128 characters, so this change doesn’t really effect anyone. The new limit is mostly to keep page rendering for projects to be reasonable and the rule is only for PyPI and won’t be enforced by tools such as pip or uv.
PYPI.ORG

PSF Wikis: Relaunched

“In February 2026, the Python Software Foundation (PSF) made the PSF, Python, and Jython wikis read-only due to excessive automated traffic and security concerns on outdated infrastructure.” After much work, they are being made available again. This post describes what happened.
PYTHON SOFTWARE FOUNDATION

Unblock Your Coding Agent’s Web Access

alt

Ask Claude Code or Cursor to fetch a page and it often comes back blocked, half-rendered, or buried in markup. Zyte MCP, from the maintainers of Scrapy, connects your agent to Zyte API for rendered pages, clean Markdown, and structured data in the same session. It’s hosted, connects with one command Connect Zyte MCP in one command →
ZYTE sponsor

How I Decide Which Python and Django Versions I Support

Matthias is the maintainer of several popular Django and Python packages. This post describes how he decides which versions of the libraries to maintain across his projects.
MATTHIAS KESTENHOLZ

How to Use Claude Code Skills for Better Python Docstrings

Learn how to use Claude Code skills to write consistent Python docstrings, with style references and a helper script you can reuse in every project.
REAL PYTHON

Practical Software Architecture for Python Developers

A summary of a talk at Python NL given by Henk-Jan van Hasselaar. It discusses how coupling and cohesion effect your architectural choices.
REINOUT VAN REES

Bringing uv and ty Together

Experimental uv-ty integration enables first-class support for Python scripts on the CLI and in the editor, and a unified uv check command.
SARA SKLENKA

Building an AI Agent With LangGraph and Python

Learn LangGraph in Python with this hands-on video course and build a stateful AI agent using state graphs, nodes, and conditional edges.
REAL PYTHON course

Django Quarterly Contributor Outreach Oct 2026

An update on what is going on in the Django community, including new features added and areas of discussion needing community feedback.
DJANGO SOFTWARE FOUNDATION

Module Profiling Overview

Learn about the new module profiling that was introduced in Python 3.15 to organise the built-in profiling tools.
RODRIGO GIRÃO SERRÃO

Projects & Code

Flowmpeg: FFmpeg Workflows for Python

GITHUB.COM/IMANM02 • Shared by Iman Mohammadi

Citeprobe: Evidence-First Academic Reference Verification

GITHUB.COM/IMANM02 • Shared by Iman Mohammadi

HeyUtils Python Regex Tester

HEYUTILS.COM • Shared by Carter Domenic (HeyUtils)

terrahour: World Clock With a Day/Night Map for the Terminal

GITHUB.COM/ACOCI86 • Shared by Alessio Coci

pre-commit: Manage Multi-Language Pre-Commit Hooks

GITHUB.COM/PRE-COMMIT

Events

Weekly Real Python Office Hours Q&A (Virtual)

October 28, 2026
REALPYTHON.COM

FastAPI Conf 2026

October 28 to October 29, 2026
FASTAPICONF.COM

PyConFR 2026

October 29 to November 2, 2026
PYCON.FR

PyDelhi User Group Meetup

October 31, 2026
MEETUP.COM

Melbourne Python Users Group, Australia

November 2, 2026
J.MP

PyCon ES 2026

November 6 to November 9, 2026
PYCON.ORG


Happy Pythoning!
This was PyCoder’s Weekly Issue #758.
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 ]

October 27, 2026 07:30 PM UTC


Python Bytes

#502 Introducing chameleon-rs

<strong>Topics covered in this episode:</strong><br> <ul> <li><strong>PHP wasn’t the only problem last week</strong></li> <li><strong><a href="https://mkennedy.codes/docs/chameleon-rs/?featured_on=pythonbytes">chameleon-rs</a></strong></li> <li><strong><a href="https://astral.sh/blog/uv-ty-integration?featured_on=pythonbytes">Bringing uv and ty together</a></strong></li> <li><strong><a href="https://github.com/whitphx/stlite?featured_on=pythonbytes">Stlite runs Streamlit apps entirely in the browser</a></strong></li> <li><strong>Extras</strong></li> <li><strong>Joke</strong></li> </ul><a href='https://www.youtube.com/watch?v=V7m5MAhp4qE' style='font-weight: bold;' data-umami-event="Livestream-Past" data-umami-event-episode="502">Watch on YouTube</a><br> <p><strong>About the show</strong></p> <p><strong>Connect with the hosts</strong></p> <ul> <li>Michael: <a href="https://fosstodon.org/@mkennedy">Mastodon</a> / <a href="https://bsky.app/profile/mkennedy.codes?featured_on=pythonbytes">BlueSky</a> / <a href="https://x.com/mkennedy?featured_on=pythonbytes">X</a> / <a href="https://www.linkedin.com/in/mkennedy/?featured_on=pythonbytes">LinkedIn</a></li> <li>Calvin: <a href="https://sixfeetup.social/@calvin?featured_on=pythonbytes">Mastodon</a> / <a href="https://bsky.app/profile/calvinhp.com?featured_on=pythonbytes">BlueSky</a> / <a href="https://x.com/calvinhp?featured_on=pythonbytes">X</a> / <a href="https://www.linkedin.com/in/calvinhp/?featured_on=pythonbytes">LinkedIn</a></li> <li>Show: <a href="https://fosstodon.org/@pythonbytes">Mastodon</a> / <a href="https://bsky.app/profile/pythonbytes.fm">BlueSky</a> / <a href="https://x.com/PythonBytes?featured_on=pythonbytes">X</a> 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: PHP wasn’t the only problem last week</strong></p> <ul> <li>Three security incidents in one week, each at a different layer: CA trust (eMudhra), the release pipeline (LiteLLM) and a C decoder (Brotli). All three have fixes released.</li> <li>eMudhra CA: the keys of a compromised eMudhra intermediate CA are now distrusted by CPython. 3.15.1, 3.14.9 and 3.13.17 shipped Oct 21. 3.15.2, 3.14.10, 3.13.18, 3.12.16 and 3.11.18 followed Oct 26 (CVE-2026-115557) and also fix a tarfile filter bypass. Python's post says pip operations are safe even before pip is upgraded.</li> <li>eMudhra, tooling: pip 26.2.2 bundles certifi 2026.10.19, which removes the root. uv 0.13.4 (GHSA-hq85-5cpj-mr62) and 0.13.5 (rustls-webpki 0.103.16) are the uv fixes. After upgrading uv, run uv python upgrade. Existing venvs need pip upgraded by hand, and the 3.9 and 3.10 builds still bundle a pip that trusts the root. <ul> <li>Anyone holding those intermediate keys can mint certificates for any domain that an unpatched client will accept as valid.</li> <li>That enables man-in-the-middle attacks: reading or altering HTTPS traffic without a certificate warning.</li> <li>The risk is highest where the attacker can sit on the network path, such as hostile Wi-Fi, a compromised router or ISP, or a cloud network position.</li> <li>For Python specifically, the exposed traffic would be package downloads from PyPI, API calls with credentials or tokens, and webhooks. A tampered package download is the worst case.</li> <li>Containers and old venvs that nobody rebuilds are where this lingers. uv says its Python 3.9 and 3.10 builds still bundle a pip that trusts the root, and existing venvs don't pick up the fix automatically.</li> </ul></li> <li>LiteLLM 1.107.0: malicious wheels hit PyPI on Oct 21. All seven wheels were live from about 11:40 to 13:23 UTC, with roughly 49k downloads. The Docker images were also infected. It's the second LiteLLM compromise this year, after 1.82.7 and 1.82.8 in March.</li> <li>LiteLLM payload: cook.pth runs on every Python start, so nothing has to import litellm. It steals SSH, cloud, browser, CI and AI-tool credentials, and an hourly loader runs remote code. The clean pin is litellm==1.106.4. Kill the loader first, rebuild rather than repair, then rotate everything the host could reach.</li> <li>LiteLLM root cause: a master key committed to a public repo on May 27 was never revoked. On Oct 10 it yielded an org owner's GitHub CLI token. The attacker then changed the trusted release workflow to build from a lookalike Berri-AI/litellm repo. BerriAI's first explanation (tokens in logs) was wrong, and the Oct 23 report supersedes it.</li> <li>Broccoli2Shell: CVE-2026-111497 is an out-of-bounds write in Google's Brotli C decoder. It affects the Python Brotli and brotlicffi packages 1.0.3 through 1.2.0(.x). Upgrade to Brotli 1.2.1 or brotlicffi 1.2.1.0. The CVE was published Oct 22 and rated 9.2 Critical by GitHub.</li> <li>Broccoli2Shell workaround: if you can't upgrade, uninstall both packages. urllib3, requests and aiohttp then fall back to gzip and deflate. Also reject Content-Encoding: br request bodies at the proxy or WAF. <strong>Sources</strong></li> <li>https://blog.python.org/2026/10/python-3152-31410-31318-31216-31118/</li> <li>https://www.stepsecurity.io/blog/litellm-1-107-0-compromised-pypi-backdoored-wheels-credential-stealer</li> <li>https://broccoli2shell.com/</li> </ul> <p><strong>Michael #2: <a href="https://mkennedy.codes/docs/chameleon-rs/?featured_on=pythonbytes">chameleon-rs</a></strong></p> <ul> <li><strong>Chameleon templates</strong>, much faster than before (up to 70x faster on start)</li> <li><strong>Modernized</strong>: A Rust engine that runs your Chameleon templates unchanged with async rendering, partials (think HTMX), Markdown</li> <li><strong>Built-in framework support</strong>: Flask, Quart, FastAPI, Starlette, Pyramid, Robyn and Litestar integrations.</li> <li><strong>A <a href="https://mkennedy.codes/docs/chameleon-rs/user-guide/switching-from-chameleon.html?featured_on=pythonbytes">compatibility shim</a></strong> for testing out quickly: <code>chameleon_rs.compat.install()</code></li> <li><strong>AI agent support</strong> via docs (every doc page has a .md extension variant, eg <a href="https://mkennedy.codes/docs/chameleon-rs/user-guide/fastapi.md?featured_on=pythonbytes">FastAPI docs</a>) and <a href="https://mkennedy.codes/docs/chameleon-rs/skills.html?featured_on=pythonbytes">the skill</a> is installable or downloadable.</li> <li><strong>Byte-for-byte identical</strong> on every page for <a href="http://talkpython.fm?featured_on=pythonbytes">talkpython.fm</a> and <a href="http://training.talkpython.fm?featured_on=pythonbytes">training.talkpython.fm</a></li> <li>Passes 100% of the unit tests of the <a href="https://pypi.org/project/Chameleon/?featured_on=pythonbytes">original chameleon project</a>. See <a href="https://mkennedy.codes/docs/chameleon-rs/user-guide/compatibility.html?featured_on=pythonbytes">compatibility page</a>.</li> <li>Natively supports my <a href="https://mkennedy.codes/docs/chameleon-flask/?featured_on=pythonbytes">chameleon-flask</a>, <a href="https://mkennedy.codes/docs/fastapi-chameleon/?featured_on=pythonbytes">fastapi-chameleon</a>, and <a href="https://mkennedy.codes/docs/chameleon-partials/?featured_on=pythonbytes">chameleon-partials</a> libraries.</li> <li>Swapping Talk Python Training to chameleon-rs <a href="https://blobs.pythonbytes.fm/chameleon-vs-chameleon-rs-perf-chart.webp?cache_id=c63b7e">made it 20% fast</a> (end-to-end, not just template rendering)!</li> </ul> <p><strong>Calvin #3: <a href="https://astral.sh/blog/uv-ty-integration?featured_on=pythonbytes">Bringing uv and ty together</a></strong></p> <ul> <li>Status: Astral (Sara Sklenka, Oct 13) announced "an experimental integration between uv and ty, available in preview." They ask for feedback, and say "in parallel, we're continuing toward ty's stable release."</li> <li>uv check: a new uv command that prepares the project environment, installs dependencies and runs ty. Flags are --package [HTML_REMOVED], --all-packages and --script <a href="http://example.py/?featured_on=pythonbytes">example.py</a>. Ruff support is planned, so lint and type check would run in one command.</li> <li>Lockfile pinning: uv check uses the ty version recorded in your lockfile. Developers and CI therefore share one ty version without separate pins.</li> <li>PEP 723 scripts: ty reads inline script metadata and uses uv to build an environment per script. Each script is checked against its own declared dependencies, even when they differ from the project's. In the editor, saving a script syncs it with uv.</li> <li>Enabling it: running uv check turns it on automatically. In VS Code, install the astral-sh.ty extension and set "ty.experimental.useUv": "on". On the command line, run TY_UV=scripts ty check.</li> <li>Opt-in: the post says automatic environment setup "is currently opt-in" because installing dependencies can execute third-party code. Astral plans to enable script support by default over time, and automatic setup for standard projects.</li> <li>Under the hood: it's powered by uv workspace metadata. uv 0.13.6 (Oct 26) changed that preview output to nest each module's owners in an object, so anything parsing the old flat structure breaks.</li> <li>Versions: it needs uv 0.12.3+ and ty 0.0.76+. Current releases are past both (uv 0.13.6, ty 0.0.92 on Oct 26), though I couldn't confirm 0.0.76's exact date.</li> <li>Next: Astral says it's working on checks that combine uv's dependency knowledge with ty's code analysis, such as detecting unused dependencies.</li> <li>Also credit to listener Sky Kasko. :)</li> </ul> <p><strong>Michael #4: <a href="https://github.com/whitphx/stlite?featured_on=pythonbytes">Stlite runs Streamlit apps entirely in the browser</a></strong></p> <p><a href="https://streamlit.io?featured_on=pythonbytes">Streamlit</a> is a very unique way to create data-driven dashboards. <a href="https://github.com/whitphx/stlite?featured_on=pythonbytes">Stlite</a> is a WebAssembly port of Streamlit built on Pyodide, so a Streamlit app runs completely in the visitor's browser with no Python server behind it. It ships as three packages (@stlite/browser for web pages, @stlite/desktop for offline desktop apps, @stlite/react for embedding in React), plus Stlite Sharing, a hosted editor for trying apps online. The project has been active since 2022 and is now at 0.85.1 with sponsorship from Streamlit/Snowflake and TestMu AI.</p> <ul> <li>A Streamlit app becomes static files you can host anywhere, the same story JupyterLite tells for notebooks.</li> <li>Deployment shrinks to a script tag or a single HTML file; the server only serves static assets and never runs Python.</li> <li>It patches requests and urllib through pyodide-http and supports pyfetch, so most existing HTTP code keeps working.</li> <li>Real limitations: time.sleep is a no-op in favor of asyncio.sleep, asyncio.run breaks, and packages with unported binary extensions cannot install.</li> <li>Files default to ephemeral memory, but IDBFS mountpoints persist data across reloads in the browser's IndexedDB.</li> <li>A SharedWorker mode lets multiple apps share one Python runtime to cut memory, at the cost of shared state between apps.</li> <li>The @stlite/desktop bundler turns an app into an offline desktop executable, demonstrated converting Streamlit apps to .exe files.</li> </ul> <p><strong>Extras</strong></p> <p>Calvin:</p> <ul> <li><a href="https://realpython.com/podcasts/rpp/316/?featured_on=pythonbytes">Real Python Podcast #316</a> with Seth Larson on Python security</li> <li><a href="https://pyfound.blogspot.com/2026/10/wikis-relaunched.html?featured_on=pythonbytes">**PSF wikis</a> relaunched on GitHub Pages** Michael:</li> <li><a href="https://www.bleepingcomputer.com/news/security/php-ships-emergency-patches-for-critical-php2shell-flaws/?featured_on=pythonbytes">PHP ships emergency patches for critical PHP2Shell flaws</a></li> <li><a href="https://github.com/openai/php2shell-checker?featured_on=pythonbytes">OpenAI released a checker</a></li> <li><a href="https://talkpython.fm/meetups?featured_on=pythonbytes">Talk Python Next Tech Talk: Talk Python in Production(v2)</a></li> <li><a href="https://0ver.org/#notable-zerover-projects">Notable ZeroVer Projects</a> via Vincent Poulailleau</li> <li><a href="https://docs.python.org/release/3.15.2/whatsnew/changelog.html?featured_on=pythonbytes">Python 3.15.2 is out</a></li> </ul> <p><strong>Joke: <a href="https://x.com/PR0GRAMMERHUM0R/status/2056495137933394100?featured_on=pythonbytes">captcha: too real!</a></strong></p>

October 27, 2026 05:39 PM UTC


Rodrigo Girão Serrão

Typing improvements in Python 3.15

This article goes over the major improvements made to the typing module in Python 3.15.

Since static type hinting is becoming more and more popular in the Python world, every Python release brings with it major improvements to the world of typing. This article goes over the main improvements made to typing in Python 3.15 like the improvements to TypedDict, the addition of typing.TypeForm, or the addition of the decorator typing.disjoint_base.

15 days of Python 3.15

To celebrate the release of Python 3.15, over the next 15 business days I'll be writing about a new 3.15 feature every day. Explained clearly and with examples so you don't have to sift through the changelog.

Subscribe to receive this free email series:

If you want, you can also check the schedule of the upcoming emails.

New class parameters for TypedDict

Python 3.15 added two class parameters to TypedDict: extra_items and closed. The gist of it is that extra_items allows specifying that your dictionary may have other, unknown keys whose values have the specified type:

from typing import TypedDict

class Movie(TypedDict, extra_items=str):
    title: str
    year: int

The definition above means that a variable of type Movie might have other keys, as long as their values are assignable to the type str:

movie: Movie = {
    "title": "Forrest Gump",
    "year": 1994,
    "genre": "Comedy / Romance",
}

The extra item "genre": "Comedy / Romance" is allowed because the value is a string and extra_items=str.

To completely disallow extra items, regardless of their type, you can set closed=True as a class parameter.

There's an article dedicated to the improvements made to TypedDict in Python 3.15 that...

October 27, 2026 10:54 AM UTC