skip to navigation
skip to content

Planet Python

Last update: August 12, 2026 04:48 PM UTC

August 12, 2026


PyCharm

What’s New in PyCharm 2026.2.1

This PyCharm release is a big one for anyone building with AI. Your agents can now roll up their sleeves inside your Jupyter notebooks – working against a live kernel instead of firing off disconnected scripts. And they finally know which Python to use, so packages land in the right environment every time.

We’re also welcoming marimo notebooks into the IDE and introducing changes to bundled plugins to keep PyCharm fast and focused.

Release highlights

Jupyter notebook skill for AI agents

Let AI agents such as Claude Code and Codex create, edit, and run .ipynb notebooks via PyCharm’s notebook model and a live kernel, so variables, models, and data persist across cells instead of disappearing when the agent shells out. For you, this means more reliable notebook and ML work – with fewer tokens used. To start, just open the AI chat and ask the agent to work in your notebook.

This functionality is available with a JetBrains AI subscription.

Agent environment coordinator

Tired of AI agents installing packages into the wrong Python environment? This new skill gives the agent your project’s configured interpreter and tool – uv, Poetry, pip in a venv, or conda – so commands target the right environment, not a system one. If none exists, it can set one up via PyCharm, and the agent decides how to use the information. To start, ask the agent to run or install something in your project.

This functionality is available with a JetBrains AI subscription.

marimo notebooks in PyCharm [third-party plugin]

You can now open, edit, and run marimo notebooks directly in PyCharm with the new plugin developed by the marimo team. 

Work with reactive cells and interactive UI elements in a dedicated notebook without leaving your IDE. Because marimo notebooks are stored as Python files, they are Git-friendly, executable as scripts, and easy to integrate into your existing Python projects.

Changes to bundled plugins in 2026.2

As part of ongoing maintenance, we are unbundling and deprecating low-usage plugins, including Data Wrangler, Hugging Face, and Google Colab support. You can continue to install compatible versions from JetBrains Marketplace, but these plugins are no longer bundled or actively maintained by the PyCharm team. A more focused set of bundled plugins means a leaner codebase, helping us keep PyCharm fast and responsive and invest our effort where it has the most impact.

Redesigned Python Packages tool window

Redesigned Python Packages tool window in PyCharm

Clearer type checking

Get clearer, more actionable type messages:

Clearer type checking in PyCharm

Bug fixes

Download PyCharm

All of these updates are available in PyCharm 2026.2.1. Update right from the IDE or the Toolbox App, or download the latest version to try everything out on your own projects. As always, we’d love to hear your feedback.

August 12, 2026 12:05 PM UTC

We Stopped AI Agents From Installing Into the Wrong Python: Task Success Rates Jumped to 95%+

AI agents are supposed to save you time. Ask one to install a dependency or run your project, though, and it often does the opposite: It installs into the wrong Python, ignores the uv or virtual environment your project uses, and hands back a broken setup for you to fix yourself.

PyCharm’s new Agent Environment Coordinator skill fixes this, and this blog post shows just how helpful it proves to be.

AGENT ENVIRONMENT COORDINATOR The agent stopped guessing Python. Average task success 68% -> 98% Baseline With skill 28 Python tasks 6 AI models No system Python pollution

We tested six AI models using 28 different Python programming tasks. Without access to the project’s real environment, they solved 68% of the tasks on average. After we gave them access, their average success rate shot up to 98% – and they didn’t even modify the system Python.

If you’re currently using AI agents in your Python projects, read on to see how the Agent Environment Coordinator can improve their performance.

When the agent could see the project’s environment, it stopped failing

When using the Agent Environment Coordinator skill, each agent, regardless of the model, was able to complete far more of the 28 tasks. (See the Methodology section below for details on what the tasks entailed.) Here is the share of successfully completed tasks for each model, comparing the baseline to running with the skill in PyCharm:

MODEL BASELINE WITH SKILL Claude Sonnet 4.6 36% 96% Claude Sonnet 5 73% 100% Claude Opus 4.8 67% 100% Claude Opus 5.0 94% 98% Codex / GPT-5.5 62% 95% Codex / GPT-5.6 80% 100%

Every model improved, with the weakest baseline improving the most.

Why we built this

LLMs almost never use a project’s dedicated virtual environment. They fall back to a system interpreter, ignoring the fact that there may be several system interpreters and real projects often have more complex, multi-interpreter setups already configured in PyCharm that the agent has no way to see.

For example, pip install httpx runs against the wrong Python, the package installs globally, the script fails, and the environment is polluted.

PyCharm already knows which interpreter belongs to your project and which tool manages it. The agent just couldn’t ask – so we gave it a way.

How it works

The Agent Environment Coordinator lets the agent ask PyCharm two things. get_python_environment returns the correct interpreter for the file or module in question – the path plus the tool behind it (uv, Poetry, pip + venv, conda). If no environment exists yet, configure_python_interpreter sets one up by reusing PyCharm’s existing configuration mechanism – the same one that offers to create a .venv – so the new interpreter also becomes visible in the IDE.

The important part is what the skill doesn’t do. It returns information; it never intercepts or rewrites the command. The agent asks which Python to use, gets an accurate answer, and decides whether and how to use it to write the command itself. We hand it the missing context using existing mechanisms in PyCharm – we don’t let it take the wheel.

The payoff is practical: The agent works with your project setup out of the box. You don’t need to coach it through prompts about which environment to use, or clean up wrong installs afterward.

This PyCharm functionality is available with a JetBrains AI subscription.

Methodology

We built a dataset of 28 tasks covering everyday Python-environment work, like running tests, installing a library, listing dependencies, resolving a version conflict, and so forth.

Each task ultimately required the agent to pick the correct interpreter to execute a command. The eval also reduced the reward when the agent polluted the system environment, so a high score reflects a clean run, not just a passing one.

We ran the full set three times per model, with and without the skill, using Harbor, and averaged the results.

Results

Success rates climbed across the board – Sonnet 5 improved from 73% to 100%, Opus 5 from 94% to 100%, and Codex/GPT-5.6 from 80% to 100%. 

Two things stand out in addition to this numerical jump: 

Want to try it?

Open the AI chat in PyCharm 2026.2.1 and ask your agent to install a package or run something in your project – it’ll reach for the right interpreter on its own.

The Agent Environment Coordinator is one of PyCharm’s bundled skills. You can browse and manage all of them right in the IDE, expand the built-in library with external registries like public GitHub repositories, or import skills you’ve already set up for Claude Code or Codex.

August 12, 2026 12:01 PM UTC

We Gave AI Agents a Live Jupyter Kernel in PyCharm

If you’ve handed notebook work to an AI agent, you know how it tends to go: More often than not, it corrupts your .ipynb, loses your trained model the moment the run finishes, or burns budget sitting idle through a long job while you watch.

To solve this, we’re introducing a brand-new Jupyter skill. Built directly into PyCharm, it lets your AI agent work inside a live Jupyter kernel instead of handing the job to a subprocess and losing your progress. This one change means state persists across cells, the .ipynb isn’t corrupted, and long jobs wait until execution is completed instead of constantly checking and wasting precious tokens.

JUPYTER SKILL FOR PYCHARM A live kernel made Opus cheaper than the shell. 12% cheaper Claude Opus 5 across 12 ML tasks Kernel USD 59.09 Shell USD 67.06 12 ML tasks 98% cache reads State persists across cells

For Opus, the kernel ran cheaper than the shell

We tested the efficiency of the Jupyter skill by comparing the performance of agents when solving twelve different machine learning problems. We compared three different modes: strictly using bash, strictly using the kernel via the Jupyter skill, and a mixture of both.

While the agent was able to solve all twelve tasks in every mode, there was a difference in how much each mode spent. For Claude Opus 5, working through the kernel cost 59.09 USD versus 67.06 USD through the shell – about 12% cheaper.

MODE COST INPUT TOKENS CACHE READS Kernel (skill) Shell (baseline) USD 59.09 USD 67.06 72.7M 36.3M 98% 82%

Here’s the counterintuitive part: The kernel used more tokens, yet cost less. That’s because it keeps the prompt cache warm. 98% of its input was cache reads, versus 82% for the shell – and cache reads incur only 1/12 of the cost of creating a fresh cache.

Why we built this

Notebooks are where coding agents tend to fall apart. Most AI tools treat an .ipynb like a plain text file: They hand-edit the JSON (and corrupt it), and then run code by running a subprocess. The moment an agent starts the subprocess, the kernel state – the trained model, the loaded dataframe, and every import – lives in the child process, and vanishes when that process exits. The agent can’t inspect it, checkpoint it, or reuse it. Output is buffered until the run ends, so progress is invisible, and long training jobs get babysat – blind until the connection times out.

We asked the obvious question: What if the agent operated a live Jupyter kernel through the IDE?

So we built our new Jupyter skill, which exposes PyCharm’s own notebook intelligence – its notebook model and live-kernel control – to the agent. It does this through a single MCP wrapper, execute_tool, which covers the core notebook operations, including creating, editing, and reading notebooks; running cells; waiting on long runs; probing a running kernel; and controlling its lifecycle. The skill tells the agent when and how to use them.

How it works

The agent:

This PyCharm functionality is available with a JetBrains AI subscription.

Methodology

We used twelve tasks from the MLGym machine-learning benchmark – classification, regression, and reinforcement-learning problems, each of which requires the agent to load data, train, evaluate, and save a result. We ran them across Claude Opus 5 and OpenAI’s GPT-5.6 models, Sol and Terra, through Codex. We compared three modes: through the kernel only, through the kernel plus the shell, and through the shell alone. As these benchmark tasks expose test labels to the agent, we treat cost – not accuracy – as the reliable signal.

One caveat, for transparency: An audit found that one of the twelve tasks, Titanic, was contaminated – the agent could peek at the test set, and each agent used this to select the best model to present as the final solution. Titanic is a well-known, easy task for LLMs, and the issue appeared consistently across all three modes, so it doesn’t skew the comparison. The pattern holds even with Titanic removed – the kernel still ran 10% cheaper than the shell for Opus (56.34 USD versus 62.65 USD).

Results

The cost win is model- and task-dependent. It was clearest for Claude Opus on long, stateful jobs, while the shell came out cheaper on short tasks and for the Codex models – which already use the cache efficiently, so there the skill earns its place on workflow, not cost.

Where it still falls short

Two things are worth keeping in mind:

The skill removes the mechanical waste, but doesn’t turn a weak approach into a strong one.

Want to try it?

Open the AI chat in PyCharm 2026.2.1 and ask your agent to work in a notebook – create one, load a dataset, or kick off a training run. The agent will operate the kernel directly instead of running commands in the shell.

You can also browse and manage skills directly from the IDE, expand the built-in library with external registries like public GitHub repositories, or let PyCharm import skills you’ve already set up for Claude Code or Codex.

August 12, 2026 12:00 PM UTC

Unbundling and Deprecating Low-Usage Plugins in PyCharm

As part of ongoing maintenance, we are unbundling and deprecating low-usage plugins starting with PyCharm 2026.2. This includes support for Data Wrangler, Hugging Face, and Google Colab, among others. 

A more focused set of bundled plugins means a leaner codebase, enabling us to keep PyCharm fast and responsive and invest our effort where it has the most impact.

You can continue installing compatible versions from the JetBrains Marketplace, but these plugins will no longer be bundled or actively maintained by the PyCharm team. Read this blog post for the full list, deprecation timeline, and next steps.

Why we’re making this change

The tools and workflows developers rely on keep evolving, and several of these plugins never reached the level of adoption we hoped for. After reviewing usage trends, we’ve decided to move a set of low-usage plugins out of active development, so our team can focus on features with broader impact for Python developers.

A smaller set of bundled plugins also means a leaner, more maintainable codebase. As PyCharm continues to grow, we want to invest our engineering effort where it has the most impact and keep the IDE fast and responsive over time.

Unbundling and deprecating a plugin doesn’t necessarily mean deleting it. If a certain plugin’s functionality is still used, we’ll move that plugin’s code to a separate Obsolete Plugins repository. The plugin will remain searchable and installable on JetBrains Marketplace with a fixed compatibility range, but will no longer be rebuilt with every new release or maintained by the PyCharm team.

Which plugins are affected

The following plugins are being deprecated; those currently bundled will be unbundled first:

Other low-usage plugins may be deprecated in the same way in future releases.

Timeline and what to expect

v2026.2

v2026.3 and beyond

What this means for you

If you rely on any of these plugins, you can continue to install a compatible version from JetBrains Marketplace for PyCharm 2026.2. Because the source moves to the Obsolete Plugins repository under an open model, the community can keep building and installing the plugins manually. If you’re interested in maintaining one of them, we’d love to hear from you.

Thank you

We’re grateful to everyone who used these plugins, filed issues, and shared feedback over the years. Thank you!

The PyCharm team

August 12, 2026 11:59 AM UTC


Python GUIs

Adding QTabWidget to a Layout Alongside Other Widgets in PyQt6 — How to combine QTabWidget with other layouts without it taking over your entire window

