07 Jun 2026
Planet Python
Eli Bendersky: Plugins case study: mdBook preprocessors
mdBook is a tool for easily creating books out of Markdown files. It's very popular in the Rust ecosystem, where it's used (among other things) to publish the official Rust book.
mdBook has a simple yet effective plugin mechanism that can be used to modify the book output in arbitrary ways, using any programming language or tool. This post describes the mechanism and how it aligns with the fundamental concepts of plugin infrastructures.
mdBook preprocessors
mdBook's architecture is pretty simple: your contents go into a directory tree of Markdown files. mdBook then renders these into a book, with one file per chapter. The book's output is HTML by default, but mdBook supports other outputs like PDF.
The preprocessor mechanism lets us register an arbitrary program that runs on the book's source after it's loaded from Markdown files; this program can modify the book's contents in any way it wishes before it all gets sent to the renderer for generating output.
The official documentation explains this process very well.
Sample plugin
I rewrote my classical "nacrissist" plugin for mdBook; the code is available here.
In fact, there are two renditions of the same plugin there:
- One in Python, to demonstrate how mdBook can invoke preprocessors written in any programming language.
- One in Rust, to demonstrate how mdBook exposes an application API to plugins written in Rust (since mdBook is itself written in Rust).
Fundamental plugin concepts in this case study
Let's see how this case study of mdBook preprocessors measures against the Fundamental plugin concepts that were covered several times on this blog.
Discovery
Discovery in mdBook is very explicit. For every plugin we want mdBook to use, it has to be listed in the project's book.toml configuration file. For example, in the code sample for this post, the Python narcissist plugin is noted in book.toml as follows:
[preprocessor.narcissistpy]
command = "python3 ../preprocessor-python-narcissist/narcissist.py"
Each preprocessor is a command for mdBook to execute in a sub-process. Here it uses Python, but it can be anything else that can be validly executed.
Registration
For the purpose of registration, mdBook actually invokes the plugin command twice. The first time, it passes the arguments supports <renderer> where <renderer> is the name of the renderer (e.g. html). If the command returns 0, it means the preprocessor supports this renderer; otherwise, it doesn't.
In the second invocation, mdBook passes some metadata plus the entire book in JSON format to the preprocessor through stdin, and expects the preprocessor to return the modified book as JSON to stdout (using the same schema).
Hooks
In terms of hooks, mdBook takes a very coarse-grained approach. The preprocessor gets the entire book in a single JSON object (along with a context object that contains metadata), and is expected to emit the entire modified book in a single JSON object. It's up to the preprocessor to figure out which parts of the book to read and which parts to modify.
Given that books and other documentation typically have limited sizes, this is a reasonable design choice. Even tens of MiB of JSON-encoded data are very quick to pass between sub-processes via stdout and marshal/unmarshal. But we wouldn't be able to implement Wikipedia using this design.
Exposing an application API to plugins
This is tricky, given that the preprocessor mechanism is language-agnostic. Here, mdBook only offers additional utilities to preprocessors implemented in Rust. These get access to mdBook's API to unmarshal the JSON representing the context metadata and book's contents. mdBook offers the Preprocessor trait Rust preprocessors can implement, which makes it easier to wrangle the book's contents. See my Rust version of the narcissist preprocessor for a basic example of this.
Renderers / backends
Actually, mdBook has another plugin mechanism, but it's very similar conceptually to preprocessors. A renderer (also called a backend in some of mdBook's own doc pages) takes the same input as a preprocessor, but is free to do whatever it wants with it. The default renderer emits the HTML for the book; other renderers can do other things.
The idea is that the book can go through multiple preprocessors, but at the end a single renderer.
The data a renderer receives is exactly the same as a preprocessor - JSON encoded book contents. Due to this similarity, there's no real point getting deeper into renderers in this post.
07 Jun 2026 1:37am GMT
06 Jun 2026
Planet Python
Armin Ronacher: Communities of Not
There is a strange thing that happens in communities that gather around abstinence from something: identity from opposition. At their best these communities are not just negative: childfree spaces can be about autonomy, choice and acceptance, anti-car spaces about safer streets and transit, and LLM-skeptical developer spaces about the future of labor, code quality and slop1. But the thing being refused often does not go away and instead becomes the main subject of the community's identity.
That would be fine if it stayed at criticism, maybe even angry criticism, but more often than not it turns into policing and hatred towards others. An influencer without children becomes a parent, an urban bike commuter by choice buys a Porsche, a respected developer tries LLMs, and the community feels betrayed because it assumed they were members of the same tribe. The expulsion of that person (who never signed up to be a community member) is entirely imaginary but the punishment that the community unleashes is not: people pile on and shame them, quote them out of context and turn their weakest moments into proof that the person was always unserious, a sharlatan or should not be listened to.
I do not think the answer is to tell people to stop paying attention. Cars shape cities even for people who cycle, children influence politics, workplaces and taxes even for people who do not have them. For us developers, LLMs show up in editors, issue trackers, hiring conversations, management pressure and code reviews whether we asked for them or not. Resisting that can be legitimate but that is no excuse for using one's rejection to justify shitty mob behavior.
I understand the thinking all too well, because I have done versions of this myself in the past. It took me a while to become more accepting of other people's worldviews that diverge from mine. Whatever insecurities we have, finding a group of others sharing them can be comforting. The danger is that being part of a crowd of negativity can easily make us part of collective harassment.
I can only encourage you to breathe, slow down, de-escalate when given the chance, and resist the temptation to always assume the most catastrophic reading. Default to being open to new things. Being negative towards something, and making that ones identity, is an easy trap to fall into.
-
These examples are not meant as equivalents. The recent mob against rsync is the LLM version that prompted this post. I picked the others because I'm familiar with those communities and they all show similar cases of personal choices being interpreted as betrayal.↩
06 Jun 2026 12:00am GMT
05 Jun 2026
Planet Python
Kay Hayen: Nuitka Release 4.1
This is to inform you about the new stable release of Nuitka. It is the extremely compatible Python compiler, "download now".
This release adds many new features and corrections with a focus on async code compatibility, missing generics features, and Python 3.14 compatibility and Python compilation scalability yet again.
Bug Fixes
-
Python 3.14: Fix, decorators were breaking when disabling deferred annotations. (Fixed in 4.0.1 already.)
-
Fix, nested loops could have wrong traces lead to mis-optimization. (Fixed in 4.0.1 already.)
-
Plugins: Fix, run-time check of package configuration was incorrect. (Fixed in 4.0.1 already.)
-
Compatibility: Fix,
__builtins__lacked necessary compatibility in compiled functions. (Fixed in 4.0.1 already.) -
Distutils: Fix, incorrect UTF-8 decoding was used for TOML input file parsing. (Fixed in 4.0.1 already.)
-
Fix, multiple hard value assignments could cause compile time crashes. (Fixed in 4.0.1 already.)
-
Fix, string concatenation was not properly annotating exception exits. (Fixed in 4.0.2 already.)
-
Windows: Fix,
--verbose-outputand--show-modules-outputdid not work with forward slashes. (Fixed in 4.0.2 already.) -
Python 3.14: Fix, there were various compatibility issues including dictionary watchers and inline values. (Fixed in 4.0.2 already.)
-
Python 3.14: Fix, stack pointer initialization to
localspluswas incorrect to avoid garbage collection issues. (Fixed in 4.0.2 already.) -
Python 3.12+: Fix, generic type variable scoping in classes was incorrect. (Fixed in 4.0.2 already.)
-
Python 3.12+: Fix, there were various issues with function generics. (Fixed in 4.0.2 already.)
-
Python 3.8+: Fix, names in named expressions were not mangled. (Fixed in 4.0.2 already.)
-
Plugins: Fix, module checksums were not robust against quoting style of module-name entry in YAML configurations. (Fixed in 4.0.2 already.)
-
Plugins: Fix, doing imports in queried expressions caused corruption. (Fixed in 4.0.2 already.)
-
UI: Fix, support for
uv_buildin the--projectoption was broken. (Fixed in 4.0.2 already.) -
Compatibility: Fix, names assigned in assignment expressions were not mangled. (Fixed in 4.0.2 already.)
-
Python 3.12+: Fix, there were still various issues with function generics. (Fixed in 4.0.3 already.)
-
Clang: Fix, debug mode was disabled for clang generally, but only ClangCL and macOS Clang didn't want it. (Fixed in 4.0.3 already.)
-
Zig: Fix,
--windows-console-mode=attach|disablewas not working when using Zig. (Fixed in 4.0.3 already.) -
macOS: Fix, yet another way self dependencies can look like, needed to have support added. (Fixed in 4.0.3 already.)
-
Python 3.12+: Fix, generic types in classes had bugs with multiple type variables. (Fixed in 4.0.3 already.)
-
Scons: Fix, repeated builds were not producing binary identical results. (Fixed in 4.0.3 already.)
-
Scons: Fix, compiling with newer Python versions did not fall back to Zig when the developer prompt MSVC was unusable, and error reporting could crash. (Fixed in 4.0.4 already.)
-
Zig: Fix, the workaround for Windows console mode
attachordisablewas incorrectly applied on non-Windows platforms. (Fixed in 4.0.4 already.) -
Standalone: Fix, linking with Python Build Standalone failed because
libHacl_Hash_SHA2was not filtered out unconditionally. (Fixed in 4.0.4 already.) -
Python 3.6+: Fix, exceptions like
CancelledErrorthrown into an async generator awaiting an inner awaitable could be swallowed, causing crashes. (Fixed in 4.0.4 already.) -
Fix, not all ordered set modules accepted generators for update. (Fixed in 4.0.5 already.)
-
Plugins: Disabled warning about rebuilding the
pytokensextension module. (Fixed in 4.0.5 already.) -
Standalone: Filtered
libHacl_Hash_SHA2from link libs unconditionally. (Fixed in 4.0.5 already.) -
Debugging: Disabled unusable unicode consistency checks for Python versions 3.4 to 3.6. (Fixed in 4.0.5 already.)
-
Python3.12+ Avoided cloning call nodes on class level which caused issues with generic functions in combination with decorators. (Added in 4.0.5 already.)
-
Python 3.12+: Added support for generic type variables in
async deffunctions. (Added in 4.0.5 already.) -
UI: Fix, flushing outputs for prompts was not working in all cases when progress bars were enabled. (Fixed in 4.0.6 already.)
-
UI: Fix, unused variable warnings were missing at C compile time when using
zigas a C compiler. (Fixed in 4.0.6 already.) -
Scons: Fix, forced stdout and stderr paths as a feature was broken. (Fixed in 4.0.6 already.)
-
Fix, replacing a branch did not accurately track shared active variables causing optimization crashes. (Fixed in 4.0.7 already.)
-
macOS: Fix, failed to remove extended attributes because files need to be made writable first. (Fixed in 4.0.7 already.)
-
Fix, dict
popandsetdefaultusing with:=rewrites lacked exception-exit annotations for un-hashable keys. (Fixed in 4.0.8 already.) -
Python 3.13: Fix, the
__parameters__attribute of generic classes was not working. (Fixed in 4.0.8 already.) -
Python 3.11+: Fix, starred arguments were not working as type variables. (Fixed in 4.0.8 already.)
-
Python2: Fix,
FileNotFoundErrorcompatibility fallback handling was not working properly. (Fixed in 4.0.8 already.) -
Compatibility: Fix, loop ownership check in value traces was missing, causing issues with nested loops.
-
Windows: Improved
--windows-console-mode=attachto properly handle console handles, enabling cases likeos.systemto work nicely. -
Python2: Fix, there was a compatibility issue where providing default values to the
mkdtempfunction was failing. -
Windows: Fix, there were spurious issues with C23 embedding in 32-bit MinGW64 by switching to
coff_objresource mode for it as well. -
Plugins: Fix, the
post-import-codeexecution could fail because the triggering sub-package was not yet available insys.modules. -
UI: Fix, listing package DLLs with
--list-package-dllswas broken due to recent plugin lifecycle changes. -
UI: Fix,
--list-package-exewas not working properly on non-Windows platforms failing to detect executable files correctly. -
UI: Handled paths starting with
{PROGRAM_DIR}the same as a relative path when parsing the--onefile-tempdir-specoption. -
Plugins: Followed multiprocessing
forkserverchanges for newer Python versions. -
Python 3.12+: Fix, generic class type parameters handling was incorrect.
-
Python 3.12: Fix, deferred evaluation of type aliases was failing.
-
Python 3.12+: Aligned
sumbuilt-in float summation with CPython's compensated sum for better accuracy. -
Python 3.10+: Fix, uncompiled coroutine
throw()return handling was incorrect, restoring completed coroutine results viaStopIteration.valuerather than exposing them as ordinary return values to the outer await chain. -
Python 3.13+: Fix, uncompiled coroutine
cancel()/awaitsuspension handling was incorrect, improved to ensure integration compatibility. -
macOS: Made finding
create-dmgmore robustly by also checking the Homebrew path for Intel and fromPATHproperly. -
Compatibility: Fix, class frames were not exposing frame locals.
-
UI: Detected
static-libpythonproblems, which affected some forms of Anaconda. -
Distutils: Rejected
--projectmixed with--mainarguments as it is not useful. -
macOS: Fix,
zigfromPATHor fromziglangwas not being used. -
Distutils: Fix, the wrong
module-rootconfig value was being checked foruvbuild backend. -
macOS: Fix, was attempting to change removed (rejected) DLLs, which of course failed and errored out.
-
Python 3.14: Fix, tuple reuse was not fully compatible, potentially causing crashes due to outdated hash caches.
-
Fix, fake modules were still being attempted to located when imported by other code, which could conflict with existing modules.
-
Python 3.5+: Fix, failed to send uncompiled coroutines the sent in value in
yield from. -
Fix, older
gcccompilers lacking newer intrinsic methods had compilation issues that needed to be addressed. -
Standalone: Fix, multiphase module extension modules with post-load code were not working properly.
-
Fix, Avoid using the non-inline copy of
pkg_resourceswith the inline copy of Jinja2. These could mismatch and cause errors. -
Fix, loops could make releasing of previous values very unclear, causing optimization errors.
-
Fix,
incbinresource mode was not working with oldgccC++ fallback. -
Python 3.4 to 3.6: Fix, bytecode demotion was not working properly for these versions, also bytecode only files not working.
-
Plugins: Added a check for the broken
patchelfversions 0.10 and 0.11 to prevent breaking Qt plugins. -
Android: Allowed
patchelfversion 0.18 on Android. -
Windows: Fix, the header path for self uninstalled Python was not detected correctly.
-
Release: Fix, inclusion of the
pkg_resourcesinline copy for Python 2 to source distributions was missing. -
UI: Detected the OBS versions of SUSE Linux better.
-
Suse: Allowed using
patchelf0.18.0 there too. -
Python 3.11: Fix, package and module dicts were not aligned close enough to avoid a CPython bug.
-
Fix, unbound compiled methods could crash when called without an object passed.
-
Standalone: Fix, multiphase module extension modules with postload. (Fixed in 4.0.8 already.)
-
Onefile: Fix, while waiting for the child, it may already be terminated.
-
macOS: Removed existing absolute rpaths for Homebrew and MacPorts.
-
Python 3.14: Avoided warning in CPython headers.
-
Python 3.14: Followed allocator changes more closely.
-
Compatibility: Avoided using
pkg_resourcesfor Jinja2 template location for loading. -
No-GIL: Applied some bug fixes to get basic things to work.
Package Support
-
Standalone: Add support for newer
paddleversion. (Added in 4.0.1 already.) -
Standalone: Add workaround for refcount checks of
pandas. (Fixed in 4.0.1 already.) -
Standalone: Add support for newer
h5pyversion. (Added in 4.0.2 already.) -
Standalone: Add support for newer
scipypackage. (Added in 4.0.2 already.) -
Plugins: Revert accidental
os.getenvoveros.environ.getchanges in anti-bloat configurations that stopped them from working. Affected packages arenetworkx,persistent, andtensorflow. (Fixed in 4.0.5 already.) -
Standalone: Added missing DLLs for
openvino. (Added in 4.0.7 already.) -
Enhanced the package configuration YAML schema by adding the
relative_toparameter forfrom_filenamesDLL specification, avoiding error-prone purely relative paths. -
Standalone: Fix,
flet_desktopapp assets were missing, now preserving the packaged runtime and sidecar DLLs. -
Standalone: Added support for the
tyropackage. -
Standalone: Added data files for the
perfettopackage. -
Standalone: Added support for
anyioprocess forking. -
Standalone: Added support for the
plotly.graphpackage. -
Anaconda: Fix, dependencies for the
numpyconda package on Windows were incorrect. -
Plugins: Enhanced the auto-icon hack in PySide6 to use compatible class names.
-
Standalone: Fix, Qt libraries were duplicated with
PySide6WebEngine framework support on macOS. -
Plugins: Fix, automatic detection of
mypycruntime dependencies was including all top level modules of the containing package by accident. (Fixed in 4.0.5 already.) -
Anaconda: Fix,
delvewheelplugin was not working with Python 3.8+. This enhances compatibility with installed PyPI packages that use it for their DLLs. (Fixed in 4.0.6 already.) -
Plugins: Fix, our protection workaround could confuse methods used with
PySide6.
New Features
-
UI: Added the
--recommended-python-versionoption to display recommended Python versions for supported, working, or commercial usage. -
UI: Add message to inform users about
Nuitka[onefile]if compression is not installed. (Added in 4.0.1 already.) -
UI: Add support for
uv_buildin the--projectoption. (Added in 4.0.1 already.) -
Onefile: Allow extra includes as well. (Added in 4.0.2 already.)
-
UI: Add
nuitka-project-setfeature to define project variables, checking for collisions with reserved runtime variables. (Added in 4.0.2 already.) -
Scons: Added new option to select
--reproduciblebuilds or not. (Added in 4.0.6 already.) -
Python 3.10+: Added support for
importlib.metadata.package_distributions(). (Added in 4.0.8 already.) -
Plugins: Added support for the multiprocessing
forkservercontext. (Added in 4.0.8 already, for 4.1 Python 3.6 and earlier, as well as 3.14 support were added too.) -
Reports: Added structured resource usage (
rusage) performance information to compilation reports. -
Reports: Included individual module-level C compiler caching (
ccache/clcache) statistics in compilation reports. -
Added support for detecting and correctly resolving the Python prefix for the
PyEnv on HomebrewPython flavor. -
macOS: Added support for
rusageinformation for Scons. -
UI: Added the
__compiled__.extension_filenameattribute to give the real filename of the containing extension module. -
Windows: Added support for
--clangor ARM. (Added in 4.0.8 already.) -
Windows: Added support for resources names as not just integers, important when we copy them from template files.
-
MacPorts: Added basic support for this Python flavor. More work will be needed to get it to work fully though.
Optimization
-
Avoid including
importlib._bootstrapandimportlib._bootstrap_external. (Added in 4.0.1 already.) -
Linux: Cached the
syscallused for time keeping during compilation to avoid loadinglibcfor each trace. (Added in 4.0.8 already.) -
UI: Output a warning for modules that remain unfinished after the third optimization pass.
-
Added an extra micro pass trigger when new variables are introduced or variable usage changes severely, ensuring optimizations are fully propagated, avoiding unnecessary extra full passes.
-
Provided scripts to compile Python statically with PGO tailored for Nuitka on Linux, Windows, and macOS.
-
Added support for running the Data Composer tool from a compiled Nuitka binary without spawning an uncompiled Python process.
-
Enhanced the usage of
vectorcallforPyCFunctionobjects by directly checking for its presence instead of relying purely on flags, allowing more frequent use of this faster execution path. -
Cached frequently used declarations for top-level variables to speed up C code generation.
-
Sped up trace collection merging by avoiding unnecessary set creation and using a set instead of a list for escaped traces.
-
Optimized plugin hook execution by tracking overloaded methods and added an option to show plugin usage statistics.
-
Improved performance of module location by avoiding unnecessary module name reconstruction and redundant filesystem checks for pre-loaded packages.
-
Improved the caching of distribution name lookups to effectively avoid repeated IO operations across all package types.
-
Plugins: Cached callback plugin dispatch for
onFunctionBodyParsingandonClassBodyParsingto skip argument computation when no plugin overrides them. -
Python 3.13: Handled sub-packages of
pathlibas hard modules. -
Handled hard attributes through merge traces as well.
-
Made constant blobs more compact by avoiding repeated identifiers and unnecessary fields.
-
Enhanced Python compilation scripts further. (Fixed in 4.0.8 already.)
-
Recognized late incomplete variables better. (Fixed in 4.0.8 already.)
-
Made constant blobs more compact. (Fixed in 4.0.8 already.)
-
Optimized calls with only constant keywords and variable posargs too.
Anti-Bloat
-
Fix, memory bloat occurred when C compiling
sqlalchemy. (Fixed in 4.0.2 already.) -
Avoid using
pydocinPySimpleGUI. (Added in 4.0.2 already.) -
Avoided using
doctestfromzodbpickle. (Added in 4.0.5 already.) -
Avoided inclusion of
cythonwhen usingpyav. (Added in 4.0.7 already.) -
Avoided including
typing_extensionswhen usingnumpy. (Added in 4.0.7 already.)
Organizational
-
UI: Relocated the warning about the available source code of extension modules to be evaluated at a more appropriate time.
-
Debian: Remove recommendation for
libfuse2package as it is no longer useful. -
Debian: Used
platformdirsinstead ofappdirs. -
Debugging: Removed Python 3.11+ restriction for
clang-formatas it is available everywhere, even Python 2.7, and we still want nicely formatted code when we read things. (Added in 4.0.6 already.) -
Removed no longer useful inline copy of
wax_off. We have our own stubs generator project. -
Release: Added missing package to the CI container for building Nuitka Debian packages.
-
Developer: Updated AI instructions for creating Minimal Reproducible Examples (MRE) to skip unneeded C compilation.
-
Debugging: Added an internal function for checking if a string is a valid Python identifier.
-
AI: Added a task in Visual Studio Code to export the currently selected Python interpreter path to a file, making it available as "python" and "pip" matching the selected interpreter. This makes it easier to use a specific version with no instructions needed.
-
AI: Updated the rules to instruct AI to only generate useful comments that add context not present in the code.
-
Containers: Added template rendering support for Jinja2 (
.j2) container files in our internal Podman tools. -
Projects: Clarified the current status and rationale of Python 2.6 support in the developer manual.
-
Debugging: Added experimental flag
--experimental=ignore-extra-micro-passto allow ignoring extra micro pass detection. -
Visual Code: Added integration scripts for
bashandzshautocompletion of Nuitka CLI options. These are now also integrated into Visual Studio Code terminal profiles and the Debian package. -
RPM: Included the Python compile script for Linux.
-
RPM: Removed the requirement for
distutilsin the spec.
Tests
-
Install only necessary build tools for test cases.
-
Avoided spurious failures in reference counting tests due to Python internal caching differences. (Fixed in 4.0.3 already.)
-
Fix, the parsing of the compilation report for reflected tests was incorrect.
-
Python 3.14: Ignored a syntax error message change.
-
Python 3.14: Added test execution support options to the main test runner to use this version as well.
-
Fix, the runner binary path was mishandled for the third pass of reflected compilations.
-
Removed the usage of obsolete plugins in reflected compilation tests.
-
Debugging: Prevented boolean testing of
namedtuplesto avoid unexpected bugs. -
Added the
Testsuffix to syntax test files and disabled "python" mode and spell checking for them to resolve issues reported in IDEs. -
Fix, newline handling in diff outputs from the output comparison tool was incorrect.
-
Covered
post-import-codefunctionality with a new subpackage test case. -
Prevented the program test suite from running an unnecessary variant to save execution time.
-
macOS: Ignored differences from GUI framework error traces in headless runs in output comparisons.
-
Reflected test for Nuitka, where it compiles itself and compares its operation has been restored to functional state.
-
Used the new method to clear internal caches if available for reference counts.
-
Disabled running nested loops test with Python 2.6.
-
Containers: Detected Python 2 defaulting containers in Podman tooling.
Cleanups
-
UI: Fix, there was a double space in the Windows Runtime DLLs inclusion message. (Fixed in 4.0.1 already.)
-
Onefile: Separated files and defines for extra includes for onefile boot and Python build.
-
Scons: Provided nicer errors in case of "unset" variables being used, so we can tell it.
-
Refactored the process execution results to correctly utilize our
namedtuplesvariant, that makes it easier to understand what code does with the results. -
Quality: Enabled automatic conversion of em-dashes and en-dashes in code comments to the autoformat tool. AI won't stop producing them and they can cause
SyntaxErrorfor older Python versions, nor is unnecessarily using UTF-8 welcome. -
Ensured that cloned outline nodes are assigned their correct names immediately upon creation, that avoids inconsistencies during their creation.
-
Quality: Updated to the latest versions of
blackand adopted a fasterisortexecution by caching results. -
Quality: Modified the PyLint wrapper to exit gracefully instead of raising an error when no matching files require checking.
-
Quality: Avoided checking YAML package configuration files twice, since autoformat already handles them.
-
Quality: Ensured that YAML package configuration checks output the original filename instead of the temporary one when a failure occurs.
-
Quality: Prevented pushing of tags from triggering git pre-push quality checks.
-
Quality: Silenced the output of
optipngandjpegoptimduring image optimization auto-formatting. -
Visual Code: Added the generated Python alias path file to the ignore list.
-
Quality: Enabled auto-formatting for the Nuitka devcontainer configuration file.
-
Watch: Avoided absolute paths in compilation to make reports more comparable across machines.
-
Quality: Changed
mdformatchecks to run only once and silently. -
Scons: Disabled format security errors in debug mode and moved Python-related warning disables into common build setup code.
-
Quality: Updated to the latest
deepdiffversion. -
Scons: Avoided MSVC telemetry since it can produce outputs that break CI.
-
Debugging: Enhanced non-deployment handler for importing excluded modules.
-
Split import module finding functionality into more pieces for enhanced readability.
-
Debugging: Added more assertions for constants loading and checking.
-
macOS: Dropped the
universaltarget arch. -
Debugging: Added more traces for deep hash verification.
Summary
This release builds on the scalability improvements established in 4.0, with enhanced Python 3.14 support, expanded package compatibility, and significant optimization work.
The --project option seems usable now.
Python 3.14 support remains experimental, but only barely made the cut, and probably will get there in hotfixes. Some of the corrections came in so late before the release, that it was just not possible to feel good about declaring it fully supported just yet.
05 Jun 2026 10:00pm GMT
Django community aggregator: Community blog posts
Browser Push Notifications for a Django Website

