07 Jun 2026

feedPlanet 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.

Preprocessor flow for mdbook

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:

  1. One in Python, to demonstrate how mdBook can invoke preprocessors written in any programming language.
  2. 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

feedPlanet 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.

  1. 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

feedPlanet 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

Package Support

New Features

Optimization

Anti-Bloat

Organizational

Tests

Cleanups

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

feedDjango 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:

Push Notification 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:

Push Notification subscription and sending sequence diagram

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:

Visitor submits feedback form

Prerequisites

Install two dependencies:

(.venv)$ pip install pywebpush py-vapid

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:

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: 🦄


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

Middleware, but for AI agents

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

feedDjango 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

feedPlanet 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
async def shipPackage(
        how: ShippingOptions,
        where: Address,
    ) -> ShippingStatus:
    ...

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:

  1. We need a type that our client code can use in its type annotations; it needs to be public.
  2. 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.
  3. 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:

  1. a public NewType, which gives us our public name...
  2. which wraps a private class with entirely private attributes, to give us an actual data structure, while not exposing the constructor,
  3. 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
from dataclasses import dataclass
from typing import Literal, NewType

@dataclass
class _RealShipOpts:
    _speed: Literal["fast", "normal", "slow"]

ShippingOptions = NewType("ShippingOptions", _RealShipOpts)

def shipFast() -> ShippingOptions:
    return ShippingOptions(_RealShipOpts("fast"))

def shipNormal() -> ShippingOptions:
    return ShippingOptions(_RealShipOpts("normal"))

def shipSlow() -> ShippingOptions:
    return ShippingOptions(_RealShipOpts("slow"))

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
from dataclasses import dataclass
from enum import Enum, auto
from typing import NewType

class Carrier(Enum):
    FedEx = auto()
    USPS = auto()
    DHL = auto()
    UPS = auto()

class Conveyance(Enum):
    air = auto()
    truck = auto()
    train = auto()

@dataclass
class _RealShipOpts:
    _carrier: Carrier
    _freight: Conveyance

ShippingOptions = NewType("ShippingOptions", _RealShipOpts)

def shipFast() -> ShippingOptions:
    return ShippingOptions(_RealShipOpts(Carrier.FedEx, Conveyance.air))

def shipNormal() -> ShippingOptions:
    return ShippingOptions(_RealShipOpts(Carrier.UPS, Conveyance.truck))

def shipSlow() -> ShippingOptions:
    return ShippingOptions(_RealShipOpts(Carrier.USPS, Conveyance.train))

def shippingDetailed(
    carrier: Carrier, conveyance: Conveyance
) -> ShippingOptions:
    return ShippingOptions(_RealShipOpts(carrier, conveyance))

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.


  1. The overhead is minimal, but it is not completely zero. The suggested idiom for converting to a NewType is 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

feedPlanet 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

feedPlanet 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