I'm trying to create a layout in PyQt and implement different layouts into one QHBoxLayout. Everything works fine, but when I add a QTabWidget alongside other layouts, it becomes the only visible widget — as if everything else disappears. Isn't it possible to have a QTabWidget in a layout beside other layouts?

Good news: you absolutely can place a QTabWidget inside a layout alongside other widgets and sub-layouts. You don't need to wrap it in a QGroupBox or use any special workaround. The issue usually comes down to how the tabs themselves are set up — specifically, whether the tab pages have any content and layout of their own.

Let's walk through what's happening and how to fix it.

Why the QTabWidget seems to "take over"

When you create a QTabWidget and add empty QWidget pages to it, those pages have no layout and no content. Depending on how the widget calculates its size, this can cause unexpected sizing behavior in the parent layout. The tab widget may request more space than you expect, or the other widgets may collapse because the layout gives the tab widget priority.

The fix is straightforward: make sure each tab page has a layout, and give the tab widget a reasonable size policy or stretch factor so it shares space with its neighbors.

A minimal example that works

Let's start with a small, complete example. We'll create a horizontal layout with a vertical stack of colored widgets on the left and a QTabWidget on the right — sitting happily side by side.

First, here's a simple Color helper widget that fills itself with a solid color, useful for visualizing layouts:

python
from PyQt6.QtWidgets import QWidget
from PyQt6.QtGui import QColor, QPalette


class Color(QWidget):
    """A simple widget that displays a solid color."""

    def __init__(self, color):
        super().__init__()
        self.setAutoFillBackground(True)
        palette = self.palette()
        palette.setColor(QPalette.ColorRole.Window, QColor(color))
        self.setPalette(palette)

Now let's build the full window:

python
import sys
from PyQt6.QtWidgets import (
    QApplication, QMainWindow, QWidget,
    QHBoxLayout, QVBoxLayout, QTabWidget, QLabel,
)


class Color(QWidget):
    def __init__(self, color):
        super().__init__()
        self.setAutoFillBackground(True)
        palette = self.palette()
        palette.setColor(palette.ColorRole.Window, QColor(color))
        self.setPalette(palette)


from PyQt6.QtGui import QColor, QPalette


class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("QTabWidget in a Layout")

        # Main horizontal layout
        main_layout = QHBoxLayout()

        # Left side: a vertical stack of colored widgets
        left_layout = QVBoxLayout()
        left_layout.addWidget(Color("black"))
        left_layout.addWidget(Color("red"))
        left_layout.addWidget(Color("yellow"))
        main_layout.addLayout(left_layout)

        # Middle: a single green widget
        main_layout.addWidget(Color("green"))

        # Right side: a QTabWidget
        tab_widget = QTabWidget()
        tab_widget.setMovable(True)

        # Create tab pages WITH layouts and content
        tab1 = QWidget()
        tab1_layout = QVBoxLayout()
        tab1_layout.addWidget(QLabel("This is Tab 1"))
        tab1_layout.addWidget(Color("lightblue"))
        tab1.setLayout(tab1_layout)

        tab2 = QWidget()
        tab2_layout = QVBoxLayout()
        tab2_layout.addWidget(QLabel("This is Tab 2"))
        tab2_layout.addWidget(Color("lightyellow"))
        tab2.setLayout(tab2_layout)

        tab_widget.addTab(tab1, "Tab 1")
        tab_widget.addTab(tab2, "Tab 2")

        main_layout.addWidget(tab_widget)

        # Set the central widget
        container = QWidget()
        container.setLayout(main_layout)
        self.setCentralWidget(container)


app = QApplication(sys.argv)
window = MainWindow()
window.resize(800, 400)
window.show()
sys.exit(app.exec())

Run this and you'll see the colored widgets on the left, the green widget in the middle, and the tab widget on the right — all sharing the horizontal space.

Controlling how much space each section gets

If you want finer control over how the horizontal space is divided, you can use stretch factors. These tell the layout how to distribute extra space among its children. For a deeper dive into how QHBoxLayout, QVBoxLayout, and QGridLayout work in PyQt6, see our guide to PyQt6 layouts.

python
main_layout.addLayout(left_layout, 1)    # stretch factor 1
main_layout.addWidget(Color("green"), 1)  # stretch factor 1
main_layout.addWidget(tab_widget, 2)      # stretch factor 2 (gets twice as much space)

The numbers are relative. Here the tab widget gets twice the space of the left section and the green widget. Adjust these to taste.

Complete working example

Here's the full example with stretch factors and a grid layout section included, similar to the original code:

python
import sys
from PyQt6.QtWidgets import (
    QApplication, QMainWindow, QWidget,
    QHBoxLayout, QVBoxLayout, QGridLayout,
    QTabWidget, QLabel,
)
from PyQt6.QtGui import QColor, QPalette


class Color(QWidget):
    """A simple widget that displays a solid color."""

    def __init__(self, color):
        super().__init__()
        self.setAutoFillBackground(True)
        palette = self.palette()
        palette.setColor(QPalette.ColorRole.Window, QColor(color))
        self.setPalette(palette)


class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("QTabWidget Alongside Other Layouts")

        main_layout = QHBoxLayout()

        # Section 1: Vertical stack
        v_layout1 = QVBoxLayout()
        v_layout1.addWidget(Color("black"))
        v_layout1.addWidget(Color("red"))
        v_layout1.addWidget(Color("yellow"))
        v_layout1.setContentsMargins(0, 0, 20, 0)
        v_layout1.setSpacing(20)
        main_layout.addLayout(v_layout1, 1)

        # Section 2: Single widget
        main_layout.addWidget(Color("green"), 1)

        # Section 3: Another vertical stack
        v_layout2 = QVBoxLayout()
        v_layout2.addWidget(Color("blue"))
        v_layout2.addWidget(Color("purple"))
        main_layout.addLayout(v_layout2, 1)

        # Section 4: Grid layout
        grid_layout = QGridLayout()
        grid_layout.addWidget(Color("grey"), 0, 0)
        grid_layout.addWidget(Color("black"), 1, 0)
        grid_layout.addWidget(Color("darkgrey"), 1, 1)
        grid_layout.addWidget(Color("orange"), 2, 1)
        main_layout.addLayout(grid_layout, 1)

        # Section 5: Tab widget
        tab_widget = QTabWidget()
        tab_widget.setMovable(True)
        tab_widget.setTabPosition(QTabWidget.TabPosition.North)

        # Tab 1 with content
        tab1 = QWidget()
        tab1_layout = QVBoxLayout()
        tab1_layout.addWidget(QLabel("Content for Tab 1"))
        tab1_layout.addWidget(Color("lightblue"))
        tab1_layout.addWidget(Color("lightyellow"))
        tab1.setLayout(tab1_layout)

        # Tab 2 with content
        tab2 = QWidget()
        tab2_layout = QVBoxLayout()
        tab2_layout.addWidget(QLabel("Content for Tab 2"))
        tab2_layout.addWidget(Color("lightgreen"))
        tab2.setLayout(tab2_layout)

        tab_widget.addTab(tab1, "Tab 1")
        tab_widget.addTab(tab2, "Tab 2")

        main_layout.addWidget(tab_widget, 2)

        # Set up the central widget
        container = QWidget()
        container.setLayout(main_layout)
        self.setCentralWidget(container)


app = QApplication(sys.argv)
window = MainWindow()
window.resize(900, 400)
window.show()
sys.exit(app.exec())

When you run this, you'll see all five sections displayed side by side in a single horizontal layout — colored blocks, a grid, and a tab widget, all coexisting without any one section dominating the window.

QTabWidget works perfectly in any layout alongside other widgets. Just make sure the tab pages have their own layouts and content, and use stretch factors to control how space is distributed. If you'd prefer to design these complex layouts visually rather than in code, you can use Qt Designer to build your GUI layout. For a broader overview of the widgets you can place inside your tabs and layouts, take a look at our PyQt6 widgets tutorial.

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

August 12, 2026 06:00 AM UTC


Python Bytes

#491 Feeling Judged

<strong>Topics covered in this episode:</strong><br> <ul> <li><strong>Claude Code /insights</strong></li> <li><strong><a href="https://blog.trailofbits.com/2026/06/30/shipping-post-quantum-cryptography-to-python/?featured_on=pythonbytes">Post-quantum crypto lands in Python</a></strong></li> <li><strong><a href="https://realpython.com/python-news-august-2026/?featured_on=pythonbytes">MCP goes stateless — and FastMCP gets renamed</a></strong></li> <li><strong><a href="https://github.com/microsoft/inshellisense?featured_on=pythonbytes">inshellisense - IDE style command line auto complete</a></strong></li> <li><strong>Extras</strong></li> <li><strong>Joke</strong></li> </ul><a href='https://www.youtube.com/watch?v=5iaT_bO3INA' style='font-weight: bold;'data-umami-event="Livestream-Past" data-umami-event-episode="491">Watch on YouTube</a><br> <p><strong>About the show</strong></p> <p><strong>Sponsored by</strong> <a href="https://pythonbytes.fm/xweather">Xweather</a> Xweather combines enterprise-grade weather intelligence with agent-ready APIs, natural language capabilities, and an MCP server so your agents can adapt workflows, automate responses, and make better decisions based on real-world conditions. Michael will tell you more about them later in the show. Get started for free at <a href="http://pythonbytes.fm/xweather">pythonbytes.fm/xweather</a> <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 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>Michael #1: Claude Code /insights</strong></p> <ul> <li>Michael’s Insights: <a href="https://blobs.pythonbytes.fm/michael-kennedy-claude-code-insights-2026-08-09.html?cache_id=6c6ef5">michael-kennedy-claude-code-insights-2026-08-09.html</a></li> <li>Be careful sharing these outputs, they include details references to your projects, errors, security findings, etc. ;)</li> <li><code>/insights</code> reads your last 30 days of local session transcripts and hands back an interactive HTML report on how you actually work. <ul> <li><strong>One command, zero setup</strong>: type <code>/insights</code> in a session, or run <code>claude -p "/insights"</code> from the shell for a non-interactive version that just prints the path</li> <li><strong>Reads what's already on disk</strong>: pulls session logs from <code>~/.claude/projects/</code>, skipping agent sub-sessions and anything under 2 messages or 1 minute</li> <li><strong>Project areas</strong>: clusters your sessions into themes like "CLI Tooling" or "Documentation" with session counts</li> <li><strong>Friction analysis</strong>: categorizes where things went wrong by root cause - and quotes your own prompts back at you</li> <li><strong>Interaction style</strong>: tells you whether you're a delegator or a micromanager, plus which workflows are worth doubling down on</li> <li><strong>Actually actionable</strong>: suggests concrete <a href="http://CLAUDE.md?featured_on=pythonbytes">CLAUDE.md</a> additions and Claude Code features you're not using</li> <li><strong>The catch</strong>: Haiku does the per-session classification, so the first run takes several minutes; results cache to <code>~/.claude/usage-data/facets/</code> and the report lands at <code>~/.claude/usage-data/report.html</code></li> </ul></li> </ul> <p><strong>Calvin #2: <a href="https://blog.trailofbits.com/2026/06/30/shipping-post-quantum-cryptography-to-python/?featured_on=pythonbytes">Post-quantum crypto lands in Python</a></strong></p> <ul> <li><code>pyca/cryptography</code> 48 ships ML-KEM (key establishment) and ML-DSA (signatures) — NIST's post-quantum standards, now one <code>pip install</code> away.</li> <li>Big deal because it's the 11th most-downloaded package on PyPI (~1.2B downloads/month) and sits under Ansible, Certbot, Airflow, and paramiko. No PQ there, no PQ anywhere in Python.</li> <li>Trail of Bits did the work (Rust bindings, cross-backend API, tests, AWS-LC backend support), funded by the Sovereign Tech Agency.</li> <li>Timing tracks a June 22 White House order setting federal deadlines: PQ key establishment by end of 2030, PQ signatures by end of 2031.</li> <li>Not a drop-in swap — the wire sizes explode. ML-DSA-65 signatures are 3,309 bytes vs Ed25519's 64; ML-KEM-768 public keys are 1,184 bytes vs X25519's 32. Hardcoded field sizes and length prefixes will bite.</li> <li>API looks like the existing asymmetric primitives, except ML-KEM is encapsulate/decapsulate rather than a Diffie-Hellman exchange. SLH-DSA (the hash-based conservative backstop) is still in progress. The primitives are here, but protocols haven't caught up — so you won't be running post-quantum Certbot this week.</li> </ul> <p><strong>Sponsor: Xweather</strong></p> <p>You're using agents that can write code, summarize documents, and automate workflows. But they're missing one thing: awareness of the world around them. This is where today's sponsor, Xweather comes in. Xweather combines enterprise-grade weather intelligence with agent-ready APIs, natural language capabilities, and an MCP server built for tools like Claude, Codex, Copilot, and modern IDEs – so your agents can adapt workflows, automate responses, and make better decisions based on real-world conditions. Backed by Vaisala, whose instruments fly on NASA missions to Mars, Xweather delivers trusted data and unique insights that go beyond conditions to actual impact – from real-time lightning strikes to road surface forecasts. Start with 15,000 free API calls each month and pay only for what you use as you grow. Xweather is your full weather stack, for developers by developers. Start building for free today at <a href="http://pythonbytes.fm/xweather">pythonbytes.fm/xweather</a>. The link is in your podcast player's show notes and on the episode page. Thanks so much to Xweather for supporting Python Bytes.</p> <p><strong>Calvin #3: <a href="https://realpython.com/python-news-august-2026/?featured_on=pythonbytes">MCP goes stateless — and FastMCP gets renamed</a></strong></p> <ul> <li>From <strong>Philipp Acsany</strong> over at Real Python</li> <li>The <code>2026-07-28</code> spec landed July 28 and the Python SDK shipped 2.0.0 the same day. Biggest rewrite since MCP launched, and it's breaking on purpose. Context for scale: the Tier 1 SDKs are pulling close to half a billion downloads a month, with TypeScript and Python each past a billion total.</li> <li>The headline is the stateless core. The <code>initialize</code>/<code>initialized</code> handshake and the <code>Mcp-Session-Id</code> header are both retired — protocol version, client identity, and capabilities now ride in <code>_meta</code> on every request, with an optional <code>server/discover</code> RPC if a client wants capabilities up front. Any request can land on any instance behind plain round-robin, no shared storage.</li> <li>Server-initiated calls are the hard part of the migration. Sampling, elicitation, and <code>roots/list</code> no longer call back to the client; instead the server returns <code>resultType: "input_required"</code> and the client retries with <code>inputResponses</code> attached. Multi Round-Trip Requests, MRTR. Also: <code>Mcp-Method</code> and <code>Mcp-Name</code> are now required headers so gateways route on headers instead of cracking JSON bodies, and missing-resource errors move to standard <code>32602</code>.</li> <li>Deprecation sweep with an actual policy behind it — Roots, Sampling, Logging, and the legacy HTTP+SSE transport all deprecated with a twelve-month minimum offramp. Tasks graduated out of the experimental core into a real extension, which is what the formalized extensions framework was for. MCP Apps is now an official extension too, so a tool call can return sandboxed interactive HTML. Auth picked up RFC 9207 issuer validation, issuer-bound credentials, and a shift from DCR toward CIMD.</li> <li>Python SDK 2.0 is where it gets personal: <code>FastMCP</code> is now <code>MCPServer</code>, no alias, no shim. <code>McpError</code> → <code>MCPError</code>. Wire types went snake_case (<code>is_error</code>, <code>input_schema</code>) and moved to a standalone <code>mcp_types</code> package, with <code>mcp.types</code> kept as a permanent alias. One <code>Client</code> object replaces the old transport + <code>ClientSession</code> + <code>initialize()</code> stack. <code>httpx</code> became <code>httpx2</code>. Sync handlers run on worker threads now, so <code>asyncio.get_running_loop()</code> raises inside them.</li> <li>The good news: one <code>MCPServer</code> serves both protocol eras, so 2025-era clients keep working with nothing to configure, and a <code>Resolve(fn)</code> parameter lets one tool body cover MRTR and the old path. 1.x is maintenance-and-security-fixes only — pin <code>mcp&gt;=1.28,&lt;2</code> if this week is already full. The Tasks extension isn't in 2.0.0 yet, so Tasks has left the core spec but hasn't landed in the SDK. If you only <em>call</em> MCP servers, you mostly just get the benefits for free. If you <em>ship</em> one, you already know what your week looks like. And if you use the standalone <code>fastmcp</code> package instead of the official SDK — different project, 3.x line, none of this touches you. The rename is partly to stop the two from being confused.</li> </ul> <p><strong>Michael #4: <a href="https://github.com/microsoft/inshellisense?featured_on=pythonbytes">inshellisense - IDE style command line auto complete</a></strong></p> <ul> <li>via Doug Nichols</li> <li><code>inshellisense</code> provides IDE style autocomplete for shells.</li> <li>It's a terminal native runtime for <a href="https://github.com/withfig/autocomplete?featured_on=pythonbytes">autocomplete</a> which has support for 600+ command line tools.</li> <li><code>inshellisense</code> supports Windows, Linux, &amp; macOS.</li> <li>If you are using a <a href="https://www.nerdfonts.com/?featured_on=pythonbytes">NerdFont</a> patched font, you can enable the NerdFonts support in your config file</li> </ul> <p><strong>Extras</strong></p> <p>Calvin:</p> <ul> <li>Django 6.1 Released — https://www.djangoproject.com/weblog/2026/aug/05/django-61-released/</li> <li>DjangoCon US is quickly arriving, grab your tickets now! — https://2026.djangocon.us/ Michael:</li> <li><a href="https://pythonbytes.fm/ai-integration">AI integration: Python Bytes for AI</a></li> <li><a href="https://training.talkpython.fm/courses/up-and-running-with-rust?featured_on=pythonbytes">Up and Running with Rust Course is out!</a></li> </ul> <p><strong>Joke: <a href="https://programmerhumor.io/security-memes/google-p5hl?featured_on=pythonbytes">But they already know</a></strong></p>