For DjangoTricks, and some other websites, I intentionally didn't set email notifications when a feedback message arrived - I didn't want to pay for an email server or spam my inbox. While checking the messages in the database from time to time, sometimes I found out about them too late.
Last weekend, I decided to implement Web Push notifications to get notified about the feedback in my OS, just like in this example:

This tutorial walks through adding Web Push notifications to a Django project from scratch. When a visitor submits a feedback form the site owner receives a native browser notification - even if the admin tab is closed - thanks to a service worker and a Huey background task.
How it works
Push Notifications work in such a way: at first, people who want to get notifications need to subscribe to the notifications in their browser. The subscribers are stored on the push notification servers and also their identifiers are stored in Django website database. Whenever we need to send the messages to those subscribers, we send them to push notification server that passes the message to all subscribers if their browsers are open at the moment. If the browsers are closed at that moment, the message's TTL (time to live) in seconds set long enough, and the message is not expired yet, they will get the message later.
The push service depends on the browser:
- Chrome - Google's FCM (Firebase Cloud Messaging) -
fcm.googleapis.com - Firefox - Mozilla Autopush -
updates.push.services.mozilla.com - Safari - Apple Push Notification Service (APNs)

The two standard Web Push prerequisites are a VAPID key pair (identifies your server to the push service) and a service worker (a background JS script that receives the push and shows the notification even when the tab is closed).
The workflow would be as follows:
- I as a superuser visit the Django administration page that has sw.js file with a service worker and a JavaScript to subscribe to notidications.
- JavaScript request Notification permission. I accept it.
- JS calls
pushManager.subscribe()with the VAPID public key. - JS POSTs the subscription to
/notifications/push/subscribe/. - Django stores it in PushSubscription model.
Visitor submits feedback form
- A view saves
FeedbackMessageto the database. transaction.on_commitqueues a Huey task.- Huey task calls
pywebpush- browser's push service (FCM / Mozilla). - Push service wakes the service worker.
- Service worker shows a native OS notification.
- Clicking it opens the Django admin change page.
Prerequisites
- Django project using Huey for background tasks
- Python virtual environment
Install two dependencies:
(.venv)$ pip install pywebpush py-vapid
pywebpushis the library to communicate with the Push Notification server.py-vapidwill only be needed once to generate keys.
You'll need HTTPS to test the Web Push notifications if your host is not 127.0.0.1. If you use a custom domain in your /etc/hosts, such as djangotricks.localhost, you will also need to set up HTTPS with mkcert.
Step 1. The Feedback App
Create feedback app with FeedbackMessage model that will store submitted feedback messages:
# myproject/apps/feedback/models.py
from django.conf import settings
from django.db import models
from django.utils.translation import gettext_lazy as _
class FeedbackMessage(models.Model):
created_at = models.DateTimeField(_("Created at"), auto_now_add=True)
submitter_name = models.CharField(_("Submitter name"), max_length=200)
submitter_email = models.EmailField(_("Submitter email")
content = models.TextField(_("Content")
class Meta:
verbose_name = _("Feedback Message")
verbose_name_plural = _("Feedback Messages")
ordering = ("-created_at",)
def __str__(self):
return _("Feedback message from {}").format(self.submitter_name)
Create the form:
# myproject/apps/feedback/forms.py
from django import forms
from django.utils.translation import gettext_lazy as _
from .models import FeedbackMessage
class FeedbackMessageForm(forms.ModelForm):
class Meta:
model = FeedbackMessage
fields = [
"content",
"submitter_name",
"submitter_email",
]
widgets = {
"content": forms.Textarea(
attrs={
"rows": 5,
"placeholder": _("Your message")
}
),
}
labels = {
"submitter_name": _("Your name"),
"submitter_email": _("Your email"),
}
Create a view to handle that form:
# myproject/apps/feedback/views.py
from django.db import transaction
from django.shortcuts import render, redirect
from django.urls import reverse
from .forms import FeedbackMessageForm
from .tasks import send_feedback_push_notification
def feedback_form(request):
if request.method == "POST":
form = FeedbackMessageForm(data=request.POST)
if form.is_valid():
message = form.save(commit=False)
if request.user.is_authenticated:
message.user = request.user
message.save()
transaction.on_commit(
lambda: send_feedback_push_notification(message.pk)
)
return redirect(reverse("feedback:complete"))
else:
form = FeedbackMessageForm()
return render(request, "feedback/form.html", {"form": form})
def feedback_complete(request):
return render(request, "feedback/complete.html")
Create the app config, migrations, templates, URLs, and Django administration for it.
Step 2. VAPID keys
Generate the key pair once. These stay on the server and are never committed to version control.
(.venv)$ vapid --gen
(.venv)$ vapid --applicationServerKey --private-key private_key.pem
--gen writes private_key.pem and public_key.pem to the current directory.
The private_key.pem file will contain the key like:
-----BEGIN PRIVATE KEY-----
<Multiline private key data>
-----END PRIVATE KEY-----
--applicationServerKey prints the base64url-encoded public key the browser needs, such as:
Application Server Key = <Public key data as base64url>
For the secrets.json or .env file where you store your secrets, you will need the content of <Private key data with newlines removed> and <Public key data as base64url>.
{
...
"VAPID_PRIVATE_KEY": "<Private key data with newlines removed>",
"VAPID_PUBLIC_KEY": "<Public key data as base64url>"
}
Don't commit the *.pem files or the secrets to the Git repo!
Step 3. Django settings
# myproject/settings.py
### WEB PUSH ###
VAPID_PRIVATE_KEY = get_secret("VAPID_PRIVATE_KEY")
VAPID_PUBLIC_KEY = get_secret("VAPID_PUBLIC_KEY")
VAPID_ADMIN_EMAIL = "admin@mydomain.com"
Step 4. The Notifications App
Create notifications app with the PushSubscription model to track the Push notification subscribers:
# myproject/apps/notifications/models.py
from django.conf import settings
from django.db import models
from django.utils.translation import gettext_lazy as _
class PushSubscription(models.Model):
"""One browser push subscription for one device."""
created_at = models.DateTimeField(_("Created at"), auto_now_add=True)
user = models.ForeignKey(
_("User"),
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="push_subscriptions",
)
endpoint = models.TextField(_("Endpoint"), unique=True)
p256dh = models.TextField(_("Browser ECDH public key"))
auth = models.TextField(_("16-byte auth secret"))
class Meta:
verbose_name = _("Push Subscription")
verbose_name_plural = _("Push Subscriptions")
ordering = ("-created_at",)
def __str__(self):
return f"{self.user} - {self.endpoint[:60]}"
Create app configuration, migrations, and Django administration for it.
Step 5. Subscribe / unsubscribe views
Create the views that will be called after the user subscribes or unsubscribes to Push notifications. Also a view for the service worker sw.js:
# myproject/apps/notifications/views.py
import json
from django.contrib.auth.decorators import login_required
from django.contrib.staticfiles import finders
from django.http import HttpResponse, JsonResponse
from django.views.decorators.http import require_POST
from .models import PushSubscription
@login_required
@require_POST
def push_subscribe(request):
try:
data = json.loads(request.body)
endpoint = data["endpoint"]
p256dh = data["keys"]["p256dh"]
auth = data["keys"]["auth"]
except (KeyError, json.JSONDecodeError):
return JsonResponse({"error": "Invalid subscription data"}, status=400)
PushSubscription.objects.update_or_create(
endpoint=endpoint,
defaults={"user": request.user, "p256dh": p256dh, "auth": auth},
)
return JsonResponse({"status": "subscribed"})
@login_required
@require_POST
def push_unsubscribe(request):
try:
endpoint = json.loads(request.body)["endpoint"]
except (KeyError, json.JSONDecodeError):
return JsonResponse(
{"error": "Invalid data"},
status=400
)
PushSubscription.objects.filter(
user=request.user,
endpoint=endpoint,
).delete()
return JsonResponse({"status": "unsubscribed"})
def service_worker(request):
"""Serve sw-feedback.js as sw.js at the admin root"""
path = finders.find("admin/js/sw-feedback.js")
with open(path) as fh:
content = fh.read()
return HttpResponse(
content,
content_type="application/javascript"
)
Step 6. Wire URLs into myproject/urls.py
Plug the notification views into URLs:
# myproject/apps/notifications/urls.py
from django.urls import path
from . import views
app_name = "notifications"
urlpatterns = [
path("push/subscribe/", views.push_subscribe, name="push_subscribe"),
path("push/unsubscribe/", views.push_unsubscribe, name="push_unsubscribe"),
]
The service worker URL must be mounted at the same prefix as ADMIN_URL so its scope covers the admin. Add both patterns before the admin.site.urls line:
# myproject/urls.py
from notifications import views as notifications_views
urlpatterns = [
# ...
path(
f"{settings.ADMIN_URL}sw.js",
notifications_views.service_worker,
name="admin_service_worker",
),
path(
"notifications/",
include("notifications.urls", namespace="notifications"),
),
path(settings.ADMIN_URL, admin.site.urls),
# ...
]
Step 7. Service worker JavaScript
Save as myproject/static/admin/js/sw-feedback.js:
self.addEventListener("push", function (event) {
const data = event.data ? event.data.json() : {};
const title = data.title || "New feedback message";
const options = {
body: data.body || "",
icon: "/static/admin/img/icon-yes.svg",
data: { url: data.url || "/" },
};
event.waitUntil(
self.registration.showNotification(title, options)
);
});
self.addEventListener("notificationclick", function (event) {
event.notification.close();
event.waitUntil(
clients.openWindow(event.notification.data.url)
);
});
Step 8. Admin subscription JavaScript
Save as myproject/static/admin/js/push-subscribe.js:
(function () {
"use strict";
// Injected by the Django template override in Part 9.
const VAPID_PUBLIC_KEY = window.VAPID_PUBLIC_KEY;
const SUBSCRIBE_URL = window.PUSH_SUBSCRIBE_URL;
const UNSUBSCRIBE_URL = window.PUSH_UNSUBSCRIBE_URL;
const ADMIN_URL = window.ADMIN_URL;
// Tracks the endpoint last registered on the server so we can delete it even
// when the browser subscription has already been silently revoked (e.g. user
// cleared site data or the push subscription expired without blocking).
const STORAGE_KEY = "pushSubscriptionEndpoint";
// Read the CSRF token from the hidden input injected by {% csrf_token %}.
// Do not use the cookie: this project has CSRF_USE_SESSIONS = True.
const CSRF_TOKEN = document.querySelector("[name=csrfmiddlewaretoken]")?.value || "";
function urlBase64ToUint8Array(base64String) {
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
const rawData = atob(base64);
return Uint8Array.from([...rawData].map((c) => c.charCodeAt(0)));
}
function post(url, body) {
return fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json", "X-CSRFToken": CSRF_TOKEN },
body: JSON.stringify(body),
});
}
async function syncSubscription() {
if (!VAPID_PUBLIC_KEY) return;
if (!("serviceWorker" in navigator) || !("PushManager" in window)) return;
const registration = await navigator.serviceWorker.register(
"/" + ADMIN_URL + "sw.js",
{ scope: "/" + ADMIN_URL }
);
await navigator.serviceWorker.ready;
const storedEndpoint = localStorage.getItem(STORAGE_KEY);
const existing = await registration.pushManager.getSubscription();
// The browser subscription was revoked (user blocked notifications, cleared
// site data, or the subscription expired) but the server record still exists
// - delete it using the endpoint we stored at subscription time.
if (storedEndpoint && (!existing || existing.endpoint !== storedEndpoint)) {
await post(UNSUBSCRIBE_URL, { endpoint: storedEndpoint });
localStorage.removeItem(STORAGE_KEY);
}
// Already subscribed and the server already knows about this endpoint.
if (existing && existing.endpoint === storedEndpoint) return;
const permission = await Notification.requestPermission();
if (permission !== "granted") return;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
});
await post(SUBSCRIBE_URL, subscription.toJSON());
localStorage.setItem(STORAGE_KEY, subscription.endpoint);
}
document.addEventListener("DOMContentLoaded", syncSubscription);
})();
localStorage is the key to reliable unsubscription. Notification.permission alone cannot detect silent revocations - when a user clears site data or a push subscription expires, the permission may still read "granted" while the browser-side subscription is gone. By storing the endpoint at subscribe time and comparing it on every page load, the script can call UNSUBSCRIBE_URL with the old endpoint even after the browser subscription object has disappeared.
Step 9. Context processor
A context processor injects the VAPID globals into every admin template response.
Create myproject/apps/notifications/context_processors.py:
from django.conf import settings
from django.urls import reverse
def push_notifications(request):
"""Inject push-notification globals into every admin page."""
if not request.path.startswith(f"/{settings.ADMIN_URL}"):
return {}
if not settings.VAPID_PUBLIC_KEY:
return {}
return {
"push_vapid_public_key": settings.VAPID_PUBLIC_KEY,
"push_subscribe_url": reverse("notifications:push_subscribe"),
"push_unsubscribe_url": reverse("notifications:push_unsubscribe"),
"push_admin_url": settings.ADMIN_URL,
}
Register it in the template settings:
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [
os.path.join(BASE_DIR, "myproject", "templates"),
],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
# ...
"myproject.apps.notifications.context_processors.push_notification_settings",
],
},
},
]
Step 10. Inject globals via admin/base_site.html
Override myproject/templates/admin/base_site.html. If one already exists, add the extrahead block; otherwise create the file extending Django's built-in template:
{% extends "admin/base_site.html" %}
{% load static %}
{% block extrahead %}
{# ... any existing content such as a favicon include ... #}
{% if push_vapid_public_key %}
{% csrf_token %}
<script nonce="{{ request.csp_nonce }}">
window.VAPID_PUBLIC_KEY = "{{ push_vapid_public_key }}";
window.PUSH_SUBSCRIBE_URL = "{{ push_subscribe_url }}";
window.PUSH_UNSUBSCRIBE_URL = "{{ push_unsubscribe_url }}";
window.ADMIN_URL = "{{ push_admin_url }}";
</script>
<script src="{% static 'admin/js/push-subscribe.js' %}"></script>
{% endif %}
{% endblock %}
Step 11. Huey task
# myproject/apps/feedback/tasks.py
import json
from django.conf import settings
from django.urls import reverse
from huey.contrib.djhuey import db_task
@db_task()
def send_feedback_push_notification(feedback_message_id):
from pywebpush import webpush, WebPushException
from feedback.models import FeedbackMessage
from notifications.models import PushSubscription
private_key = settings.VAPID_PRIVATE_KEY
if not private_key:
return
if not (message := FeedbackMessage.objects.filter(
pk=feedback_message_id
).first()):
return
admin_url = settings.WEBSITE_URL + reverse(
"admin:feedback_feedbackmessage_change",
args=(message.pk,)
)
content = message.content
body = content[:120] + (
"..." if len(content) > 120 else ""
)
payload = json.dumps({
"title": f"{message.submitter_name}:",
"body": body,
"url": admin_url,
})
dead_endpoints = []
for sub in PushSubscription.objects.all():
try:
webpush(
subscription_info={
"endpoint": sub.endpoint,
"keys": {"p256dh": sub.p256dh, "auth": sub.auth},
},
data=payload,
vapid_private_key=private_key,
vapid_claims={"sub": f"mailto:{settings.VAPID_ADMIN_EMAIL}"},
)
except WebPushException as exc:
# 410 Gone / 404 Not Found means the subscription has expired
if exc.response is not None and exc.response.status_code in (404, 410):
dead_endpoints.append(sub.endpoint)
if dead_endpoints:
PushSubscription.objects.filter(
endpoint__in=dead_endpoints,
).delete()
The Huey task is already called from the view in feedback app via transaction.on_commit. Using on_commit ensures the task is only queued after the database row is fully committed, so the task always finds the message when it runs.
Step 12. Content Security Policy
If the project uses Django CSP, two directives need adjusting:
CSP_WORKER_SRC = ["'self'"] # allows service worker registration from this origin
CSP_CONNECT_SRC = ["'self'"] # allows the subscribe POST fetch
The pywebpush HTTP call to the external push service (FCM, Mozilla) runs server-side inside the Huey worker process and is not subject to the browser's CSP.
User experience
It' safest to keep the body of the message up to 120 characters long - the rest will likely be cut on different OSes or browsers.
You can check the current status of notification permissions by:
Notification.permission // should be "granted", not "default" or "denied"
Based on that you can write a specific message in the User Interface what to do if the permission has been denied.
If you have many different types of notifications, you would set the configuration in a Django website. And let the visitor subscribe to the notifications in the browser once. Then your Huey tasks would check the notification settings and trigger send according messages to the subscribers.
For example, for DjangoTricks website, I would allow subscribing to new tricks, blog posts, and goodies in the notification configuration, and the visitors would grant permission to Web Push notifications just once.
Privacy and security
Messages themselves are stored on the Web Push servers encrypted, but back in the OS they are shown in plain text, and can be seen by people standing behind the user or possibly read out by other apps (or viruses) which have permissions to access OS notifications.
Practical recommendations for sensitive content:
- Send a generic notification ("You have a new message") and make the user open the app to see the actual content - this is what most banking/healthcare apps do.
- If you do include content, keep it minimal - avoid full message text, Personally Identifiable Information (PII), medical info, etc.
- Make sure your VAPID keys are securely stored and rotated if compromised.
- Set a short TTL (time-to-live) on the push message so it doesn't sit on Google's servers long if the device is offline.
For anything regulated (HIPAA, GDPR, financial data), the safest approach is the generic notification pattern, since you have no control over how the OS handles notification display and history once it leaves the browser.
OS permissions
For notification receivers, the permissions can be denied for the Browser globally as well.
One can set or unset them on MacOS at Settings ➔ Notifications ➔ Google Chrome (or another browser analogically) or on Windows at Focus Assist / Notification settings.
Marketing perspective
Opt-in: When asking for push notifications without context, only 5-25% would grant the permission. If the permission is asked in the UI at first and the reason is given, about 60% would grant the permission.
Opt-out: On average, nearly 8-10% of subscribers opt out from web push notifications per year. Even just 1 push notification per week leads to 10% of users disabling notifications. 46% of users disable notifications if they receive more than 6 notifications.
Final words
The technique of Web Push is not trivial, but with the help of py-vapid and pywebpush, it becomes manageable. The best use cases for push notifications are those where a SaaS project or a web platform suggests to use this technique intentionally: when waiting for something to happen, such as a new comment, a reply to a message, a new task to do from another person, or a new post of a favorite author who writes irregularly.
Cover picture by cottonbro studio
05 Jun 2026 5:00pm GMT
Issue 340: Django security releases 6.0.6 and 5.2.15
News
Django security releases issued: 6.0.6 and 5.2.15
Five CVEs are fixed in this latest release. As ever, perhaps the best security step you can take is to always update to the latest version of Django.
Updates to Django
Today, "Updates to Django" is presented by Hwayoung from Djangonaut Space! 🚀
Last week we had 13 pull requests merged into Django by 8 different contributors - including 4 first-time contributors! Congratulations to Vishwa, Tim Harris, Codequiver, and Joe Babbitt for having their first commits merged into Django - welcome on board!
This week's Django highlights: 🦄
- Deprecated the safe parameter of JsonResponse, as the browser vulnerability it protected against was fixed in ECMAScript 5. #36905
Releases
Python Release Python 3.15.0b2
Python 3.15.0b2, the second beta of four, is out with an explicit push for third-party maintainers to test now and file issues as early as possible. The release targets feature-complete beta with no ABI changes after beta 4, and recommends delaying production releases until 3.15.0rc1.
Python Software Foundation
PSF Strategic Plan 2026 Draft: Open for Community Feedback
PSF is publishing the full Strategic Plan 2026 draft and opening a three-week feedback window ending June 25. The board asks reviewers to focus on whether the goals and objectives are right, while implementation details will be shaped later by staff.
Sponsored Link
Django middleware composes request handlers. Harnesses do the same for AI agents - Claude Code, Codex, Gemini in one coordinated system. Learn what a harness actually is, why it's a new primitive, and how to engineer one that holds in production. Apache 2.0, open source.

Articles
Showcasing allauth IdP: build an MCP server | allauth
Learn how to use Django and django-allauth to secure MCP endpoints with OIDC, including token validation, client registration, and host authorization flows.
Django: introducing django-integrity-policy
From Adam Johnson, a new security header and detailed article laying out the "why."
Dependency Pruning
Tips on how to treat every lockfile entry as an attack surface and maintenance burden you do not want, then start by deleting dependencies you never import.
Loopwerk: uv is fantastic, but its package management UX is a mess
uv shines for Python toolchains, but its package maintenance UX is rough: there is no straightforward uv outdated, and the upgrade workflow (uv lock --upgrade) can aggressively pull in breaking major releases.
Python 3.15: features that didn't make the headlines
Python 3.15 beta highlights worth a look: TaskGroup.cancel for graceful cancellation, ContextDecorator fixing decorator lifecycles for async and generators, a new threading iterator helpers to avoid broken state, and immutable JSON support via frozendict and an array_hook.
Please add an RSS Feed to Your Site
RSS is still the cleanest way to keep up with the people you actually want to hear from. If you host a personal site with Django, add an RSS feed quickly with a simple, up-to-date tutorial and ship it.
Using Read the Docs to benefit Django
Read the Docs can integrate with EthicalAds, letting maintainers earn a little from their documentation.
The Pursuit Of Purity (The Right Way To Do AI)
A thoughtful look at competing takes on AI ethics, from safety-first big-lab work to open, locally run, consensually sourced models.
Django Forum
django-alauth 65.18.0 released: IdP demo time
django-allauth 65.18.0 was just shipped with a bunch of Identity Provider (IdP) improvements!
Daphne v4.2.2 release
Daphne v4.2.2 is now available on PyPI. It fixes a couple of moderate/low security issues and is a recommended update for all users.
Django Fellow Reports
Natalia Bidart
My primary focus this week was polishing the upcoming security release. I spent time going deeper into areas I am less familiar with to ensure everything was in good shape for release. As release manager, this included reviewing and completing release notes, preparing backports for all three supported stable branches, and crafting the corresponding CVE metadata so records are ready ahead of disclosure (this is part of our CNA responsibilities).
Sarah Boyce
I was at PyCon Italia this week, which was fantastic, highly recommend going if you get the chance.
Jacob Walls
After a Monday holiday in the US, I spent a week focusing on contributions from the prior week's PyCon sprint.
Events
PyBay 2026
October 3rd in San Francisco this year. The Call for Proposals (CP) is open until July 8th.
Django Job Board
Founding Engineer at MyDataValue
Projects
feincms/feincms3-cookiecontrol
Cookie banner with support for embedded media.
adamghill/dj-lite-tenant
Multi-tenant SQLite databases for Django.
05 Jun 2026 2:00pm GMT
03 Jun 2026
Django community aggregator: Community blog posts
Anything new?
Anything new?
A lot of time has passed since I officially announced that I want to step down from maintaining django-mptt. I started contributing around 2009, tagged the 0.3 release in April 2010, and have been the sole active maintainer since somewhere around 2019. The post about django-tree-queries has more background, but that's not today's topic.
Stepping away isn't easy
For me, abandoning a project is a bit like stepping out of a relationship: negative emotions end up being a somewhat necessary driver, because the absence of positive events alone rarely provides enough force on its own. I get a lot of satisfaction from a job well done, and walking away means letting that go.
Even with time set aside for open source in my work day, I still have to choose where that time goes. django-mptt stopped being where it needed to go.
The sense of entitlement
When a project is obviously unmaintained, asking for free labor is walking a tightrope. It takes real care not to rekindle exactly the frustrations that led maintainers away in the first place.
It takes energy not to clap back when someone is being rude or insensitive in the issue tracker. Asking "Anything new?" on a ticket where the next steps were outlined clearly and obviously nothing happened in the meantime is just one variant of this.
Quietly quitting isn't what I want to do - and as a user of django-mptt myself, I can't really do that either. Taking the high road is the professional choice. But it costs something.
I keep coming back to Mona Eltahawy on refusing to be civil. She's speaking about something quite different, and I'm aware I write this as a white man. The situations aren't the same at all. But she articulated something I haven't managed to put into words as well myself and I like the idea of speaking up and taking the fight to those who awaken these feelings instead of taking the high road.
Doing it with AI
No post these days is complete without the obligatory AI mention, but there's some relevancy to it.
I fixed and closed almost all open django-mptt issues in a two-hour Claude session. I've previously written about using LLMs for open source maintenance, and the productivity gain is real whatever the detractors say. And the quality isn't suddenly getting worse. Code wasn't perfect before either. The test suite allows a certain degree of trust in the result and according to my rules for releasing Open Source software we don't have to require more than that.
It doesn't change the underlying dynamic, though. rsync and outrage illustrates the trap neatly: Tridgell got flooded with AI-generated security reports, used AI to handle them, and then got criticized for using AI. The tools that created the workload aren't allowed to address it. The expectation is that the work has to involve sweat and tears and uncountable unpaid hours.
The common goal should be more and better open source software. What we get as Open Source maintainers is shit from both sides: One side took our free work and trained models on it without asking, the other side complains about the supposedly unethical use of AI while acting in unethical ways themselves.
There's something Kantian about how open source contribution gets framed. Kant's argument was that the only truly moral acts are those driven by duty and good will - not by desire, inclination, or any expectation of compensation. By that logic, I'm only acting morally if I keep going despite the burnout and the entitlement. If I stop, I'm not.
It's bleak. The problems with AI are real. The people controlling the large models are assholes. But I have to work in the world as it is while also trying to change it for the better.
03 Jun 2026 5:00pm GMT
22 May 2026
Planet Twisted
Glyph Lefkowitz: Opaque Types in Python
Let's say you're writing a Python library.
In this library, you have some collection of state that represents "options" or "configuration" for a bunch of operations. Such a set of options is a bundle of potentially ever-increasing complexity. Thus, you will want it to have an extremely minimal compatibility surface, with a very carefully chosen public interface, that is either small, or perhaps nothing at all. Such an object conveys state and might have some private behavior, but all you want consumers to be able to do is build it in very constrained, specific ways, and then pass it along as a parameter to your own APIs.
By way of example, imagine that you're wrapping a library that handles shipping physical packages.
There are a zillion ways to do it ship a package. There are different carriers who can ship it for you. There's air freight, and ground freight, and sea freight. There's overnight shipping. There's the option to require a signature. There's package tracking and certified mail. Suffice it to say, lots of stuff.
If you are starting out to implement such a library, you might need an object called something like ShippingOptions that encapsulates some of this. At the core of your library you might have a function like this:
1 2 3 4 5 |
|
If you are starting out implementing such a library, you know that you're going to get the initial implementation of ShippingOptions wrong; or, at the very least, if not "wrong", then "incomplete". You should not want to commit to an expansive public API with a ton of different attributes until you really understand the problem domain pretty well.
Yet, ShippingOptions is absolutely vital to the rest of your library. You'll need to construct it and pass it to various methods like estimateShippingCost and shipPackage. So you're not going to want a ton of complexity and churn as you evolve it to be more complex.
Worse yet, this object has to hold a ton of state. It's got attributes, maybe even quite complex internal attributes that relate to different shipping services.
Right now, today, you need to add something so you can have "no rush", "standard" and "expedited" options. You can't just put off implementing that indefinitely until you can come up with the perfect shape. What to do?
The tool you want here is the opaque data type design pattern. C is lousy with such things (FILE, pthread_*_t, fd_set, etc). A typedef in a header file can easily achieve this.
But in Python, if you expose a dataclass - or any class, really - even if you keep all your fields private, the constructor is still, inherently, public. You can make it raise an exception or something, but your type checker still won't help your users; it'll still look like it's a normal class.
Luckily, Python typing provides a tool for this: typing.NewType.
Let's review our requirements:
- We need a type that our client code can use in its type annotations; it needs to be public.
- They need to be able to consruct it somehow, even if they shouldn't be able to see its attributes or its internal constructor arguments.
- To express high-level things (like "ship fast") that should stay supported as we add more nuanced and complex configurations in the future (like "ship with the fastest possible option provided by the lowest-cost carrier that supports signature verification").
In order to solve these problems respectively, we will use:
- a public
NewType, which gives us our public name... - which wraps a private class with entirely private attributes, to give us an actual data structure, while not exposing the constructor,
- a set of public constructor functions, which returns our
NewType.
When we put that all together, it looks like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
|
As a snapshot in time, this is not all that interesting; we could have just exposed _RealShipOpts as a public class and saved ourselves some time. The fact that this exposes a constructor that takes a string is not a big deal for the present moment. For an initial quick and dirty implementation, we can just do checks like if options._speed == "fast" in our shipping and estimation code.
However, the main thing we are doing here is preserving our flexibility to evolve the related APIs into the future, so let's see how we might do that. For example, let's allow the shipping options to contain a concrete and specific carrier and freight method:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
|
As a NewType, our public ShippingOptions type doesn't have a constructor. Since _RealShipOpts is private, and all its attributes are private, we can completely remove the old versions.
Anything within our shipping library can still access the private variables on ShippingOptions; as a NewType, it's the same type as its base at runtime, so it presents minimal1 overhead.
Clients outside our shipping library can still call all of our public constructors: shipFast, shipNormal, and shipSlow all still work with the same (as far as calling code knows) signature and behavior.
If you need to build and convey some state within your public API, while avoiding breakages associated with compatibility churn, hopefully this technique can help you do that!
Acknowledgments
Thanks for reading, and thank you to my patrons who are supporting my writing on this blog. If you like what you've read here and you'd like to read more of it, or you'd like to support my various open-source endeavors, you can support my work as a sponsor.
-
The overhead is minimal, but it is not completely zero. The suggested idiom for converting to a
NewTypeis to call it like a function, as I've done in these examples, but if you are wanting to use this pattern inside of a hot loop, you can use# type: ignore[return-value]comments to avoid that small cost. ↩
22 May 2026 12:33am GMT
04 Apr 2026
Planet Twisted
Donovan Preston: Using osascript with terminal agents on macOS
Here is a useful trick that is unreasonably effective for simple computer use goals using modern terminal agents. On macOS, there has been a terminal osascript command since the original release of Mac OS X. All you have to do is suggest your agent use it and it can perform any application control action available in any AppleScript dictionary for any Mac app. No MCP set up or tools required at all. Agents are much more adapt at using rod terminal commands, especially ones that haven't changed in 30 years. Having a computer control interface that hasn't changed in 30 years and has extensive examples in the Internet corpus makes modern models understand how to use these tools basically Effortlessly. macOS locks down these permissions pretty heavily nowadays though, so you will have to grant the application control permission to terminal. But once you have done that, the range of possibilities for commanding applications using natural language is quite extensive. Also, for both Safari and chrome on Mac, you are going to want to turn on JavaScript over AppleScript permission. This basically allows claude or another agent to debug your web applications live for you as you are using them.In chrome, go to the view menu, developer submenu, and choose "Allow JavaScript from Apple events". In Safari, it's under the safari menu, settings, developer, "Allow JavaScript from Apple events". Then you can do something like "Hey Claude, would you Please use osascript to navigate the front chrome tab to hacker news". Once you suggest using OSA script in a session it will figure out pretty quickly what it can do with it. Of course you can ask it to do casual things like open your mail app or whatever. Then you can figure out what other things will work like please click around my web app or check the JavaScript Console for errors. Another very important tips for using modern agents is to try to practice using speech to text. I think speaking might be something like five times faster than typing. It takes a lot of time to get used to, especially after a lifetime of programming by typing, but it's a very interesting and a different experience and once you have a lot of practice It starts to to feel effortless.
04 Apr 2026 1:31pm GMT
16 Mar 2026
Planet Twisted
Donovan Preston: "Start Drag" and "Drop" to select text with macOS Voice Control
I have been using macOS voice control for about three years. First it was a way to reduce pain from excessive computer use. It has been a real struggle. Decades of computer use habits with typing and the mouse are hard to overcome! Text selection manipulation commands work quite well on macOS native apps like apps written in swift or safari with an accessibly tagged webpage. However, many webpages and electron apps (Visual Studio Code) have serious problems manipulating the selection, not working at all when using "select foo" where foo is a word in the text box to select, or off by one errors when manipulating the cursor position or extending the selection. I only recently expanded my repertoire with the "start drag" and "drop" commands, previously having used "Click and hold mouse", "move cursor to x", and "release mouse". Well, now I have discovered that using "start drag x" and "drop x" makes a fantastic text selection method! This is really going to improve my speed. In the long run, I believe computer voice control in general is going to end up being faster than WIMP, but for now the awkwardly rigid command phrasing and the amount of times it misses commands or misunderstands commands still really holds it back. I've been learning the macOS Voice Control specific command set for years now and I still reach for the keyboard and mouse way too often.
16 Mar 2026 11:04am GMT