Planet Python
Last update: August 08, 2026 09:48 PM UTC
August 08, 2026
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, that is a golden too!
Whilst complex goldens may be used and marked for semantic simularity 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 the real proxy, and score whether the selected destination 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. It changed what routing meant.
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 through upm-agent-proxy. 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 delivering it.
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.
The useful 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 “this case failed because the right skill was selected, but the wrong tool arguments were passed on step three”, or “the tool path was fine, but the final answer missed two required sections”.
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 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):
- Fundraising: leading sponsorship development, corporate and individual membership growth, and partner relationships, including support for the jump from our current 300K USD annual fundraising goal to 500K USD. At the current funding level (around 300K per year), a full-time Executive Director isn't yet sustainable. We'd like to fix that, and we want you to lead that change.
- Admin and operations management: day-to-day operations and administration of the DSF, financial reporting, grant management, and the general running of the organization. Over time, helping grow the DSF into a more mature organization by establishing the operational foundations that support the nonprofit's growth.
- Managing the DSF Assistant and Fellows: overseeing the DSF Assistant and the Django Fellows program, the paid maintainers funded by the DSF.
- Marketing and outreach: community outreach and communications, representing the DSF publicly (for example, conference representation), and growing awareness of the Foundation as distinct from the framework.
- Legal, trademark, and follow-ups: overseeing international trademark policy enforcement, creating, signing, and renewing contracts, handling legal correspondence, and the unglamorous administrative follow-through that keeps a 501(c)(3) compliant. First-hand legal knowledge isn't required here; you'll work with counsel.
- Working group check-ins: regular coordination with the DSF working groups, the volunteer committees handling events, AI, accessibility, fundraising, and more.
- Working with our Django events and conferences like our DjangoCons.
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):
- Fundraising, sponsorship, and partnership development
- Nonprofit operations, financial reporting, and grant management
- Contracts, trademark, and 501(c)(3) compliance (in coordination with counsel)
- Public representation, marketing, and communications
- Coordinating staff, volunteers, and working groups
- Technical knowledge is not required, but is a nice-to-have:
- Knowledge of, or familiarity with, the Django and Python community
- Familiarity with open source licenses and communities
And required professional skills such as:
- Conflict resolution
- Time management and prioritization expertise
- Ability to focus in short periods of time and do substantial context switches
- Self-awareness to recognize their own limits and reach out for help
- Relationship-building and coordination with the Board, staff, working groups, sponsors, and external parties
- Tenacity, patience, compassion and empathy are essential
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:
- A cover letter (optional)
- A resume or CV
- A brief vision statement (500 to 1000 words) addressing your understanding of the Foundation's current position, the key opportunities and challenges you see for the Foundation, and your approach to the role
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:
- Relevant nonprofit leadership and operational experience
- Track record in fundraising and partnership development
- Understanding of the position and of the DSF's current stage
- Clarity, formality, and precision of communications
- Familiarity with open source and/or the Django and Python community
- Strength of reference(s)
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).
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 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:
-
Model field fetch modes for configuring on-demand fetching behavior
-
Database-level delete options for
ForeignKey.on_delete -
Dictionary-based email settings
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.
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
- Attack Vector: Network
- Attack Complexity: Low
- Privileges Required: High
- User Interaction: None
- Scope: Unchanged
- Confidentiality: High
- Integrity: None
- Availability: None
Workaround
There is no workaround.
Resolution
All affected users should upgrade trytond to the latest version.
Affected versions per series:
trytond:- 8.0: <= 8.0.7
- 7.8: <= 7.8.13
- 7.0: <= 7.0.54
Not affected versions per series:
trytond:- 8.0: >= 8.0.8
- 7.8: >= 7.8.14
- 7.0: >= 7.0.55
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
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:
- A file URL (
text/uri-list) - Raw image data (
image/pngorimage/jpeg) - An HTML
<img>tag (text/html) - A plain text URL (
text/plain)
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:
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:
- Local file URL — use the file path directly
- Remote URL — download the image
- Raw image data — use it directly from the MIME data
- 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:
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):
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:
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():
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:
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:
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:
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:
- Direct image data — the fastest and most reliable, when available.
- URLs — first checking for local files, then attempting to download remote URLs.
- HTML fragments — parsing out
<img>tags and fetching the referenced image, including support fordata:URIs. - Fallback — if none of the above match, it passes control to the default
QTextEditbehavior, 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.
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
- Python 3.15.0 release candidate 2 — Sep 01
Official News
- Python 3.14.7 and 3.13.15 are now available! — By Thomas Wouters
- Python 3.15.0 release candidate 1 is here! — By Hugo van Kemenade
- Releases now reject new files after 14 days — By Seth Larson
- Planned updates to the PyPI user interface — By Nicole Harris
- 2026 Python Packaging Council nominations opening soon — By Pradyun Gedam
- Announcing a 2026 PSF Grants Program funding round — By Marie Nordin
- Announcing Python Software Foundation Fellow members for Q2 2026! 🎉 — By Marie Nordin
- PSF Board nominations opening soon — By Marie Nordin
PEP Updates
- PEP 828: Supporting
yield fromin asynchronous generators - PEP 842: Module Exports
- PEP 837: Extensible JSON serialization
Steering Council Updates
- 2026-07-02 Python Steering Council meeting summary
- 2026-06-25 Python Steering Council meeting summary
Merged PRs
- Add
buffersizetomultiprocessing.pool.imap()andimap_unordered() - Avoid quadratic behavior in
xml.etree.ElementPathindex predicates - Allow stable
abi3extensions to include a multiarch tuple in filenames - Add a
ctypesdecorator for generating function pointers using annotations - Use a per-type method cache
- Reduce
LOAD_GLOBALspecialization lock contention in free-threaded builds - Allow
memoryview.cast()for F-contiguous views - Add an
orderparameter tomemoryview.cast() - Add
modetoloop.create_unix_server()andasyncio.start_unix_server() - Remove
Tools/msi,Tools/nuget, and legacypy.exelauncher sources - Rewrite
csv.Snifferdialect detection using trial parsing - Modernize
pydocHTML with dark mode and collapsible inherited members - Generate default
AttributeErrormessages from context - Fix
asyncio.TaskGroupinteraction withaclose()
Discussion
- PEP 841: Adding
frozenSyntax to Make Immutable Types Optimizable — 🆕 🔥 85 new replies · 2.3k views - PEP 828: Supporting
yield fromin Asynchronous Generators — 🔥 16 new replies · 5.4k views - PEP 838: Adding
python-versiontopyvenv.cfg— 🆕 🔥 11 new replies · 300 views - PEP 822: Dedented Multiline String (
d-string) — 7 new replies · 8.1k views - PEP 827: Type Manipulation — 6 new replies · 9.2k views
- PEP 832: Virtual Environment Discovery — 4 new replies · 9.1k views
- PEP 840: Name Resolution in Class Namespaces — 4 new replies · 932 views
- PEP 835: Shorthand syntax for
Annotatedtype metadata — 3 new replies · 5.2k views
Core Dev Musings
- CPython panel at EuroPython 2026 — By EuroPython Conference
- Python Steering Council update at EuroPython 2026 — By EuroPython Conference
- Security and ethics in the age of generative AI at EuroPython 2026 — By EuroPython Conference
- Live
core.pywith Guido van Rossum at EuroPython 2026 — By Pablo Galindo and Łukasz Langa
Upcoming CFPs & Conferences
- PyCon Indonesia 2026 — Aug 08
- Python Nordeste 2026 (PyNE 2026) — Aug 13
- 📋 PyCon South Africa 2026 Tutorial Deadline — Aug 14
- PyCon Korea 2026 — Aug 15
- 📋 Django Day Copenhagen 2026 Deadline — Aug 16
- PyCon Ghana 2026 — Aug 20
- PyCon Latam 2026 — Aug 20
One More Thing
Marco Burro
Credits
Python Insider
Python 3.14.7 and 3.13.15 are now available!
A pair of bug fix releases await your upgrade.
August 04, 2026
Marc-André Lemburg
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 --helpAt 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
PyCoder’s Weekly
Issue #746: Free-Threaded NumPy, __all__, PyTorch, and More (2026-08-04)
#746 – AUGUST 4, 2026
View in Browser »
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
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
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
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
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 »
[ 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 ]
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.
Python Slicing
Django Weblog
Django security releases issued: 6.0.8 and 5.2.17
In accordance with our security release policy, the Django team is issuing releases for Django 6.0.8 and Django 5.2.17. These releases address the security issues detailed below. We encourage all users of Django to upgrade as soon as possible.
CVE-2026-15307: Server-side file-write and request forgery via spatial lookups
Spatial lookups allowed str and dict lookup values to be passed to
GDALRaster when they represented rasters. Depending on the raster driver, this could
write a file to disk (in some cases enabling remote code execution) or issue a
network request as the Django process user. Because the admin changelist
permits filtering via ModelAdmin.lookup_allowed(), the flaw was reachable by
staff users with view permissions on any registered model containing a spatial
field.
The following types are now disallowed by spatial lookups:
dict- A
strthat is not a validGEOSGeometry, e.g. a serialized dictionary
This is a backward incompatible change. As a reminder, all untrusted user input should be validated before use. For that reason, assignments to model fields are unaffected and still accept these input types.
For guidance on how to keep using these types in spatial lookups, on validating untrusted input, and on further security considerations, see the raster security considerations documentation.
This issue has severity "high" according to the Django security policy.
Thanks to Bence Nagy, localhost-detect, and kimchunbok_ for the report.
CVE-2026-15337: Potential denial-of-service vulnerability in check_for_language()
django.utils.translation.check_for_language() was subject to a potential denial-of-service attack when checking many distinct, very long language codes. Each code was used as a key in an in-memory cache, consuming process memory.
The language value reaches this function through the django.views.i18n.set_language() view (not active by default) from POST data. Since request data is limited by DATA_UPLOAD_MAX_MEMORY_SIZE and the cache is configured to store a maximum number of entries, the memory that could be consumed was bounded.
To mitigate this vulnerability, language codes longer than 500 characters are now rejected before the cached lookup.
This issue has severity "low" according to the Django security policy.
Thanks to Jaeyoung Jang for the report.
CVE-2026-15830: Potential denial-of-service vulnerability via nested geometry collections
GEOSGeometry was subject to a potential denial-of-service attack when
provided deeply nested GEOMETRYCOLLECTION objects, leading to a segmentation
fault in GEOS. A maximum depth of 198 GEOMETRYCOLLECTIONs is now enforced for
the well-known text (WKT) format, and a maximum number of 198
GEOMETRYCOLLECTIONs in total (breadth and depth) is enforced for well-known
binary (WKB).
Lookups against spatial fields and the GeometryField form field were also
affected.
The limit can be customized through the new max_geom_collections argument,
available on GEOSGeometry, the form field, and the model field. The limit is
not applied to GeoJSON inputs, as they were parsed by GDAL and are not affected.
This issue has severity "moderate" according to the Django security policy.
Thanks to Andrew MacPherson and kimchunbok_ for the report.
CVE-2026-15920: Potential cross-site scripting via URLField values in the admin
The admin renders URLField values as clickable links on changelist views and read-only fields. The link was generated without validating the value as a safe URL, so a stored value using a potentially dangerous scheme was rendered as a link.
URLField values shown via display_for_field are now validated using URLValidator before a link is rendered, and displayed as plain text if validation is failed.
This issue has severity "moderate" according to the Django security policy.
Thanks to Egor Saltykov (misop00p / ansjdnakjdnajkd) for the report.
Affected supported versions
- Django main
- Django 6.1 (currently at release candidate status)
- Django 6.0
- Django 5.2
Resolution
Patches to resolve the issue have been applied to Django's main, 6.1 (currently at release candidate status), 6.0, and 5.2 branches. The patches may be obtained from the following changesets.
CVE-2026-15307: Server-side file-write and request forgery via spatial lookups
- On the main branch
- On the 6.1 branch
- On the 6.0 branch
- On the 5.2 branch
CVE-2026-15337: Potential denial-of-service vulnerability in check_for_language()
- On the main branch
- On the 6.1 branch
- On the 6.0 branch
- On the 5.2 branch
CVE-2026-15830: Potential denial-of-service vulnerability via nested geometry collections
- On the main branch
- On the 6.1 branch
- On the 6.0 branch
- On the 5.2 branch
CVE-2026-15920: Potential cross-site scripting via URLField values in the admin
- On the main branch
- On the 6.1 branch
- On the 6.0 branch
- On the 5.2 branch
The following releases have been issued
The PGP key ID used for this release is Natalia Bidart: 2EE82A8D9470983E
General notes regarding security reporting
As always, we ask that potential security issues be reported via private email
to security@djangoproject.com, and not via Django's Trac instance, nor via
the Django Forum. Please see
our security policies for further
information.
Python Insider
Python 3.15.0 candidate 1 is here!
Get those wheels rolling!
August 03, 2026
Tryton News
Tryton News August 2026
This month the community focused on the trytond core. The XML data import grows a new way to find existing records, the database schema shrinks oversized character columns automatically, and the test suite for function fields and button methods gets more coverage. On the user-facing side, the accounting menus are tidied up, and the DPD shipping carrier moves to the latest API. All of this builds on our last LTS release 8.0.
For an in depth overview of the Tryton issues please take a look at our issue tracker or see the issues and merge requests filtered by label.
Changes for the User
Accounting, Invoicing and Payments
When a user un-reconciles a line that was created by the receivable rule, Tryton now raises a warning, similar to the warning it already shows for payables. This avoids accidentally leaving too much money in the target account.
The accounting menus are simplified. The “open journal” entry now opens the journal and period form directly instead of going through a wizard. The “close” prefix is dropped from the processing menu-entries, the reconcile wizard becomes the first one, and the redundant “account” prefix is removed from the moves menu entry.
Stock, Production and Shipments
The DPD shipping carrier integration now uses Shipment Service 4.5, the latest version of the DPD API.
New Documentation
The documentation for creating account moves from a template now links the accounting menu-entry in the client, so the workflow is easier to find.
New Releases
We released bug fixes for the currently maintained long term support series 8.0, 7.8, and 7.0.
Changes for Implementers and Developers
When all rows of a char field fit in a smaller column, the database schema is now shrunk automatically. This keeps the database compact without any manual intervention.
The XML data import now supports a search attribute on the <record> tag. When the record with the given id does not exist, Tryton uses this domain to find an existing record before creating a new one. This makes it possible to import data using natural unique keys like the language code.
The test suite for button methods now runs each method as a subtest, so a failure in one method does not prevent the remaining button methods from being tested.
The test suite for column-based function fields is now added to the tests of fields methods.
Initial draft powered by Minimax-M3. Curated and finalized by human hands.
1 post - 1 participant
August 02, 2026
Mike C. Fletcher
OMI Audio extension for glTF
Another OMI-based extension, this time for positional audio mixing. Again, Claude-coded, using the OMI/KHR audio extensions for glTF as the base model and then implementing the actual mixing using Numpy. Supports mp3, wav, opus and flac inputs and stereo (headset) outputs. Up on github as omi_audio and on PyPI as omi_audio as well.
This is loosely the same model as Web Audio's Panner Node. The only significant difference from the KHR extension is that we also support VRML97 style double-ellipse emitter. The package delegates file retrieval to the application, and doesn't include any special effects or the like.
Talk Python to Me
#557: Security of everything at PyCon 2026
Security has always been the vegetables of software. Everyone agrees it matters, and somehow it never quite makes it onto the plate. At PyCon US this year, that changed. For the first time ever, security got its own dedicated, day-long track, one of just two at the whole conference, sitting right next to AI. And the room was packed to the back wall. <br/> <br/> On this episode, I'm joined by the three people at the center of it. Seth Larson, Security Developer in Residence at the Python Software Foundation and, very recently, a CPython core developer. Juanita Gomez, a PhD researcher at UC Santa Cruz in open source security, who co-chaired the track. And Mike Fiedler, PyPI's Safety and Security Engineer, one of the very few people paid full-time to keep the packages you install safe. <br/> <br/> We use the arc of the track's talks to take the temperature of Python security right now: supply chain attacks, dependency cooldowns, zero trust, SBOMs, and the push to bring Rust into CPython. And why not one of us thinks security is anywhere close to solved. Turns out that's the good news. It's why the room was full.<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/training'>Talk Python Courses</a><br/> <br/> <h2 class="links-heading mb-4">Links from the show</h2> <div><strong>Guests</strong><br/> <strong>Juanita Gomez</strong>: <a href="https://www.linkedin.com/in/juanitagomezr/?featured_on=talkpython" target="_blank" >linkedin.com</a><br/> <strong>Mike Fiedler</strong>: <a href="https://miketheman.dev?featured_on=talkpython" target="_blank" >miketheman.dev</a><br/> <strong>Seth Michael Larson</strong>: <a href="https://sethmlarson.dev?featured_on=talkpython" target="_blank" >sethmlarson.dev</a><br/> <br/> <strong>Trailblazing Python Security</strong>: <a href="https://us.pycon.org/2026/tracks/security/?featured_on=talkpython" target="_blank" >us.pycon.org</a><br/> <strong>Everything Security at PyCon US 2026 (PSF blog)</strong>: <a href="https://pyfound.blogspot.com/2026/06/everything-security-at-pycon-us-2026.html?featured_on=talkpython" target="_blank" >pyfound.blogspot.com</a><br/> <strong>Dependency Cooldowns</strong>: <a href="https://cooldowns.dev/?featured_on=talkpython" target="_blank" >cooldowns.dev</a><br/> <strong>Anatomy of a Phishing Campaign (Mike Fiedler) Recording</strong>: <a href="https://www.youtube.com/watch?v=uXW1qeUS6Yw" target="_blank" >www.youtube.com</a><br/> <strong>FedRAMP</strong>: <a href="https://www.gsa.gov/technology/government-it-initiatives/fedramp?featured_on=talkpython" target="_blank" >www.gsa.gov</a><br/> <strong>Zero Trust in 200ms: Implementing Identity-Per-Transaction with Python & Serverless-Tristan McKinnon</strong>: <a href="https://www.youtube.com/watch?v=WOOlzsJx9cg" target="_blank" >www.youtube.com</a><br/> <strong>Rust for CPython project</strong>: <a href="https://blog.python.org/2026/04/rust-for-cpython-2026-04/?featured_on=talkpython" target="_blank" >blog.python.org</a><br/> <strong>pre-PEP</strong>: <a href="https://discuss.python.org/t/pre-pep-rust-for-cpython/104906?featured_on=talkpython" target="_blank" >discuss.python.org</a><br/> <strong>Rust for CPython: Making Python Safer and More Robust for Everyone - Emma Smith</strong>: <a href="https://www.youtube.com/watch?v=42kibVnUHYE" target="_blank" >www.youtube.com</a><br/> <strong>SBOMit</strong>: <a href="https://github.com/in-toto/sbomit?featured_on=talkpython" target="_blank" >github.com</a><br/> <strong>Asleep at the Wheel: Getting your SBOMs to pay attention... - Sanchit Sahay, Abhishek Reddypalle</strong>: <a href="https://www.youtube.com/watch?v=VYY3HnRtV6U" target="_blank" >www.youtube.com</a><br/> <strong>Volatility</strong>: <a href="https://volatilityfoundation.org/?featured_on=talkpython" target="_blank" >volatilityfoundation.org</a><br/> <strong>Post Incident Runtime SBOM Generation from Python Memory - Hala Ali</strong>: <a href="https://www.youtube.com/watch?v=mgY7zuDYrag" target="_blank" >www.youtube.com</a><br/> <strong>zizmor</strong>: <a href="https://docs.zizmor.sh/?featured_on=talkpython" target="_blank" >docs.zizmor.sh</a><br/> <strong>GitHub Actions security in Python packages (Andrew Nesbitt write-up)</strong>: <a href="https://nesbitt.io/2026/05/25/github-actions-security-in-python-packages.html?featured_on=talkpython" target="_blank" >nesbitt.io</a><br/> <strong>andrew/pycon: data & analysis for the GitHub Actions security talk</strong>: <a href="https://github.com/andrew/pycon?featured_on=talkpython" target="_blank" >github.com</a><br/> <strong>GitHub Actions Security in Python Packages - Andrew Nesbitt</strong>: <a href="https://www.youtube.com/watch?v=vn3YIqdseWI" target="_blank" >www.youtube.com</a><br/> <strong>gh-profiler: examine a GitHub user's profile to gauge their contributions</strong>: <a href="https://github.com/ehmatthes/gh-profiler?featured_on=talkpython" target="_blank" >github.com</a><br/> <strong>PyCon US YouTube channel</strong>: <a href="https://www.youtube.com/@PyConUS" target="_blank" >www.youtube.com</a><br/> <strong>SBOMit: adding verification to SBOMs (OpenSSF)</strong>: <a href="https://openssf.org/blog/2023/12/13/introducing-sbomit-adding-verification-to-sboms/?featured_on=talkpython" target="_blank" >openssf.org</a><br/> <strong>Ecosystems</strong>: <a href="https://ecosyste.ms?featured_on=talkpython" target="_blank" >ecosyste.ms</a><br/> <br/> <strong>Watch this episode on YouTube</strong>: <a href="https://www.youtube.com/watch?v=ktEssFryRjY" target="_blank" >youtube.com</a><br/> <strong>Episode #557 deep-dive</strong>: <a href="https://talkpython.fm/episodes/show/557/security-of-everything-at-pycon-2026#takeaways-anchor" target="_blank" >talkpython.fm/557</a><br/> <strong>Episode transcripts</strong>: <a href="https://talkpython.fm/episodes/transcript/557/security-of-everything-at-pycon-2026" 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>
Python⇒Speed
Faster floating point math with Rust’s new API
Floating point math is often slower than integer math because the compiler is being conservative about how it optimizes your code. While some programming languages already had solutions of a sort, until now Rust did not have a good stable way to deal with this limitation. But now, starting in version 1.98, Rust will allow telling the compiler it can optimize your code further—but with extra control so that you can still write numeric algorithms with minimal rounding errors.
In this article you will learn:
- Why by default the compiler won’t optimize floating point math as much as it does integer math.
- Rust’s new API to solve this limitation.
- Examples of using this new API, its speed impact, and how you can control where it is used.
August 01, 2026
Seth Michael Larson
Let’s Play “htmx 4: the game”
Moments ago I just finished playing “htmx 4: the game”, the first JavaScript library published exclusively for Game Boy and Game Boy Color. I've recorded my play session and published the video to YouTube:
When I first saw this announcement it was pure blog bait for me. Game Boy, open source software, reverse-engineering, oh my. I purchased the cartridge within moments of seeing the announcement.
The Game Boy cartridge arrived earlier today inside a custom cardboard box and plastic case which I appreciate! The box was tight to open without ripping the cardboard, I managed with the help of some tweezers. The game is on a Game Boy cartridge with a label. The cartridge itself reads “Game” instead of “Game Boy” to avoid running afoul of Nintendo trademarks and licensing.
I was unable to dump the ROM using my Epilogue GB Operator, the cartridge didn't fit into the slot unless I angled it weirdly and even then the cartridge wouldn't give the GB Operator the consistent connection it needs to dump the ROM. This was unfortunate, I wanted to dig into the ROM itself more for this post, but that will have to be another day. I suspect the tolerances for the cartridge are just outside what the Epilogue will tolerate. I switched over to my Game Boy Player to capture footage of game play with my HDMI-modded GameCube. Here the cartridge fit and worked just fine.
The game is tough (especially The Slop Factory, yeesh) and took me over an hour to complete, and I didn't even find all 4 HTMX letters in each stage... hinting that there is more to be discovered! There was pervasive lag while playing which the game’s story blames on... React. A likely story! ;)
Going in I wasn't sure how they were going to distribute the final source code... Would there be a QR code to the source code (which would have been a disappointment) or would the htmx team actually make you type it in yourself like a BASIC program from a magazine? I was so happy to see that the htmx team went all the way, it means I'm even more excited to dig into this ROM!
As a software distribution mechanism... including
ordering the game, waiting for delivery, beating
the game, and then typing in the source code by
hand from the Game Boy screen... Let's say it's
slightly less user-friendly than npm install :)
But that's the fun of a project like this.
Thanks for reading ♥ I would love to hear your thoughts! Contact me via Mastodon, Bluesky, or email. Browse the blog archive. Check out my blogroll.
July 31, 2026
Programiz
Python Booleans and Boolean Expressions
Caktus Consulting Group
Chicago Like a Local: Things to Do During DjangoCon US 2026 (Part 1)
DjangoCon US 2026 is coming back to Chicago from August 23–28, and we at Caktus couldn’t be more thrilled, not only to be returning as a Silver Sponsor, but also because I (Keanya) have the privilege of chairing the conference once again!
Jaime Buelta
Python Automation Cookbook 3rd edition: now with AI recipes.
Exciting news! The third edition of the Python Automation Cookbook is out, featuring a vibrant new cover and over 100 pages of fresh AI content. Aimed at Python enthusiasts of all levels, it retains classic recipes while introducing innovative ways to incorporate AI in code. A fantastic addition to any developer's library!
Python Software Foundation
Get Ready: Python Packaging Council Nominations Opening Soon!
The inaugural Python Packaging Council Election nomination period opens next week on Tuesday, July 28th, 2:00 pm UTC and closes on Tuesday, August 11th, 2:00 pm UTC.
The Python Packaging Council (PPC) will be the technical decision-making body for the interoperability specifications that govern how Python packages are built, distributed, and installed. It will also coordinate efforts among packaging tool maintainers, the Python core team, and the broader community.
Running for the Packaging Council
Do you have a vision for improving the Python packaging experience? Do you make the tools used to build and consume Python packages? Are you passionate about building communities, consensus, and standards focused on the user experience? If these resonate with you, and you have the time to attend regular meetings and participate in the standardization process, you should consider running for the inaugural PPC!
We're looking for candidates who can build bridges between projects and communities, who enjoy working with a very large community of passionate volunteers, and have a willingness to represent the wider community ahead of any single tool, project, or employer. We also welcome candidates who have a diverse set of skills and experiences, including open-governance experience, community stewardship, fundraising knowledge, and (of course!) technical expertise in Python packaging and distribution.
PEP 772 does provide non-binding operational suggestions, which hint at how the council could function. As this is the inaugural PPC, the individuals serving on it will be establishing the initial operating procedures, scope, interests, and agenda that future councils will build upon. Notably, "establishing specific processes for [the] Packaging Council and PyPA relationship" is something that the inaugural Packaging Council is expected to do.
Election Overview
The 2026 inaugural election fills all five seats on the PPC. The two candidates receiving the highest number of votes shall be designated Cohort A with a two year term, and the three candidates receiving the next highest number of votes shall be designated Cohort B with a one year term.
In future elections, each cohort will be elected for a full two-year term in alternating years, so that roughly half of the PPC turns over each cycle.
Election Timeline
- Nominations open: Tuesday, July 28th, 2:00 pm UTC
- Nomination cut-off: Tuesday, August 11th, 2:00 pm UTC
- Announce candidates: Thursday, August 13th
- Voter affirmation cut-off: Tuesday, August 25th, 2:00 pm UTC
- Voting start date: Tuesday, September 1st, 2:00 pm UTC
- Voting end date: Tuesday, September 15th, 2:00 pm UTC
Not sure what UTC is for you locally? Check this UTC time converter!
Nomination details
You can nominate yourself or someone else. If you're nominating someone else, we'd encourage you to reach out to them first to make sure they're excited about the opportunity and give them a heads up that they'll need to submit their own nomination statement too. Remember, nominees must themselves be PSF voting members, and nomination statements must include information about the nominee’s relevant affiliations.
To submit a nomination for yourself or someone else, use the 2026 PPC Election Nomination Form on our website. The form will open on Tuesday, July 28th, 2:00 pm UTC and close on Tuesday, August 11th, 2:00 pm UTC.
Voting Reminder!
Every PSF Voting Member (Supporting, Contributing, and Fellow) needs to be a member in good standing by August 25th and affirm their membership to vote in this election. You should have received an email with information on how to affirm your voting status.
You can see your membership record and status on your PSF Member User Information page. If you are a voting-eligible member and do not already have a login, please create an account on psfmember.org first and then email pc-elections@python.org so we can link your membership to your account.
Seth Michael Larson
Extracting “Ocarina of Time: Master Quest” ROM from the Legend of Zelda: Wind Waker bonus disc
Another day, another extracting ROMs from GameCube titles mini-tutorial. This time it’s the PAL Legend of Zelda: Wind Waker Bonus Disc that I recently purchased from Kraków, Poland. This disc contains two N64 games: Legend of Zelda: Ocarina of Time and the “Master Quest” version of Ocarina of Time. Let’s extract those ROMs from the GameCube ROM so we can play on other emulators.
Going from a GameCube disc to an ISO ROM is the usual
process, for me that means using the FlippyDrive Disc Backup
Utility but CleanRip on a GameCube or Wii also works.
To extract the individual files from the ISO open
the ISO in Dolphin, right-click the ISO, Properties,
Filesystem, and extract the zlj_f.tgc file into
your working directory.
According to The Cutting Room Floor both N64 ROMs
are included in the file zlj_f.tgc. TGC is an
archive format that Nintendo uses often in this generation
of games. Thanks to resources like No Intro
we have the known-good ROM lengths and hashes for both Ocarina of
Time and Master Quest for all regions. Using this information
(along with the N64 ROM header) we can find the embedded N64 ROMs
within zlj_f.tgc without understanding the TGC format at all:
# License: MIT
import hashlib
ROM_HEADER = b"\x80\x37\x12\x40"
ROM_LENGTH = 0x2000000
ROM_HASHES = {
"1618403427e4344a57833043db5ce3c3": "Legend of Zelda, The - Ocarina of Time - Master Quest (Europe) (En,Fr,De) (Zelda Collection).n64",
"2c27b4e000e85fd78dbca551f1b1c965": "Extracted Legend of Zelda, The - Ocarina of Time (Europe) (GameCube).n64",
# If you see 'Unknown ROM', paste the MD5 into
# https://datomatic.no-intro.org/index.php
# searching on 'hash-data' and add the values here.
}
def main():
data = open("zlj_f.tgc", "rb").read()
# Find the embedded ROMs from the N64 ROM header.
offset1 = data.find(ROM_HEADER)
offset2 = (offset1 + 4) + data[(offset1 + 4):].find(ROM_HEADER)
for offset in (offset1, offset2):
# Determine which ROM we have based on MD5.
rom = data[offset:offset + ROM_LENGTH]
rom_md5 = hashlib.md5(rom).hexdigest()
to_file = ROM_HASHES.get(rom_md5, None)
if to_file is None:
print(f"Unknown ROM ({rom_md5})")
continue
with open(to_file, "wb") as f:
f.truncate()
f.write(rom)
print(f"Extracted {to_file} ({rom_md5})...")
if __name__ == "__main__":
main()
The script above only knows about the ROM hashes for
the PAL revision of the Bonus Disc, so if you see Unknown ROM (...),
don't panic! You can search the MD5 you receive on No Intro
by filtering on hash-data to discover whether the ROM
is correct and add the value to ROM_HASHES in the script.
With my Master Quest ROM in hand I can load the game into Delta Emulator and play on my iPhone:
Legend of Zelda: Ocarina of Time Master Quest running in the Delta Emulator
I won’t be playing much because I have never played Ocarina of Time before and want to play the upcoming remake for the Nintendo Switch 2 with a completely blank slate. I’m excited to try one of the most universally acclaimed games ever made for the first time soon! :)
What’s new in the blogroll?
I publish a blogroll containing links to websites that I've enjoyed recently.
Pokémon cards were on the mind the past few weeks! My brother is a collector so we've been discussing the different artists and sets that he's interested in. While I was doing research I came across two websites that seemed useful for this: collating Pokémon set symbols and Pokémon card artwork by artist. A comedic graphic design YouTuber “Elliot” also published a new video about Pokémon’s graphic designs which I found entertaining.
I purchased “htmx 4: the game”, the first JavaScript library published exclusively to the Nintendo Game Boy platform. The package (hah) arrives today and I plan to post about the experience with the game and discussing the form factor as a software distribution mechanism, look forward to that blog post soon.
I found a Game Boy Link Cable to internet adapter hardware project which looks intriguing if you've got friends that are far away that you still want to play Generation 3 Pokémon with (Pokémon Emerald forever). This hardware enables trading, battling, record mixing, and more by pretending to be your peer in a GBA Link Cable connection.
Thanks for reading ♥ I would love to hear your thoughts! Contact me via Mastodon, Bluesky, or email. Browse the blog archive. Check out my blogroll.