August 12, 2026 01:00 AM UTC

August 11, 2026


TestDriven.io

Storing Django Static and Media Files on Cloudflare R2

This tutorial shows how to configure Django to load and serve up static and media files, public and private, via Cloudflare R2.

August 11, 2026 10:28 PM UTC


PyCoder’s Weekly

Issue #747: Modern OOP, bisect, Django Async, and More (2026-08-11)

#747 – AUGUST 11, 2026
View in Browser »

The PyCoder’s Weekly Logo


New Book: Modern Object-Oriented Python

Real Python’s new book on classes, Python’s data model, and object-oriented design is out in Early Access. Nine chapters collected from a decade of tutorials, re-edited into one curriculum, and updated for today’s Python.
REAL PYTHON

Binary Search in Python With bisect

Python’s bisect module implements binary search for you. Here’s how bisect_left, bisect_right, and insort work, plus recipes for finding the closest match or all values in a range.
TREY HUNNER

Let AI Agents Into Your B2B App. Securely

alt

More of your users are asking to connect AI agents to your product, and you want to say yes. PropelAuth lets you give each agent scoped, revocable access, so you stay in control of what it can do. Learn More →
PROPELAUTH sponsor

Updates on Django’s Async Story

For years, “Django and async” came with an asterisk. The docs themselves warned you off it, but that has changed. Talk Python interviews Carlton Gibson and they talk async in Django.
TALK PYTHON

PEP 841: Adding Frozen Syntax to Optimize Immutable Types (Draft)

PYTHON.ORG

PEP 833: Freezing the HTML Simple Repository API (Final)

PYTHON.ORG

PEP 828: Supporting ‘Yield From’ in Asynchronous Generators (Accepted)

PYTHON.ORG

PEP 837: Extensible JSON Serialization (Draft)

PYTHON.ORG

Python 3.14.7 and 3.13.15 Released

PYTHON.ORG

Django 6.1 Released

DJANGO SOFTWARE FOUNDATION

Articles & Tutorials

Take the 2026 Python Typing Survey

Now in its third year, the Python Typing Survey has become a recognised reference point for the direction of Python’s type system. It’s being referenced in PEPs and presentations to the Python community. Whether you’re an experienced user of Python types or someone who hasn’t yet written your first type annotation, participating in this survey helps the open-source community make Python better for everyone.
SURVEYMONKEY.COM • Shared by Abby Mitchell

Hydra for Python Configuration: Build Modular and Maintainable Pipelines

Hardcoded paths and parameters can quickly drift out of sync across a data science project’s scripts. This article shows how to move them into Hydra configuration files, access values with dot notation, override settings from the command line, swap entire configuration groups, and run experiment sweeps with a single multirun flag.
CODECUT.AI • Shared by Khuyen Tran

📘 Modern Object-Oriented Python: Write Clean, Pythonic Code With OOP

alt

Learn to design classes that feel native to Python: special methods, inheritance vs. composition, properties, data classes, and the SOLID principles. Get Your Copy →
REAL PYTHON sponsor

asyncio.all_tasks() Could Silently Drop Tasks on the Free-Threaded Build

A concrete example of how free-threading turns old GIL-era assumptions into silent bugs: asyncio.all_tasks() dropping live tasks from another thread. Useful for anyone porting or debugging code under the free-threaded build. Written by the author of the fix.
DEADLOVELLL.GITHUB.IO • Shared by Timofei Ivankov

Celery: From First Task to Advanced Recipes

Celery is a mature distributed task queue for Python, but many practical details aren’t immediately obvious from the documentation. This guide goes from the basic usage to timeouts, retries, preventing parallel execution, and emulating async/await support.
STANISLAV GOLEV • Shared by Stanislav Golev

Programmatically Developing LLM Prompts With DSPy

How can you move from manually writing prompts for an LLM application toward defining them programmatically? This week on the show, Brett Kennedy returns to discuss his new book “Building LLM Applications with DSPy.”
REAL PYTHON podcast

Nifty Django Feature: setUpTestData

The setUp() call in TestCase gets invoked for every single test. Django adds setUpTestData() which only gets called once per test class, meaning your tests can run faster.
TIM SCHILLING

Thoughts on “SIMD in Pure Python”

This article is a response to SIMD in Pure Python and discusses ways of optimizing his code even further.
ALISA SIRENEVA

Since When? Which Python Version Added That

An interesting tool where you paste Python into it to see which version added each feature it uses, or look up a single built-in, module, or piece of syntax.
PYTHONMORSELS.COM

Validating Data With Pointblank in Python

Learn how to validate data in Python with Pointblank: declare quality checks, split clean from failing rows, and rerun validation plans from YAML.
REAL PYTHON

Quiz: Validating Data With Pointblank in Python

REAL PYTHON

Acidica

Ned has written a toy BASIC interpreter in Python. This announcement post tells you why (“for fun”) and talks about the accomplishment.
NED BATCHELDER

Some More Things About Django I’ve Been Enjoying

Julia has been learning Django to create websites in “2010 style”. This post talks about new things she’s picked up along the way.
JULIA EVANS

Sending Emails Using Python

Learn how to send emails with Python using SMTP and smtplib, attach files, format HTML messages, and personalize bulk emails.
REAL PYTHON course

Quiz: Sending Emails Using Python

REAL PYTHON

Projects & Code

xy: Ultra-Fast and Customizable Python Charts

GITHUB.COM/REFLEX-DEV

autowt: A Better git Worktree Experience

GITHUB.COM/IRSKEP

vscode-marimo: marimo Vscode Extension

GITHUB.COM/MARIMO-TEAM

commerce: Opensource E-Commerce Platform

GITHUB.COM/SPWIG

Build and Inspect Python Packages in GitHub Actions

GITHUB.COM/HYNEK

Events

Weekly Real Python Office Hours Q&A (Virtual)

August 12, 2026
REALPYTHON.COM

Python Nordeste 2026 (PyNE 2026)

August 13 to August 16, 2026
PYTHONNORDESTE.ORG

Python Help: Problems to Solutions

August 13, 2026
NONE

Python Atlanta

August 13 to August 14, 2026
MEETUP.COM

PyCon Korea 2026

August 15 to August 18, 2026
PYCON.KR

DjangoCologne

August 18, 2026
MEETUP.COM

PyCon Ghana 2026

August 20 to August 23, 2026
PYCON.ORG

PyCon Latam 2026

August 20 to August 24, 2026
PYLATAM.ORG

PyCon JP 2026

August 21 to August 24, 2026
PYCON.JP


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

August 11, 2026 07:30 PM UTC

August 10, 2026


Brett Cannon

My nomination statement for the 2026 Python packaging council

I have decided to run for the inaugural/2026 Python packaging council (PPC). I will say I have the support of my employer (Microsoft) to do this, but they didn&apost ask me to and my usual thing that I would quit before I let any employer pressure me into doing anything I didn&apost agree with still stands.

I will admit that writing this was a little hard for me since it&aposs for the entire PSF membership (compared to the SC which is only Python core developers), and so I had to assume someone had no idea who I was (where with the core devs I have been around for so long that at the core dev sprint last year I was the 4th longest-serving member in attendance). As well, I&aposm not good at humblebragging, so I had to think about what to say, and in a way that didn&apost dismiss what I&aposve done like I typically do (as an example, I introduced myself at lunch at PyCon US once and someone at the table said, "we know who you are, Brett"; that was very flattering, humbling, and I still don&apost totally believe people who didn&apost just attend a talk I gave at that conference know who I am).

Anyway, here is a list of stuff I have done for Python packaging and some stuff I would like to see happen as I put in my self-nomination statement.

Qualifications

My qualifications for joining the council include:

More about me can be found on my blog, public notes, and GitHub profile.

Goals

Here are some high-level goals I have in mind for the PPC.

Setting up the inaugural PPC

Having served on the first 5 Python steering councils, I have a somewhat unique experience in knowing what can (not) end up working for councils such as the PPC. If I were to be elected, I would try to help my fellow PPC members learn from the SC&aposs experience.

Developer experience

There are two groups of users of packaging: producers and consumers.

