09 Jun 2026

feedPlanet Python

Real Python: Accessing Multiple AI Models With the OpenRouter API

One of the quickest ways to call multiple AI models from a single Python script is to use OpenRouter's API, which acts as a unified routing layer between your code and multiple AI providers. By the end of this course, you'll be able to access models from several providers through one unified API.

This convenience matters because the AI ecosystem is highly fragmented: each provider exposes its own API, authentication scheme, rate limits, and model lineup. Working with multiple providers often requires additional setup and integration effort, especially when you want to experiment with different models, compare outputs, or evaluate trade-offs for a specific task.

OpenRouter gives you access to thousands of models from leading providers like OpenAI, Anthropic, Mistral, Google, and Meta. You can switch between them without changing your application code.


[ Improve Your Python With 🐍 Python Tricks 💌 - Get a short & sweet Python Trick delivered to your inbox every couple of days. >> Click here to learn more and see examples ]

09 Jun 2026 2:00pm GMT

Real Python: Quiz: Embeddings and Vector Databases With ChromaDB

In this quiz, you'll test your understanding of Embeddings and Vector Databases With ChromaDB.

By working through this quiz, you'll revisit key concepts like vectors, cosine similarity, word and text embeddings, ChromaDB collections, metadata filtering, and retrieval-augmented generation (RAG).


[ Improve Your Python With 🐍 Python Tricks 💌 - Get a short & sweet Python Trick delivered to your inbox every couple of days. >> Click here to learn more and see examples ]

09 Jun 2026 12:00pm GMT

Real Python: Quiz: Accessing Multiple AI Models With the OpenRouter API

In this quiz, you'll test your understanding of Accessing Multiple AI Models With the OpenRouter API.

By working through this quiz, you'll revisit how OpenRouter provides a unified routing layer, how to call AI models from a single Python script, how to switch between intelligent routing and a specific model, how to prioritize providers, and how to add model fallbacks for reliability.

It also reinforces how to weigh trade-offs like cost, latency, and quality when you choose a model for your use case.


[ Improve Your Python With 🐍 Python Tricks 💌 - Get a short & sweet Python Trick delivered to your inbox every couple of days. >> Click here to learn more and see examples ]

09 Jun 2026 12:00pm GMT

feedDjango community aggregator: Community blog posts

Logical optimizations

The second article in the series. The first was about control flow; this one stays with the same tactic - reshaping code - one layer down, at the condition. Here: merging ifs, factoring shared decisions, and dropping checks that earn nothing. The Boolean algebra of conditions - De Morgan and friends - is a different lever, and gets its own installment next time.

Logical optimizations

09 Jun 2026 11:00am GMT

feedPlanet Twisted

Hynek Schlawack: How to Ditch Codecov for Python Projects

Codecov's unreliability breaking CI on my open source projects has been a constant source of frustration for me for years. I have found a way to enforce coverage over a whole GitHub Actions build matrix that doesn't rely on third-party services.

09 Jun 2026 12:00am GMT

05 Jun 2026

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

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