For the people producing packages, I would want to help make the experience better. That includes having clearer specs with less edge cases and any new specs that would help ease packaging up some code. And hopefully making the process around specs easy enough that people are willing to bring up instances of where something should be updated.

For consumers, I would also like to see the experience improve. For example, part of why uv is so fast is it doesn&apost strictly follow the current specs (while pip always tries to follow the spec accurately). In those cases where uv doesn&apost follow a spec but has found it to work out, I think we should evaluate if there&aposs a change to be made so that pip can have an equivalent benefit.

A good example of this is that uv assumes all wheel files have the same metadata, while pip doesn&apost since the specs say the metadata can vary. If the specs could somehow be updated so you only had to check a single copy of release metadata, then pip doesn&apost have to check every wheel it considers when trying to determine what to install which takes time.

Secure supply chain

Unfortunately, there are bad people on the internet. And those bad people know there are a lot of Python developers, so they are trying to exploit Python projects for nefarious reasons. As such, I think we should do what we can to make things hard for these bad people while not adding a bunch of burden on those who are doing us all a service by sharing their code in the world (i.e. better security without sacrificing the developer experience).

There are two ways to thwart attackers: keep them out and prevent yourself from being exploited if there is vulnerable. One way to help keep attackers out is verifying files are legitimate. One possibility for this is to make getting reproducible builds easier, from source to wheel. This would require everything from code to help package up the bits in a reproducible way to metadata to be able to trace a wheel file back to its source code. This would let people be able to independently verify the files uploaded to PyPI were not tampered with between the source repository to uploading.

For preventing exploitation once some vulnerable code exists, one approach is software bills of material (SBOMs). If we could make it easy to have SBOMs for every step of the packaging process as well as for anything you install, it would make it easier to know when you may be running vulnerable code. This work was started with PEP 770 (which I was a PEP delegate on), but there are more opportunities to record more SBOMs (transparently) along more of the packaging process.

August 10, 2026 10:22 PM UTC


James Bennett

Breaking up (lines) is hard to do

Here’s a seemingly simple question: given a chunk of multi-line text, how do you split it and return an array whose members are the constituent lines of the text?

Hopefully, your first instinct is to reach for some sort of standard-library function, maybe something like the splitlines() method of Python’s str type. Because it turns out this “simple” question is actually pretty complex to answer! For example, quite some time ago I read a post by William Woodruff pointing out the surprising discovery that Python treats up to eleven different Unicode code points or code point sequences as indicating a line break.

At the time I meant to write about that, but a lot of other things started fighting for my time, and it’s only now that I’m finally digging it out of my drafts. Still, better late than never, so today let’s dig into some of the many ways there are to break a line of text and how they’ve been standardized and specified and ultimately wound up in the set Python uses.

In the beginning…

Once upon a time, there was ASCII. Of course there were other things before ASCII, and alongside ASCII, but for today’s discussion we really only need to go back to ASCII; if you want the full history of physical teletypes, how they evolved from typewriters and influenced character sets for computing and so on, I suggest Wikipedia. Here, I’m just going to gloss over and simplify a lot of that to focus on the topic at hand.

So. Once upon a time, there was ASCII. And it wound up being incredibly influential and important in computing, to an extent other early character sets couldn’t match. And because it was used on computers which used teletypes (basically electronic typewriters connected as input/output devices) as a user interface, it contained control characters for sending commands to the teletype. Such as a LINE FEED (byte value 0x0A) to advance the paper vertically to the next line, and a CARRIAGE RETURN (byte value 0x0D) to re-align the print head/carriage with the horizontal start point of the line.

These are often abbreviated LF and CR (or by their C-family escape sequences \n and \r, respectively), and you might think that since physically advancing a typewriter-style device to be ready to print the next line requires both operations, that would have just become the universal way everybody did new lines. Or at least the universal way everybody did them in English, or in the US, where ASCII dominated. Right?

Well, nothing is ever that simple. Physical teletypes apparently benefited from the two-character approach (as opposed to a single “new line” character) because it gave them time to physically move everything into the right position. But as virtual teletypes—“printing” to a television-like display instead of to paper—became more common, that was less of an issue. So there were multiple possible options for representing line breaks, and several of them showed up in historical systems. For example:

This meant “plain text” was not easily portable between these various systems, since none of them could agree on how to represent a line break. Which led to one of my all-time favorite programming jokes, in the infamous NOT the comp.text.sgml FAQ document:

Q. What’s an RE?

A. RE is an acronym for Record End, which is sort of like a newline, only different. Goldfarb’s First Law of Text Processing states that:

… if a text processing system has bugs, at least one of them will have to do with the handling of input line endings.”

[The Handbook, footnote p. 321]

The Record End concept was introduced to make sure that SGML parsers don’t violate Goldfarb’s First Law.

(for the uninitiated, Charles Goldfarb created SGML)

Anyway, over twenty years ago Python tried (in Python 2.3) to smooth this over by introducing “universal newline” mode for opening files, which accepts all three options: a plain \n (Unix), or a plain \r (classic Mac), or an \r\n sequence (DOS and Windows) will all be interpreted as line breaks.

But even in ASCII there there are other ways of breaking a line. For example, at byte value 0x0C ASCII includes the FORM FEED control character (FF, or \f). Which is not one of the traditional characters used by major operating systems as a “newline”, but nonetheless does cause a new line to occur: it moves to the next page (if necessary, by ejecting the current sheet of paper from the printer and feeding in a new one). And there’s also 0x0B, VERTICAL TAB (VT or \v): just as a “regular” tab (\t) causes a horizontal adjustment, a vertical tab causes a vertical one. So it, too, causes output to advance to another line (probably skipping several in the process).

And the C1 control characters added 0x85, the NEXT LINE character (typically abbreviated NEL), useful for translating back and forth between ASCII and IBM’s EBCDIC character set (which had “New Line” as a single character).

Then Unicode happened

Today we live in a Unicode world, and Unicode tries its hardest to catalog and standardize and describe how to work with all the world’s writing systems. Chapter 5, Section 8 of the Unicode Standard, “Newline Guidelines”, lists seven code points to recognize as causing new lines. Five of them we’ve seen already:

The CR LF sequence is also recognized, on systems which use it.

But the other two code points are new and were created specifically for Unicode:

The Unicode Standard explains that the traditional newline characters had started to become ambiguous, because of the rise of tools such as word-processing programs which implicitly broke lines to wrap them for display and so began using explicit “newline” characters to mean a paragraph break rather than a line break. So Unicode added two new code points whose purposes are explicit. And the standard says that “[I]n Unicode text, the PS and LS characters should be used wherever the desired function is unambiguous.”

This set of line-breaking code points originated in version 5.0 of Unicode, with Unicode Technical Report #13, which lists the seven “newline” code points and the CR LF sequence. This is also the set of code points and sequences defined for line boundaries in Unicode regular expressions, Unicode Technical Standard #18.

And expanding on Chapter 5 of the Standard, there’s Unicode Standard Annex #14, “Unicode Line Breaking Algorithm”. As the name implies, this document formally specifies the line-breaking algorithm for Unicode, including defining things like which characters offer an opportunity to break a line, whether the break is mandatory, and whether the break would come before or after the character in question. It does this in a typical Unicode way: by defining a set of named properties and specifying which characters have which properties.

Two ways about it

But there are still three “newline” characters supported by Python that we haven’t seen yet, and they come from a place that might be surprising: Unicode Standard Annex #9, the bidirectional algorithm. And it’s OK if you’re wondering what that has to do with newlines, because it’s not immediately obvious if you don’t already know about it.

Some written scripts, like the Latin script this blog post is written in, are written and read left-to-right: the start of a line of text is on the left-hand side, and the end is on the right-hand side. Other scripts, such as Arabic or Hebrew, do the opposite, and are right-to-left. And so Unicode, which again wants to cover all the world’s writing systems and let you use any or all of them, has to support both left-to-right and right-to-left horizontal text direction.

But more than that, it has to support switching direction within a single piece of text. You might have something that’s in, say, Arabic but quotes something in Spanish in the middle of a line; that would require a short section of left-to-right inside an otherwise right-to-left text. Or you might be writing something that uses boustrophedon, switching directions on each line. So Unicode includes direction-control characters like U+200E LEFT-TO-RIGHT MARK and U+200F RIGHT-TO-LEFT MARK to handle this. But it also needs to know the scope of a direction change, and that’s where the last “newline” characters come in: the Unicode bidirectional algorithm says that “[t]he effects of all of these formatting characters are limited to the current paragraph; thus, they are terminated by a paragraph separator”.

So Unicode characters have, among their properties, a “bidirectional class” which influences how they affect the bidirectional algorithm. And the characters which act as paragraph separators for purposes of ending the effects of an explicit directional marker all share a common value for this: bidirectional class B. The characters with that class include quite a few that we’ve already seen, along with three more characters:

But these are better known by their original ASCII names: FILE SEPARATOR, GROUP SEPARATOR, and RECORD SEPARATOR. ASCII provided these to help represent data structures in memory and on storage media. Today it’s not as common to try to use control characters for this purpose, though they do have the virtue of being rare in actual text, unlike other common delimiters such as tab or comma.

End of the line

And now, after looking at multiple character sets and five Unicode technical documents, we can finally state clearly what’s going on in Python.

Python’s splitlines() treats ten different code points, and one multi-code-point sequence, as causing a line break. These are:

Which is also exactly what’s stated by a comment in the CPython source code accompanying the list of individual code points that are considered to break lines, but hopefully now you have a better understanding of what that comment means and how this particular set was arrived at.

August 10, 2026 04:42 PM UTC


Talk Python to Me

#558: Hyper-Personal Software with Python

Every company has one. The little internal tool that Jane built back in 2021, and then Jane left. Nobody understands it, nobody will touch it. There are two unwritten rules around it: don't change it, it's working. And if you break it, you bought it. That's dark-matter enterprise software. <br/> <br/> For every app you can actually see, there are ten of these sitting in the shadows, frozen. Michael Booth thinks that just changed. He read my article on hyper-personal software and ran with it, writing about hyper-team software: small teams inside big companies finally building the tools that were never going to get built. <br/> <br/> We cover where this works, where it quietly goes wrong, and the guardrails that keep it from turning into a mess. Let's get into it.<br/> <br/> <strong>Episode sponsors</strong><br/> <br/> <a href='https://talkpython.fm/sentry'>Sentry Error Monitoring, Code talkpython26</a><br> <a href='https://talkpython.fm/devopsbook'>Python in Production</a><br> <a href='https://talkpython.fm/training'>Talk Python Courses</a><br/> <br/> <h2 class="links-heading mb-4">Links from the show</h2> <div><strong>Guest</strong><br/> <strong>Michael Booth</strong>: <a href="https://github.com/mjboothaus/?featured_on=talkpython" target="_blank" >github.com</a><br/> <br/> <strong>Talk Python AI Integrations</strong>: <a href="https://talkpython.fm/blog/posts/announcing-talk-python-ai-integrations/" target="_blank" >talkpython.fm/blog</a><br/> <br/> <strong>From Hyper-Personal to Hyper-Team Software: Small Team-Built, AI-Assisted Tools Inside the Enterprise</strong>: <a href="https://www.databooth.com.au/posts/hyper-team-software/?featured_on=talkpython" target="_blank" >www.databooth.com.au</a><br/> <br/> <strong>What hyper-personal software looks like (MK's article)</strong>: <a href="https://mkennedy.codes/posts/what-hyper-personal-software-looks-like/?featured_on=talkpython" target="_blank" >mkennedy.codes</a><br/> <br/> <strong>Databooth Site</strong>: <a href="https://www.databooth.com.au?featured_on=talkpython" target="_blank" >www.databooth.com.au</a><br/> <br/> <strong>Wall Street just lost $285 billion because of 13 markdown files</strong>: <a href="https://martinalderson.com/posts/wall-street-lost-285-billion-because-of-13-markdown-files/?featured_on=talkpython" target="_blank" >martinalderson.com</a><br/> <strong>SaaSpocalypse is real but everyone is panicking about the wrong thing</strong>: <a href="https://www.reddit.com/r/SaaS/comments/1rtszfp/saaspocalypse_is_real_but_everyone_is_panicking/?featured_on=talkpython" target="_blank" >www.reddit.com</a><br/> <strong>Warp Terminal</strong>: <a href="https://www.warp.dev?featured_on=talkpython" target="_blank" >www.warp.dev</a><br/> <br/> <strong>Watch this episode on YouTube</strong>: <a href="https://www.youtube.com/watch?v=rWSRsEBiyiE" target="_blank" >youtube.com</a><br/> <strong>Episode #558 deep-dive</strong>: <a href="https://talkpython.fm/episodes/show/558/hyper-personal-software-with-python#takeaways-anchor" target="_blank" >talkpython.fm/558</a><br/> <strong>Episode transcripts</strong>: <a href="https://talkpython.fm/episodes/transcript/558/hyper-personal-software-with-python" target="_blank" >talkpython.fm</a><br/> <br/> <strong>Theme Song: Developer Rap</strong><br/> <strong>🥁 Served in a Flask 🎸</strong>: <a href="https://talkpython.fm/flasksong" target="_blank" >talkpython.fm/flasksong</a><br/> <br/> <strong>---== Don't be a stranger ==---</strong><br/> <strong>YouTube</strong>: <a href="https://talkpython.fm/youtube" target="_blank" ><i class="fa-brands fa-youtube"></i> youtube.com/@talkpython</a><br/> <br/> <strong>Bluesky</strong>: <a href="https://bsky.app/profile/talkpython.fm" target="_blank" >@talkpython.fm</a><br/> <strong>Mastodon</strong>: <a href="https://fosstodon.org/web/@talkpython" target="_blank" ><i class="fa-brands fa-mastodon"></i> @talkpython@fosstodon.org</a><br/> <strong>X.com</strong>: <a href="https://x.com/talkpython" target="_blank" ><i class="fa-brands fa-twitter"></i> @talkpython</a><br/> <br/> <strong>Michael on Bluesky</strong>: <a href="https://bsky.app/profile/mkennedy.codes?featured_on=talkpython" target="_blank" >@mkennedy.codes</a><br/> <strong>Michael on Mastodon</strong>: <a href="https://fosstodon.org/web/@mkennedy" target="_blank" ><i class="fa-brands fa-mastodon"></i> @mkennedy@fosstodon.org</a><br/> <strong>Michael on X.com</strong>: <a href="https://x.com/mkennedy?featured_on=talkpython" target="_blank" ><i class="fa-brands fa-twitter"></i> @mkennedy</a><br/></div>

August 10, 2026 01:21 PM UTC


Ed Crewe

From Routing Checks to Trajectory Testing: Evaluating an Agentic Chatbot

pre[class*="language-"] { border-radius: 6px; font-size: 14px; overflow-x: auto; }

Which Agentic Chatbot?

I have been working on a Python based AI test framework for a chatbot interface for my company's product, Postgres AI Hybrid Manager. The manager allows the setup of Postgres clusters across cloud or on-prem and attaching various AI tools such as Langflow.  So a combination of more traditional Postgres backup, migration, telemetry and analytics features along with LLM workflows leveraging the data it holds.

The product already has a control plane UI for managing Postgres estates. It also has full help for the product, all Postgres versions, analytics, AI and add ons. The chatbot brings all these things together: ask a question, get the relevant help, or ask it to do something such as migrate a cluster, or evaluate telemetry that would otherwise require clicking through the UI.

That makes it a pretty handy interface, especially for the less technical. However it is not a simple to test and ensure good quality responses.

A normal deterministic API test is simple. Send a request, check the status code, check the JSON body, perhaps check the database state. An LLM-backed agent does not pass or fail so clearly. It can route to the wrong capability and still return fluent text. It can pick a plausible but wrong tool. It can miss half the task and still sound confident. It can complete the first turn of a conversation and lose the plot on the second. It could get malformed or missing data from tooling that leads it to deliver a misleading conclusion. It might only provide help to something that should be from tool data or was a request for an action such as create a cluster.

So the testing problem was not “does the chatbot return a reasonable response?” It was “how do we test the whole chat path is doing the right thing?”

This is the story of how our agent-eval test framework evolved as we worked to see that our chatbot was not only getting the right answer,  42 , but whether it was asking all the right questions of the right tools to get that answer. Known as trajectory testing ...


 You're Golden 

Before we can tell our story we need to define some terms.

A Golden is an example of a perfect desired output from a test input. They often refer to more complex outputs that may need saving as separate files, but a simple assertable output such as 42, is a golden too!
Whilst complex goldens may be used and marked for semantic similarity against the test output. It is more common for complex outputs to be described by a rubric. A rubric is a checklist of qualitative properties a good answer must exhibit, written in plain English as opposed to a golden example of an answer.

For AI testing the tests are termed evals, ie they evaluate the tool, but not by strict assertions, because one thing you can be sure of with an LLM is that given the same input, you usually get subtly different output, ie they are non-deterministic. Which means for LLM outputs the only way to test them is to use an LLM-as-judge,  ie give that LLM the test output and a rubric or golden and let it mark it against that. Then you set a pass threshold for that mark, to translate your complex output into a pass or fail.

You can also total up all the passes to give you a Task Completion Rate, TCR. So with complex AI agentic LLM interactions a 100% pass of all evals is often not realistic. Hence you set a TCR below 100% for the whole test suite of evals to pass. Start with the smallest useful test. The core principle of evals is not complicated, you want the input to give you the expected output.

But for an Agentic application this may require a sequence of LLM calls and tools: Making the final output dependent on the route that should be chosen, the tool(s) that should be called, the actions to be taken, further LLM calls that may be necessary and finally the core data that the response to the user should contain.

Our first version did not try to solve every part of that. It started with routing, simple and deterministic.

Routing is the starting point

The chatbot originally had an agent per tool. The tool being the code and API calls that performed actions or returned data or help.

Different specialist agents owned different parts of the product surface: Control-plane actions, Postgres database operations, schema design, roles and permissions, cluster reporting, migration, and so on.

Before any specialist can help, something has to choose the right specialist.

So the first eval suite asked a narrow question:

Given this user prompt, did the chatbot route to the expected tool?

That gave us a fast health check. We could keep a corpus of prompts, map each one to an expected destination, run them through either a direct model path or against the real deployment and its tools, and score whether the selected destination tool matched the golden.

 A golden here is just the name of the tool:

- id: "core-iam-001"
    prompt: "List all my projects"
    expected_tool: "control-plane"
    tags: ["core", "control-plane", "project"]

And the check on the other end is deliberately dumb — an equality test, not a semantic one:

self.success = tool_match(predicted_tool, expected_tool)


Agents became skills, but routing remained 

The design moved away from “one agent per tool family” toward a more consolidated orchestrating agent with skills.

That is a better fit for how modern agent systems are evolving. A skill = instructions, constraints, and a subset of tools that are relevant for a task. It is a form of progressive disclosure. Give the model the minium it needs at each step to save tokens.

But this did not make routing irrelevant.

Instead of asking “did we transfer to the right sub-agent?”, the eval asks “was the right skill made visible and selected for this task?” The labels changed but a skill could still use the wrong tool.

Routing evals stayed valuable because they were fast, explainable, and easy to run in CI. But they are limited, routing should always be correct but it doesn't mean that the final agent response is too.

TCR jumps to the endpoint, the response

Task Completion Rate, or TCR, was the next step.

The user asked for a cluster comparison, or a schema recommendation, or help diagnosing a database issue. We need to know whether the full response actually completed these tasks.

Responses are complex goldens so they need the LLM-as-a-judge pattern: run the chatbot, take the actual response, and ask a judge model to score it against expected sections.

The eval has a rubric here for judging the output:

- id: "tcr-core-014"
  prompt: "Compare CPU usage between these two clusters"
  expected_sections:
    - "identifies which cluster has higher CPU usage"
    - "cites at least one supporting metric"
    - "suggests a plausible next step"

The judge gets one simple instruction: score each expected_sections between 0.0–1.0 A metric class then just thresholds it for pass / fail:

self.success = score >= 0.7

The judge must be calibrated and a consistent model used for comparing runs over time. Enabling skill an prompt tuning from metric trends. The rubric must be specific enough to avoid marking waffle as success. But it turns a non-deterministic complex output into a simple pass and fail. It also separated two different levels of QA:

  • Can the underlying model answer the task if given the right context?
  • Does the deployed chatbot complete the task through the real product path?

That led to two execution modes.

Direct mode calls the model with simulated context. It is faster and useful for prompt and rubric development.

Proxy mode calls the real chatbot. It is slower, but it exercises the production path: routing, skill selection, tool calls, guardrails, streaming responses, conversation state, and the actual service wiring.

Both matter. Direct mode tells you whether the model is capable of the answer. Proxy mode tells you whether your product is capable of really delivering it via agents running your deployment's tools. 

This is the major difference from standard AI LLM testing, the model is only a small pluggable engine for the full agentic skill set that requires the actual deployment domain of data, actions and tools. Direct mode testing of only the model, is occasionally useful but E2E testing of the Chatbot deployment is required for agentic AI Chatbot QA, tuning and validation.   

Multi-step conversations changed the unit of testing

Single-turn TCR is still too small for many real chatbot tasks.

Users do not always provide all required information in one message. They ask to create a cluster, then pick a project, then choose a size, then confirm. They ask for a schema review, then refine the problem, then ask for a migration path. They troubleshoot by adding information over time.

So the framework has to exercise test cases that are conversations, not just single prompts.

That sounds like a minor data-model change. It was not. Once a test has steps, the eval runner has to preserve conversation state. In proxy mode, that means carrying the real conversation_id returned by the chatbot and sending each follow-up as part of the same server-side conversation. In direct mode, it means building a synthetic conversation history so the model sees the prior turns.

In code that split is about as literal as it sounds. Proxy mode threads a real id through each call:

response = client.send_message(prompt=msg, conversation_id=conversation_id)
conversation_id = response.conversation_id  # captured on turn 1, reused after

Direct mode has no server-side conversation to lean on, so it fakes one by re-rendering the transcript into the prompt itself, every turn:

full_prompt = f"## Conversation History\n{render(history)}\n\n{next_prompt}"

Same test case, same expected outcome, but a different code path depending on which half of the system is actually holding the conversation state. That's impacts multi-turn evals because conversation memory is part of the harness code for the actual deployment not just a model issue.

The scoring also becomes more interesting. You want per-step checks, because the assistant should ask the right clarifying question at the right time. You also want an overall score, because a conversation can have reasonable individual turns and still fail to complete the user's goal.

Coding it yourself: deepeval underneath

Everything above sits on top of deepeval, the open-source LLM eval library. We add a Synthesize → Execute → Evaluate pipeline, a plugin system, YAML goldens, CI wiring, and Langfuse push on top of it But the core library underneath is plain deepeval, and you do not need any of the surrounding machinery we used. Here are routing, TCR and multi-step just built directly on deepeval (simplified deepeval 3.6.9)

A test case is just an input/output pair. LLMTestCase is the base unit everything else scores:

from deepeval.test_case import LLMTestCase

test_case = LLMTestCase(
    input="List all my projects",
    actual_output=chatbot_response_text,       # what the system under test said
    expected_output="control-plane",                  # the golden - a skill label here, not prose
    additional_metadata={"predicted_skill": predicted_skill},
)

Routing is a custom metric, not a built-in one. deepeval ships plenty of semantic metrics, but “did it route to the right skill” is an exact-match business rule, so you write your own BaseMetric. This is a simplified version of the same shape our real AgentMatch metric takes:

from deepeval.metrics import BaseMetric
from deepeval.test_case import LLMTestCase

class AgentMatch(BaseMetric):
    def __init__(self, threshold: float = 1.0):
        self.threshold = threshold
        self.async_mode = False  # routing checks are cheap; no need for async here

    def measure(self, test_case: LLMTestCase) -> float:
        predicted = test_case.additional_metadata["predicted_skill"]
        expected = test_case.expected_output
        self.score = 1.0 if tool_match(predicted, expected) else 0.0
        self.success = self.score >= self.threshold
        return self.score

    async def a_measure(self, test_case: LLMTestCase) -> float:
        return self.measure(test_case)

    def is_successful(self) -> bool:
        return bool(self.success)

    @property
    def __name__(self):
        return "Agent Match"

tool_match is the check from earlier. Run it with deepeval's own runner rather than hand-rolled assertions, and you get retries, pretty output, and a result object for free:

from deepeval import evaluate

evaluate(test_cases=[test_case], metrics=[AgentMatch()])

TCR is where deepeval's built-in GEval earns its keep. GEval is deepeval's off-the-shelf LLM-as-judge metric, you give it criteria (or explicit evaluation steps) and it handles the judge prompt, the JSON parsing, and the scoring for you. Our rubric-per-line expected_sections maps onto evaluation_steps almost directly:

from deepeval.metrics import GEval
from deepeval.test_case import LLMTestCase, LLMTestCaseParams

task_completion = GEval(
    name="TaskCompletion",
    evaluation_steps=[
        "Check whether the response identifies which cluster has higher CPU usage",
        "Check whether the response cites at least one supporting metric",
        "Check whether the response suggests a plausible next step",
    ],
    evaluation_params=[LLMTestCaseParams.INPUT, LLMTestCaseParams.ACTUAL_OUTPUT],
    threshold=0.7,
)

test_case = LLMTestCase(
    input="Compare CPU usage between these two clusters",
    actual_output=chatbot_response_text,
)

evaluate(test_cases=[test_case], metrics=[task_completion])

Multi-step conversations get their own test case type. ConversationalTestCase takes a list of Turns instead of a single input/output pair, and pairs with a BaseConversationalMetric instead of BaseMetric:

from deepeval.test_case import ConversationalTestCase, Turn

convo = ConversationalTestCase(
    turns=[
        Turn(role="user", content="Create a new cluster"),
        Turn(role="assistant", content="Sure - which project should it go in?"),
        Turn(role="user", content="acme-prod"),
        Turn(role="assistant", content=final_response_text),
    ],
    expected_outcome="A cluster is created in acme-prod after resolving the missing project name",
)

deepeval has a conversational counterpart to GEval too (ConversationalGEval), scored against the whole turn sequence rather than a single response which is the natural fit for “did the assistant ask the right clarifying question at the right time”, the per-step-plus-overall shape TCR needed once prompts became conversations.

Put together, that is the whole starting kit: LLMTestCase plus a hand-written BaseMetric for hard business rules like routing, GEval for rubric-style task completion, ConversationalTestCase plus ConversationalGEval once a prompt becomes a conversation, and evaluate() to run the lot and get a result object back.

Everything else we built, the YAML goldens, the plugin architecture, the CI wiring, the Langfuse push exists to run more of these at scale and make the failures easy to find. But none of it is required to get started. If you are testing your own agentic chatbot, this is how to begin.

This is where instrumentation started to matter much more.

For a single-turn answer, a markdown report with pass/fail rows is often enough to start debugging. For multi-step conversations, that is thin. You need to know which turn failed, whether the route changed, whether the wrong tool was called, whether the tool call used correct arguments, whether the model forgot earlier context, or whether the final answer simply missed a required section.

That is why we added span-level telemetry and pushed eval traces into Langfuse.

Langfuse made the failures inspectable

The useful thing about Langfuse is not just having another pretty dashboard. Although that is important for spotting quality regressions over time via regular CI/CD automated runs.

The vital thing was being able to treat an eval run as a set of traces. A run becomes a session. Each test case becomes a trace. The trace carries the prompt, response, scores, tags, model, mode, scenario, and the spans emitted by the proxy.

For a chatbot path, those spans are where the debugging starts. You can see routing, tool execution, LLM calls, latency, and token usage where it is available. You can filter by scenario and model. You can compare runs. You can look at a failing conversation and see whether the problem began at route selection, tool selection, tool arguments, or final synthesis.

That changes the tuning loop.



Without traces, an eval failure says “this case failed”. With traces, it can say why it failed.

That distinction matters because the fix lands in different places...
Is it a routing rule?
Is it a skill description?
Is it a tool schema?
Is it the judge rubric?
Is it that the eval has has an expectation that the product has never actually promised?

 Trajectory testing ->  knitted the pieces together

Routing and TCR started as separate signals.

Routing asked whether the right capability was selected. TCR asked whether the final task was completed. Multi-step testing asked whether that held across a conversation. Instrumentation showed what happened between those points.

Trajectory testing is the next natural step: score the path itself.

For an agentic product, the fully correct path is essential to response quality.
So trajectory tests add expectations about intermediate actions:

  • which tool or flow should be used
  • whether the arguments are valid
  • whether the conversation reached the right state
  • whether the final answer completed the task

The label-based routing tests are still useful as fast canaries. They tell us whether the classifier shape has drifted and distinguish tiers - see the next section.
But full trajectory tests judge the route by consequence: did the system actually follow the tool path that would satisfy the user?

So retain the fast determisitc routing tests, but move more user-visible behavioural coverage into trajectory and TCR.

Sovereign AI makes the eval problem tiered S/M/L/XL

There is one more constraint that makes this more than a generic chatbot-testing story.

Our chatbot has to work for sovereign and air-gapped deployments. In those environments, prompts, tool results, schema details, and operational data cannot be sent to a hosted frontier model outside the customer's trust boundary. The inference model may run inside the customer's environment.

That usually means a smaller model.

Smaller models are not just cheaper versions of larger ones. They have different context limits, weaker tool-selection behaviour, and less tolerance for an over-wide capability surface. If you show a smaller model every possible tool and skill, you have increased the chance that it chooses a bad one.

So the architecture becomes tiered. Models are effectively T-shirt sized. A small self-hosted model sees a curated subset of reliable skills. A larger model can be allowed to see more. Some experimental or complex skills only make sense for the highest tiers.

That changes the meaning of a routing eval again.

The correct visible skill set is no longer universal. It depends on the model tier. A prompt that should route to an advanced skill for an XL model may need to be dropped, refused, or handled differently for a smaller model that should not see that skill at all.

This is why trajectory testing and routing need to be tier-aware. We are not only asking whether the chatbot can complete a task. We are asking whether it can complete the task through the capability surface that a deployment's LLM size allows.

What I would keep from the journey

The final shape was not obvious at the start.

We began with routing because it was the first integration failure point and the cheapest one to isolate. We added TCR because correct routing did not prove task completion. We added multi-step cases because real users have conversations, not isolated prompts. We added telemetry because multi-step failures are otherwise too hard to debug. We moved toward trajectory testing because the route, tools, arguments, and answer need to be judged as one path.

If I were starting another agentic product eval framework, I would keep that order.

Do not start by trying to build a grand universal benchmark. Start with the smallest failure point that would embarrass the product if it regressed. Then move the signal closer to the user's actual goal.

For a chatbot wired into a real control plane, that means testing more than the output text. It means testing the route, the skill, the tool call, the arguments, the conversation state, the final answer, and the model tier that made those options visible in the first place.

That is the difference between checking that an AI system said something vaguely relevant and checking that it actually did all the things the user asked of it.

August 10, 2026 10:37 AM UTC


Django Weblog

Django is moving to an annual release cycle

Django's Steering Council has accepted the Django Enhancement Proposal DEP 20 to move Django to an annual release cycle. From January 2028, Django will make one feature release a year, giving every feature release the LTS-level three years of support, and version numbers will carry the feature release year: Django 2028, then Django 2029, and so on.

Better Python support

Python releases annually, each October. Django's eight-month cycle hasn't fit that well: LTS releases carried a wide Python matrix, including versions long past their upstream end-of-life.

Under the new cycle, each Django version supports the three latest Python versions at release, and picks up the new Python version during its first year. Django's support window ends in step with its oldest supported Python.

Every release is an LTS

Every feature release gets three years of support: one year of mainstream bugfixes, then two years of security and data-loss fixes. The "LTS" label is retired — every feature release now carries that same, unique commitment.

No more LTS gap: no racing a deadline to jump two years of changes at once. Upgrade one year at a time, whenever suits you within the support window. Three versions are supported at any time, giving third-party packages a clear, rolling target.

API stability and deprecation policies are unchanged — deprecation periods actually get longer in calendar terms.

Transition timeline

Django 2028 will be the first release under the new cycle.

Release Date End of life
Django 6.1 August 2026 December 2027
Django 6.2 LTS April 2027 April 2030
Django 2028 January 2028 December 2030
Django 2029 January 2029 December 2031

Nothing changes before 2028. Support commitments for Django 5.2 LTS and 6.2 LTS stand as made.

Read the DEP

DEP 20 has the full specification and the reasoning behind each decision. Thanks to everyone who took part in the discussion, and to the Steering Council for its consideration.

August 10, 2026 09:00 AM UTC

August 09, 2026


Ned Batchelder

Caller-specific coverage

I’ve had an idea rattling around to get more detail from coverage measurement. Can we measure the coverage in a function separately for each caller of the function?

Here’s why I want it: in Acidica, my toy BASIC interpreter, I had code to implement the built-in functions that looked something like this:

match func_name:


    case "LEN":
        if len(args) != 1:
            raise TypeError(f"Wrong arguments for LEN, got {len(args)}")
        return len(args[0])

    case "LEFT$":
        if len(args) != 2:
            raise TypeError(f"Wrong arguments for LEFT$, got {len(args)}")
        return args[0][:args[1]]

    # ... 19 other built-ins ...

I didn’t like the repeated code here: each different func_name has to check that it got its expected number of arguments and perhaps raise an error. So I refactored:

def expects(nargs: int, func_name: str, args: tuple) -> None:

    if len(args) != nargs:
        raise TypeError(f"Wrong arguments for {func_name}, got {len(args)}")

match func_name:
    case "LEN":
        expects(1, func_name, args)
        return len(args[0])

    case "LEFT$":
        expects(2, func_name, args)
        return args[0][:args[1]]

Nice. The code is tighter, easier to read, and common behavior is implemented in one place.

But the old code had an advantage: because each error condition had its own raise line, coverage measurement could tell me whether I had tested every func_name for the wrong number of arguments. With the error handling happening in a helper function, that information is lost. I’ll know that some func_name had a test for the wrong number of arguments, but not that all of them did.

Here’s where the new idea comes in. What if I could indicate that for the expects function, I want separate coverage data for each distinct calling site? Then I could see that every func_name had a test for both the wrong number of arguments and the right number of arguments. The simple branch inside expects would be measured separately for each caller.

I have a quick proof-of-concept. A decorator on expects does the work. Coverage.py already has dynamic contexts which are used for things like tracking which tests called which code. The decorator starts a new context named for the calling location, then restores the context when the function returns:

def coverage_per_caller(func):

    @functools.wraps(func)
    def _wrapper(*args, **kwargs):
        cov = coverage.Coverage.current()
        name = func.__name__
        caller = inspect.currentframe().f_back
        file = caller.f_code.co_filename
        lineno = caller.f_lineno
        prev_context = cov.switch_context(f"per_caller:{name}:{file}:{lineno}")
        try:
            ret = func(*args, **kwargs)
        finally:
            cov.switch_context(prev_context)
        return ret

    return _wrapper

I had to make one tiny (unreleased) change to coverage.py for this: switch_context used to return None, but now it returns the previous context so that we can nest them properly.

To my delight, this works! I can look at the HTML coverage report and see the caller contexts for the lines in expects. I can see that 20 callers ran the if line, but only 2 ran the raise, and the context names show the file and line number of the callers for each:

HTML report showing the contexts that ran each line of expects()

This isn’t the whole solution yet. Things to improve:

But it’s a start, and gives me other ideas. I could use some aspect of the data passed into a function as the context name. In this example, we could have used func_name as the context instead of the caller’s location. Maybe you have ideas for other uses.

August 09, 2026 05:56 PM UTC


LernerPython blog, from Reuven Lerner

Free real-world Pandas exercises, with solutions

If you want to get better at Pandas, the hard part isn’t finding tutorials. It’s finding problems worth solving. Most exercises hand you a tidy little table of five rows and ask you to sum a column — which teaches you the syntax, but nothing about the job.

For the last 3.5 years, I’ve written Bamboo Weekly, a weekly set of Pandas exercises built on real, current, public data: coal plants, earthquakes, Netflix viewing hours, government corruption indices, and IPO filings, among many others. Real data, which means the columns are named badly, the dates are strings, and answering the interesting questions takes four steps rather than one.

As of this week, every issue older than two years is free — no signup, no subscription. That’s issues #1 through #78, with 155 posts, and more than 500 exercises with fully worked-out solutions. Another opens every week as it passes its second birthday.

Why real data changes what you learn

A toy dataset teaches you groupby. A real one teaches you that the column is a string when you expected a number, that three rows have a country name nobody standardised, and that observed=True changes your answer.

Here is an actual example from the archive — the Global Coal Plant Tracker, one row per generating unit, asking which countries emit the most CO2 from coal:

import pandas as pd

url = ('https://www.bambooweekly.com/content/files/wp-content/uploads/2024/02/'
       'global-coal-plant-tracker-january-2024.xlsx')

(
    pd.read_excel(url, sheet_name='Units',
                  usecols=['Country', 'Annual CO2 (million tonnes / annum)'])
    .groupby('Country')
    ['Annual CO2 (million tonnes / annum)']
    .sum()
    .sort_values(ascending=False)
    .head(3)
)
Country
China             10091.0
India              3941.4
United States      1999.9

Four methods, one question, and every step is one you would actually use at work. That is the whole idea. You improve your data-analysis muscle memory with Bamboo Weekly, and then you’re ready to tackle problems at work with greater confidence.

Method guides, with the mistakes people actually make

Alongside the exercises, I’ve written up 16 of the Pandas methods that come up most often. Each one covers what the method does, the argument forms worth knowing, a worked example on a real dataset you can load from the URL in the code, and the mistakes that catch people — all verified against Pandas 3.

Reading data

Selecting and filtering

Reshaping and transforming

Grouping and aggregating

Dates and times

Method chaining

If you are upgrading to Pandas 3, two of those are worth reading first. pd.col replaces most of the lambdas in your chains, and resample will break your code outright: 'M', 'Y', 'T', 'H' and 'S' no longer warn, they raise ValueError.

Try one without installing anything

Each method guide links to a matching exercise on the LernerPython practice system, which runs entirely in the browser. No installation, no signup, no account.

Where to start

Pick a method you use constantly and read its page. You will probably recognize one or more of the mistakes, and see how your code can be cleaner, clearer, and more efficient. Or open the archive to an interesting issue, and try the questions before reading the solutions. Peeking at the answers before you’ve tried your hand at solving the problem yourself is harder, but it also teaches you more.

New issues go out every week, and the two-year-old ones keep opening up behind them.

The post Free real-world Pandas exercises, with solutions appeared first on LernerPython.

August 09, 2026 02:12 PM UTC

August 06, 2026


Django Weblog

Call for applicants for a Django Executive Director

The Django Software Foundation is announcing a call for an Executive Director. The Executive Director is the operational leader of the Django Software Foundation, a paid position reporting to the Board of Directors, responsible for setting the Foundation's strategic direction and turning it into action, while managing day-to-day operations. They serve as the main connector between the Board, staff, community, and sponsors.

The Django Software Foundation (DSF) is a 501(c)(3) nonprofit that develops and maintains Django, a free and open-source web application framework. The Foundation exists to support the development of Django by sponsoring sprints, meetups, gatherings and community events; to promote the use of Django among the web development community; to protect the framework's intellectual property and long-term viability; and to advance the state of the art in web development.

This is a new role for the Foundation. Django itself has been around since 2005, but the DSF wasn't founded until 2008, and the person who takes on this role will play a key part in maturing the Foundation's internal structure, helping ensure the DSF can properly support and sustain this important ecosystem going forward. The position is initially for a period of one year, renewable subject to an annual performance evaluation. Depending on the candidate, the role may be part-time or full-time.

Beyond running the Foundation, the Executive Director is a representative of the DSF itself. They embody Django's welcoming culture and help the community sustain the framework's home. The Executive Director is often called upon to represent the Foundation publicly, including at Django conferences and events, and to grow awareness of the DSF as an organization, distinct from the framework it supports.

Responsibilities

Executive Director duties include (but are not limited to):

Requirements

An Executive Director is responsible for fundraising, operations, communications, and community coordination. This is a broad remit, and it isn't our expectation that you come into the job an expert in every part of it. We hope you'll have solid experience in a few of these areas, particularly the ones most central to the role (fundraising and partnership development, nonprofit operations, and stakeholder communication). A willingness to learn and a demonstrated history of doing so are more important than comprehensive knowledge.

The areas you can expect to work across include (and are not limited to):

And required professional skills such as:

Therefore, a Django Executive Director requires the skills and judgment of an experienced nonprofit leader who is comfortable with fundraising, operations, and coordination with community stakeholders. Open-source experience and familiarity with the Django or Python community in particular are a big plus.

Being part of the Django community isn't a prerequisite for this position. We'll consider applications from anyone with a proven history of nonprofit leadership or comparable experience in an open-source or mission-driven community, but this is a remote position based in the United States, and unfortunately we are not able to offer visa sponsorship for this role.

The DSF is an equal opportunity employer. We welcome applicants of every background and don't discriminate on the basis of race, color, religion, gender, gender identity or expression, sexual orientation, national origin, disability, age, or veteran status.

How to apply

If you're interested in applying for the position, please submit your application via hiring@djangoproject.com. Your application should include:

References may be requested during the interview process.

The compensation for this role is a base salary of $90,000 to $120,000, plus a bonus of up to $60,000 tied to our progress toward the $500,000 fundraising goal, which we'll tier as we work toward it. Depending on the candidate, the DSF will consider a part-time position and adjust the salary accordingly.

Applicants will be evaluated based on the following criteria:

Applications will be open until midnight Central Time, September 14, 2026, with the expectation that the successful candidate will start around November 1, 2026 (to be confirmed).

Reference: Announcing the Search for a DSF Executive Director (Django Project blog, June 17, 2026).

August 06, 2026 02:45 PM UTC


Hynek Schlawack

Production-ready Python Docker Containers with uv

Starting with 0.3.0, Astral’s uv brought many great features, including support for cross-platform lock files uv.lock. Together with subsequent fixes, it has become Python’s finest workflow tool for my (non-scientific) use cases. Here’s how I build production-ready containers, as fast as possible.

August 06, 2026 12:00 AM UTC

August 05, 2026


Django Weblog

Django 6.1 released

The Django team is happy to announce the release of Django 6.1.

The release notes offer a harmonious mélange of new features and usability improvements. A few highlights are:

You can get Django 6.1 from our downloads page or from the Python Package Index.

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

With the release of Django 6.1, Django 6.0 has reached the end of mainstream support. The final minor bug fix release, 6.0.8, which was also a security release, was issued yesterday, Aug. 4, 2026. Django 6.0 will receive security and data loss fixes until April 2027. All users are encouraged to upgrade before then to continue receiving fixes for security issues.

See the downloads page for a table of supported versions and the future release schedule.

August 05, 2026 07:30 PM UTC


Tryton News

Security Release for issue #14947

Cédric Krier has discovered that Tryton does not prevent weasyprint to access local files when rendering HTML report to PDF.

Impact

CVSS v3.0 Base Score: 4.9

Workaround

There is no workaround.

Resolution

All affected users should upgrade trytond to the latest version.

Affected versions per series:

Not affected versions per series:

Some custom reports may fail after the upgrade because they are using local files. Such reports must be updated to use only files via public HTTP.

Reference

Concerns?

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

1 post - 1 participant

Read full topic

August 05, 2026 06:00 AM UTC


Python GUIs

Handling Image Drag and Drop from Web Browsers in PyQt6 — Why toLocalFile() returns an empty string and how to handle remote image drops correctly

When dragging and dropping images from a web browser into a PyQt6 rich text editor, toLocalFile() sometimes returns a blank string. It works for some images (like Google image search results) but fails for others (like images embedded directly on a webpage). Why does this happen, and how can I handle it?

If you've added drag and drop support to your application, you may have noticed something frustrating: dragging an image from a browser sometimes works perfectly, and other times gives you nothing at all. The toLocalFile() method returns an empty string, and your image never appears.

This comes down to how browsers package image data when you start a drag operation, and what your application expects to receive. Let's walk through what's happening and how to fix it.

How drag and drop MIME data works

When you drag something — a file, an image, some text — the source application bundles that data into a QMimeData object. This object can contain several different formats at once. For example, dragging an image might include:

Which of these formats are included depends entirely on the source application. Different browsers, and even different types of images within the same browser, behave differently.

Why toLocalFile() returns an empty string

The method QUrl.toLocalFile() converts a URL into a local filesystem path. It only works when the URL uses the file:// scheme — meaning the file actually exists on your computer.

When you drag an image from a Google image search, the browser often creates a temporary local file and provides a file:// URL. That's why toLocalFile() works in that case.

But when you drag an image that's embedded directly in a webpage (like a screenshot in a blog post), the browser typically provides a remote http:// or https:// URL instead. There's no local file, so toLocalFile() returns an empty string. Some browsers may also provide the image as inline data or an HTML fragment with no URL at all.

Inspecting what the browser actually sends

A good first step is to look at exactly what MIME data arrives when you drop something. This small example creates a drop target that prints out all available MIME formats and their contents:

python
import sys

from PyQt6.QtCore import Qt
from PyQt6.QtWidgets import QApplication, QLabel, QVBoxLayout, QWidget


class DropInspector(QLabel):
    def __init__(self):
        super().__init__("Drop something here")
        self.setAlignment(Qt.AlignmentFlag.AlignCenter)
        self.setMinimumSize(400, 300)
        self.setStyleSheet(
            "background-color: #f0f0f0; border: 2px dashed #aaa; font-size: 16px;"
        )
        self.setAcceptDrops(True)

    def dragEnterEvent(self, event):
        event.acceptProposedAction()

    def dropEvent(self, event):
        mime_data = event.mimeData()
        print("=== Drop received ===")
        for fmt in mime_data.formats():
            data = mime_data.data(fmt)
            print(f"\nFormat: {fmt}")
            # Show first 200 bytes as text for readability.
            try:
                print(f"  Data: {bytes(data[:200]).decode('utf-8', errors='replace')}")
            except Exception:
                print(f"  Data: ({len(data)} bytes, binary)")

        if mime_data.hasUrls():
            for url in mime_data.urls():
                print(f"\nURL: {url.toString()}")
                print(f"  toLocalFile: '{url.toLocalFile()}'")
                print(f"  scheme: '{url.scheme()}'")

        event.acceptProposedAction()
        self.setText("Check console output!")


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

Try dragging different images from your browser into this window. You'll see that some drops include file:// URLs while others include https:// URLs or even raw image data with no URL at all.

Handling all cases in your drop event

To make your application work reliably with images dragged from any source, you need to handle multiple scenarios:

  1. Local file URL — use the file path directly
  2. Remote URL — download the image
  3. Raw image data — use it directly from the MIME data
  4. HTML with an <img> tag — extract the image URL from the HTML

Here's how to implement this step by step.

Checking for local files first

This is the simplest case and the one you likely already have working:

python
def dropEvent(self, event):
    mime_data = event.mimeData()

    if mime_data.hasUrls():
        for url in mime_data.urls():
            local_path = url.toLocalFile()
            if local_path:
                # It's a local file — use it directly.
                self.insert_image_from_path(local_path)
                event.acceptProposedAction()
                return

Handling remote URLs

When toLocalFile() returns an empty string but you still have a URL, it's likely a remote image. You can download it using Python's urllib (or requests if you prefer):

python
import os
import tempfile
import urllib.request


def download_image(url_string):
    """Download an image from a URL and return the local file path."""
    try:
        # Create a temporary file to store the downloaded image.
        suffix = os.path.splitext(url_string)[-1].split("?")[0]
        if suffix not in (".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp"):
            suffix = ".png"
        tmp_file = tempfile.NamedTemporaryFile(delete=False, suffix=suffix)
        urllib.request.urlretrieve(url_string, tmp_file.name)
        return tmp_file.name
    except Exception as e:
        print(f"Failed to download image: {e}")
        return None

Then extend your drop handler:

python
if mime_data.hasUrls():
    for url in mime_data.urls():
        local_path = url.toLocalFile()
        if local_path:
            self.insert_image_from_path(local_path)
            event.acceptProposedAction()
            return

        # No local file — try downloading the remote URL.
        url_string = url.toString()
        if url_string:
            local_path = download_image(url_string)
            if local_path:
                self.insert_image_from_path(local_path)
                event.acceptProposedAction()
                return

Handling raw image data

Sometimes the browser sends the image data directly, without any URL. You can check for this using hasImage():

python
if mime_data.hasImage():
    image = mime_data.imageData()
    if image and not image.isNull():
        # Save the image to a temp file and insert it.
        tmp_path = tempfile.NamedTemporaryFile(
            delete=False, suffix=".png"
        ).name
        image.save(tmp_path)
        self.insert_image_from_path(tmp_path)
        event.acceptProposedAction()
        return

Extracting URLs from HTML

As a fallback, some drops include an HTML fragment with an <img> tag. You can parse out the src attribute:

python
import re


def extract_image_url_from_html(html):
    """Extract the first image URL from an HTML string."""
    match = re.search(r'<img[^>]+src=["\']([^"\']+)["\']', html)
    if match:
        return match.group(1)
    return None

Then add this as a final fallback:

python
if mime_data.hasHtml():
    html = mime_data.html()
    image_url = extract_image_url_from_html(html)
    if image_url:
        local_path = download_image(image_url)
        if local_path:
            self.insert_image_from_path(local_path)
            event.acceptProposedAction()
            return

Complete working example

Here's a full, working rich text editor with robust image drag and drop support. You can copy this and run it directly:

python
import os
import re
import sys
import tempfile
import urllib.request

from PyQt6.QtCore import Qt
from PyQt6.QtGui import QImage, QTextCursor
from PyQt6.QtWidgets import QApplication, QMainWindow, QTextEdit, QVBoxLayout, QWidget


def download_image(url_string):
    """Download an image from a URL and return the local file path."""
    try:
        suffix = os.path.splitext(url_string.split("?")[0])[-1]
        if suffix.lower() not in (".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp"):
            suffix = ".png"
        tmp_file = tempfile.NamedTemporaryFile(delete=False, suffix=suffix)
        urllib.request.urlretrieve(url_string, tmp_file.name)
        return tmp_file.name
    except Exception as e:
        print(f"Failed to download image: {e}")
        return None


def extract_image_url_from_html(html):
    """Extract the first image URL from an HTML string."""
    match = re.search(r'<img[^>]+src=["\']([^"\']+)["\']', html)
    if match:
        return match.group(1)
    return None


class ImageDropTextEdit(QTextEdit):
    def __init__(self):
        super().__init__()
        self.setAcceptDrops(True)

    def canInsertFromMimeData(self, source):
        if source.hasImage() or source.hasUrls() or source.hasHtml():
            return True
        return super().canInsertFromMimeData(source)

    def insertFromMimeData(self, source):
        """Handle paste and drop events with image support."""
        # Try each method in order of reliability.

        # 1. Check for direct image data.
        if source.hasImage():
            image = source.imageData()
            if isinstance(image, QImage) and not image.isNull():
                self.insert_image(image)
                return

        # 2. Check for URLs (local or remote).
        if source.hasUrls():
            for url in source.urls():
                local_path = url.toLocalFile()
                if local_path and self.is_image_file(local_path):
                    self.insert_image_from_path(local_path)
                    return

                # Try downloading remote URL.
                url_string = url.toString()
                if url_string and self.looks_like_image_url(url_string):
                    local_path = download_image(url_string)
                    if local_path:
                        self.insert_image_from_path(local_path)
                        return

        # 3. Check for HTML with embedded image tags.
        if source.hasHtml():
            image_url = extract_image_url_from_html(source.html())
            if image_url:
                if image_url.startswith("data:"):
                    # Data URI — decode and insert.
                    image = self.image_from_data_uri(image_url)
                    if image and not image.isNull():
                        self.insert_image(image)
                        return
                else:
                    local_path = download_image(image_url)
                    if local_path:
                        self.insert_image_from_path(local_path)
                        return

        # Fall back to default behavior for plain text, etc.
        super().insertFromMimeData(source)

    def insert_image_from_path(self, file_path):
        """Insert an image from a local file path into the editor."""
        image = QImage(file_path)
        if image.isNull():
            print(f"Could not load image: {file_path}")
            return
        self.insert_image(image)

    def insert_image(self, image):
        """Insert a QImage into the editor at the current cursor position."""
        cursor = self.textCursor()
        document = self.document()

        # Add the image as a resource in the document.
        image_name = f"dropped_image_{id(image)}"
        document.addResource(
            document.ResourceType.ImageResource.value,
            self.create_url(image_name),
            image,
        )

        # Insert the image at the cursor.
        image_format = cursor.charFormat()
        from PyQt6.QtGui import QTextImageFormat

        img_fmt = QTextImageFormat()
        img_fmt.setName(image_name)
        img_fmt.setWidth(min(image.width(), 600))
        img_fmt.setHeight(
            int(image.height() * min(image.width(), 600) / max(image.width(), 1))
        )
        cursor.insertImage(img_fmt)

    @staticmethod
    def create_url(name):
        from PyQt6.QtCore import QUrl

        return QUrl(name)

    @staticmethod
    def is_image_file(path):
        extensions = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".svg"}
        return os.path.splitext(path.lower())[-1] in extensions

    @staticmethod
    def looks_like_image_url(url_string):
        """Check if a URL looks like it points to an image."""
        clean_url = url_string.split("?")[0].lower()
        extensions = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".svg"}
        return any(clean_url.endswith(ext) for ext in extensions)

    @staticmethod
    def image_from_data_uri(data_uri):
        """Decode a data: URI and return a QImage."""
        import base64

        try:
            # data:image/png;base64,iVBOR...
            header, data = data_uri.split(",", 1)
            image_data = base64.b64decode(data)
            image = QImage()
            image.loadFromData(image_data)
            return image
        except Exception as e:
            print(f"Failed to decode data URI: {e}")
            return None


class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("Rich Text Editor — Image Drop Demo")
        self.setMinimumSize(700, 500)

        self.editor = ImageDropTextEdit()
        self.editor.setPlaceholderText(
            "Try dragging an image from your web browser into this editor..."
        )

        layout = QVBoxLayout()
        layout.addWidget(self.editor)

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


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

What's happening in the complete example

The ImageDropTextEdit class overrides insertFromMimeData, which Qt calls for both paste (Ctrl+V) and drag-and-drop operations. This gives you a single place to handle all image insertion.

The method tries each data source in order:

  1. Direct image data — the fastest and most reliable, when available.
  2. URLs — first checking for local files, then attempting to download remote URLs.
  3. HTML fragments — parsing out <img> tags and fetching the referenced image, including support for data: URIs.
  4. Fallback — if none of the above match, it passes control to the default QTextEdit behavior, so normal text paste and drop still work.

By overriding canInsertFromMimeData as well, we tell Qt's drag and drop system that our editor accepts these additional formats, which ensures the correct cursor icon appears when hovering over the editor.

This approach handles the differences between browsers — Chrome, Firefox, Edge — and between different types of images on the web, making your rich text editor's drag and drop support much more resilient.

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

August 05, 2026 06:00 AM UTC


Core Dispatch

Core Dispatch #9

Welcome back to Core Dispatch! This edition covers July 18 through August 5, 2026. Python 3.15.0 release candidate 1 landed on August 4, followed by Python 3.14.7 and 3.13.15 on August 5. With the first release candidate here, 3.15 is firmly in the home stretch.

Two new PEPs joined the queue this fortnight. PEP 842 proposes explicit module exports, while PEP 837 explores an extensible protocol for JSON serialization. There is also lively pre-PEP discussion around PEP 841, which proposes frozen syntax for immutable types. PEP 828 has also been accepted by its PEP delegate, clearing the way for yield from in asynchronous generators.

Elsewhere, PyPI announced that releases will stop accepting new files after 14 days, nominations for the inaugural Python Packaging Council are about to open, and core developer Petr Viktorin was named a PSF Fellow. If you missed EuroPython, this edition's Core Team Musings includes the new CPython and Steering Council panel recordings, along with the video edition of a very fun, live core.py interview with Guido van Rossum.

Python 3.15 has now entered the release candidate phase, so this is the moment for maintainers to test their projects and publish Python 3.15 wheels to PyPI. Wheels built against the 3.15 release candidates will work with future 3.15 releases, and publishing them now helps downstream projects test too. Please file any CPython issues you find.

Upcoming Releases

Official News

PEP Updates

Steering Council Updates

Merged PRs

Discussion

Core Dev Musings

Upcoming CFPs & Conferences

One More Thing

A still from Charli xcx's Marco Burro

Credits

August 05, 2026 12:00 AM UTC


Python Insider

Python 3.14.7 and 3.13.15 are now available!

A pair of bug fix releases await your upgrade.

August 05, 2026 12:00 AM UTC

August 04, 2026


Marc-André Lemburg

Pymmich – an AI-first OSS project 🐍✨

Pymmich – an AI-first OSS project 🐍✨

A couple of months ago, I decided to switch to Immich, the photo management software, for storing and managing photos.

I had used Nextcloud Memories before that, but found the Immich app more intuitive and easier to use.

What was missing, was a good way to quickly upload albums from Nextcloud to Immich. This is how Pymmich was born, an AI-first CLI written in Python for uploading and downloading albums to and from an Immich server (and nothing much more). Usage is really easy, and only requires uv to be installed:

uvx pymmich --help

At the time, I was using an Immich 2.7.5 server and the CLI worked great for that version. Porting my albums was easy, flexible enough for my needs and reduced the effort significantly.

Last weekend I realized that a new version 3.x of Immich had been released, so I added support to Pymmich for the API of this new version yesterday. And because I wanted better testing, I also added docker based live Immich servers to the project, to not only mock things, but test against actual server APIs.

So what is this AI-first thing ? ✨

I have been working with agents since last year, pretty much on a daily basis. At first, I had the usual problems with hallucinations and the agents wondering off into the weeds, but since late last year, this has changed dramatically. Agents only rarely hallucinated anymore, you could actually tell them not to and instead ask for help or add comments where they did not have enough information.

This improved a whole lot between January and February this year, and then continued to improve pretty much on a bi-monthly basis.

Nowadays, the agents produce code which is of high quality, they follow specifications a lot more thoroughly and generally keep better focus. Things are not perfect yet, and they sometimes have bad days (or I&aposm getting A/B tested), but I&aposm at a point now, where I wouldn&apost want to miss these excellent tools anymore.

But doesn&apost AI kill open source ? What about all those vibe coded PRs/MRs hitting OSS projects ?

Well, I don&apost have a good answer for dealing with AI slop, except maybe to use the same AI tooling to identify and filter such slop – agents are very good at reviews and detecting code which doesn&apost do what the author says it does.

But there&aposs an alternative to all this. Just like you need to start feeling comfortable working in pair programming mode with an agent, you have to accept that programming is shifting to specifying what you want to achieve, rather than writing the code yourself, these days.

Accordingly, accepting code patches doesn&apost seem like the right strategy for AI driven open source software anymore. Instead, I chose to turn off PRs on the Pymmich Github repo and ask users who want to contribute to open discussions threads for new features, providing prompts or specifications for those new features instead of code.

How does that make a difference ? 🤝

I don&apost have to spend time reviewing PRs, but instead can think through whether a feature or enhancements makes sense or not – at a much higher level. Just like what I do on a daily basis these days.

And what&aposs even better: I can use my own agent tooling for actually having the feature or enhancement implemented – using my own trusted workflows and review strategies.

This what I call an "AI-first project".

IMHO, this is a good strategy for new open source projects. In any case, have fun with Pymmich 0.4.0.

Cheers,
Marc-André Lemburg

August 04, 2026 08:36 PM UTC


PyCoder’s Weekly

Issue #746: Free-Threaded NumPy, __all__, PyTorch, and More (2026-08-04)

#746 – AUGUST 4, 2026
View in Browser »

The PyCoder’s Weekly Logo


Scaling NumPy on Free-Threaded Python

A recap on the work done in NumPy and CPython to make multi-threaded NumPy workloads scale on the free-threaded build of CPython.
KUMAR ADITYA

Managing Imports With Python’s __all__

Learn how Python’s dunder all variable controls wildcard imports and shapes the public API your packages and modules expose.
REAL PYTHON course

Quiz: Managing Imports With Python’s __all__

REAL PYTHON

Open Source Just Reached Frontier Code Review

An open-source code reviewer just outranked leading closed models. pr-af places #2 of 42 on Code-Review-Bench. The open-source harness plans a custom review per PR, runs reviewer agents in parallel, and verifies every finding against your source before posting inline comments. About 10x cheaper. Star & Deploy
AGENTFIELD.AI sponsor

PyTorch Tutorial for Deep Learning

This article provides a guide for developers with basic Python knowledge looking to explore deep learning using PyTorch.
EVGENIA VERBINA

Announcing PSF Fellow Members for Q2 2026

PYTHON SOFTWARE FOUNDATION

PEP 842: Module Exports

PYTHON.ORG

Articles & Tutorials

Introducing django-crawl

During a recent site migration, Adam used the Django test harness to crawl his site looking for missing security headers. In the process he uncovered seven other bugs for a project that had 100% code coverage. He has consolidated the crawling technique for testing into a library: django-crawl.
ADAM JOHNSON

Running subprocesses in Python

You can use Python’s subprocess.run() function to launch other programs from within Python.
TREY HUNNER

SIMD in Pure Python

SIMD is Single Instruction, Multiple Data, an approach that does calculations with vectors of data sets. Python doesn’t support it natively, but libraries like NumPy allow you to code this way.
DAVID BUCHANAN

Why the Hardest Concept for Python Devs Is Concurrency

“Concurrency is arguably the hardest concept for Python developers, because the important ideas already assume you understand the operating system underneath.”
OEDOKUMACI.COM • Shared by Oral Ersoy Dokumaci

Stream Subprocess Output in Real Time in PyQt6

Learn how to run external processes from PyQt6 and display their output line-by-line in real time, without blocking the GUI. Covers QProcess and QThread-based approaches with complete working examples.
PYTHONGUIS

Setting Django’s DEBUG Safely

“Deploying Django with DEBUG=True exposes your app to attackers. Learn why it’s risky and how to fail closed so DEBUG=False stays the safe default.”
JAMES OSGOOD

CrewAI in Python: Coordinating Teams of AI Agents

Learn how to use CrewAI to build teams of AI agents in Python, define roles and tools, and coordinate multi-agent workflows that solve complex tasks.
REAL PYTHON

How to Use Google’s Antigravity CLI for AI Code Assistance

Get started with Google’s Antigravity CLI, a terminal-based AI coding agent, and use it to read, review, and refactor your Python code.
REAL PYTHON

Quiz: How to Use Google’s Antigravity CLI for AI Code Assistance

REAL PYTHON

Spy on Function Calls With unittest.mock Wraps

Testing terminology distinguishes between different kinds of test doubles: mocks to replace real world behavior, and spies which wrap a function recording information about them. Despite its name, Python’s unittest.mock supports both.
ADAM JOHNSON

Projects & Code

ome-zarr-py:NGFF Specifications for Storing Bioimaging Data

GITHUB.COM/OME

icalendar: icalendar Parser Library for Python

GITHUB.COM/COLLECTIVE

django-orm-lens: Django Schema Review

GITHUB.COM/FROWNINGDEV

cnsplots: Python Data Visualization for Complex Datasets

GITHUB.COM/FARIDRASHIDI • Shared by Farid Rashidi

Whoosh: Full-Text Search (BM25F, No Server, No Native Deps)

GITHUB.COM/PRIYA-SUNDARAM-DEV • Shared by Priya Sundaram

Events

Weekly Real Python Office Hours Q&A (Virtual)

August 5, 2026
REALPYTHON.COM

Mocking Demystified: Make Your Unit Tests Stronger

August 6, 2026
LUMA.COM • Shared by Alla Barbalat

Canberra Python Meetup

August 6, 2026
MEETUP.COM

Sydney Python User Group (SyPy)

August 6, 2026
SYPY.ORG

PyCon Indonesia 2026

August 8 to August 10, 2026
PYCON.ID

PyDelhi User Group Meetup

August 8, 2026
MEETUP.COM

Python Nordeste 2026 (PyNE 2026)

August 13 to August 16, 2026
PYTHONNORDESTE.ORG

PyCon Korea 2026

August 15 to August 18, 2026
PYCON.KR


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

August 04, 2026 07:30 PM UTC


Programiz

Python Lists

In this tutorial, we will learn about Python lists (creating lists, changing list items, removing items, and other list operations) with the help of examples.

August 04, 2026 03:47 PM UTC