18 Sep 2026

feedPlanet Python

Graham Dumpleton: Writing a workshop for JupyterLab

Yesterday's post introduced jupyterlab-workshop and showed one page of a workshop. This one writes a whole workshop from an empty directory. It is small enough to fit in a post, but it has everything a real one has: a manifest, four pages, actions that drive the JupyterLab session, checks that run by themselves, a quiz, and the tooling that proves the workshop works before a learner ever sees it.

The subject is pytest. The workshop teaches the loop pytest is built around, which is write a test, watch one fail, fix the code, and it does that in four pages. Because pytest has to be installed, it is a chance to show how a workshop gets a Python environment of its own. It also lets me put the three content lessons from my PyCon talk to work, since a workshop format is only useful if it makes it easier to do those things well.

Plan the steps first

Before writing anything, decide the steps, and for each one decide what proves it was done. That second part is what distinguishes a workshop from a tutorial, and it also happens to be the constraint that keeps the steps small, because a step that ends in something checkable is rarely a big one.

For this workshop there are four. Set things up, which is done when pytest can be run. Write a first test, done when it passes. Add a second test that exposes a bug, done when the learner has predicted what pytest will say and then seen one test fail. Fix the bug, done when both tests pass. The prediction in the third step is the "change one thing and say what you think will happen" pattern from the talk, and the check on every page is the "verify after anything that can silently go wrong" one.

Scaffolding

The jupyter workshop command that comes with the package scaffolds a workshop directory:

jupyter workshop init pytest-first-steps --title "Testing with pytest"

That writes a manifest, two example pages to replace, a README, a .gitignore and an empty files/ directory. I threw the example pages away and made the manifest read:

apiVersion: jupyterlab-workshop/v1alpha1
name: pytest-first-steps
title: Testing with pytest
version: 0.1.0
description: Write a first test with pytest, watch a second one fail, and fix the code it caught.
tags: [python, pytest, testing]
duration: 20m
platforms: [linux, macos]
capabilities:
  - terminal
  - write-files
  - kernel-exec
  - install-packages
environment:
  requirements: requirements.txt
env:
  PYTHONDONTWRITEBYTECODE: "1"
layout: default
gating: soft
pages:
  - pages/01-set-up.md
  - pages/02-first-test.md
  - pages/03-a-failing-test.md
  - pages/04-fix-the-bug.md

The capabilities list is what the learner will be asked to trust when the workshop opens. This one runs commands in a terminal, writes files in the learner's workspace, runs code in a kernel, which the checks need, and installs packages. An action whose capability is not declared never runs, and the linter checks the list against what the pages actually use, in both directions.

The environment field is how the workshop gets pytest. The requirements.txt beside the manifest has one line in it, pytest, and the first page has an action that creates a virtual environment from it. The environment lives inside the workshop directory, so nothing else on the learner's machine changes, and once it exists it is first on the path in the workshop's terminals and in the checks, with no activation step anywhere on a page. A kernel is registered for it too, which a notebook workshop would use. That is far better than walking the learner through python -m venv and pip install, since the workshop is there to teach pytest, not how to create virtual environments.

The env entry guards against a problem that can arise when a file changes in quick succession, which in a workshop happens whenever a learner clicks rapidly through the steps. Python reuses cached bytecode whenever a source file's size and its modification time in whole seconds are unchanged, so an edit that swaps text for text of the same length, made within a second of the previous run, runs the old code. The self-test, which runs actions back to back, hits it even more readily. Turning bytecode caching off makes it go away. The other fields are less interesting. layout: default opens a terminal beneath the main area, and gating: soft means a learner can move past a page whose check has not passed, but is told so, and the page is not marked as done.

The one file the workshop ships to the learner is files/orders.py. Anything in files/ is copied into a work/ directory when the workshop first opens, and that is where the learner works, where the terminals start, and what restarting the workshop empties and refills. The function has a bug in it, on purpose:

"""Order totals for a small shop."""


def total(prices, discount=0):
    """Add up the prices and take off a percentage discount."""

    subtotal = sum(prices)

    return subtotal - discount

Page one: set up

A page is a Markdown file with YAML front matter, and the actions are fenced blocks with the action name in braces. Options are :name: value lines at the top of the block and the rest is the body.

---
title: Set up
requires: [verify:pytest-available]
---

# Set up

pytest is the test runner most Python projects use. In this workshop
you write a test for a small function, add a second test and watch it
fail, then fix the code the test caught.

The function is in `orders.py`, which is already in your workspace.
Open it and read it before going on.

```{file-open}
:path: orders.py
```

pytest is not part of Python, so the workshop needs an environment
with it installed. This creates one inside the workshop directory, so
nothing else on your machine changes.

```{environment-create}
:title: Create the workshop environment
```

Once it exists the workshop terminal picks it up. Confirm pytest is
there.

```{execute}
:id: pytest-version
:wait: prompt
python -m pytest --version
```

```{verify}
:id: pytest-available
:label: pytest is installed
:substrate: shell
:trigger: after:pytest-version
python -m pytest --version
```

The requires line in the front matter says the page is not done until the pytest-available check has passed. The file-open action opens the shipped file in the editor beside the panel, so the learner has read the function before being asked to test it. The environment-create action creates the virtual environment from the requirements file, with the text before it saying why that is needed. The check at the end of the page then confirms that pytest was installed into it, so progress past the page is gated on the environment actually working.

The execute action runs its body in the workshop terminal, and :wait: prompt makes it wait until the shell is back at its prompt before reporting that it is done, rather than the moment the command has been typed. The verify after it is triggered by that completion, so the check runs by itself once the learner has taken the step. The check runs its own command rather than relying on what the terminal did, so here it would pass at any point after the environment exists, but tying it to the step keeps it from running before the learner has got there. The check uses the shell substrate, meaning its body is run as a shell command and exit code zero passes. Its output becomes the message the learner sees with the check, which here is the pytest version.

Page two: the first test

---
title: The first test
requires: [verify:first-test-passes]
---

# The first test

pytest finds tests on its own: any file named `test_*.py`, and in it
any function named `test_*`. A test is a plain function that calls
the code and uses `assert` to say what it expects. This writes one for
`total()` and opens it in the editor.

```{file-write}
:id: write-test
:path: test_orders.py
:open: true
from orders import total


def test_total_adds_the_prices():
    assert total([10, 20]) == 30
```

Run it. The `-q` keeps the output to one character per test and a
summary line.

```{execute}
:id: run-first-test
:wait: prompt
python -m pytest -q
```

A dot is a pass. The check below runs the tests itself, so it passes
whether you clicked the command or typed it.

```{verify}
:id: first-test-passes
:label: The first test passes
:substrate: shell
:trigger: after:run-first-test
out=$(python -m pytest -q --color=no test_orders.py 2>&1) && { printf '%s\n' "$out" | tail -n 1; exit 0; }; printf '%s\n' "$out"; exit 1
```

file-write writes its body to a file in the workspace, and with :open: true shows it in the editor, so the learner sees the test appear rather than being asked to type it. Typing it yourself is more work, and work is where learning happens, but a typo in a file you were told to copy teaches nothing except frustration. Writing the file and opening it in front of them, then having them type the command that runs it, is a reasonable compromise if you want the learner doing some things by hand rather than clicking an action for everything.

The check is worth a closer look, because it is a pattern that repeats through the workshop. A shell check's whole output is its message, so the command shapes it. On success it prints only the last line of pytest's output, the 1 passed in 0.00s summary, and exits zero. On failure it prints everything pytest said and exits one, so the learner sees the failing assertion in the panel. The --color=no is there because escape codes in a check message are not helpful. And the check runs pytest itself rather than looking at what the terminal printed, which is the rule I would give anyone writing checks: check the outcome, not the keystrokes. It means the check passes whether the learner clicked the action, typed the command, or ran it some third way.

Page three: a failing test

---
title: A failing test
requires: [quiz:predict, verify:one-fails]
---

# A failing test

`total()` takes a `discount`, which its docstring says is a
percentage. Add a test that holds it to that: ten percent off thirty
should be twenty-seven.

```{editor-insert}
:id: add-discount-test
:path: test_orders.py



def test_discount_is_a_percentage():
    assert total([10, 20], discount=10) == 27
```

Before running it, say what you think pytest will report.

```{quiz}
:id: predict
:title: Predict the result
question: What happens when you run pytest now?
options:
  - { text: One test passes and one fails, correct: true }
  - text: Both tests fail, because orders.py is wrong
    explanation: The first test never uses the discount, so the bug never reaches it. A test only checks what it asks about.
  - text: pytest stops at the first failure
    explanation: pytest runs every test it finds and reports them all, unless you ask it to stop with -x.
explanation: Each test runs on its own and is reported on its own, so the bug shows up in exactly the test that exercises it.
```

Now run it.

```{execute}
:id: run-second-test
:wait: prompt
python -m pytest -q
```

Read the failure from the bottom up. The last line counts what passed
and what failed, and above it pytest shows the assert that failed with
the value on each side, so you can see the function returned 20 where
27 was expected.

```{verify}
:id: one-fails
:label: One test fails and one passes
:substrate: shell
:trigger: after:run-second-test
out=$(python -m pytest -q --color=no test_orders.py 2>&1); case "$out" in *"1 failed, 1 passed"*) echo "One failed, one passed, as expected"; exit 0;; esac; printf '%s\n' "$out"; exit 1
```

editor-insert adds its body to the end of the file that is already open in the editor, and the learner watches it appear.

The quiz is the point of the page. It comes before the command, and the page is gated on it, so the learner has to commit to an answer before they can find out. The wrong options are not filler either. Each one has an explanation that teaches something a learner who picked it did not know, which is the only reason to have a wrong option at all. That question is the whole trick I described in the talk. If they are wrong they find out in about two seconds, and now they understand that pytest runs each test on its own.

The check then confirms the learner is in the state the next page assumes, one test failing and one passing, and it does that by looking for pytest's summary line. The execute action before it reports a pass even though the command exits with status one, since the action's job was to type the command, and it is the check's job to say what the result should have been.

Page four: fix the bug

---
title: Fix the bug
requires: [verify:all-pass]
---

# Fix the bug

The function subtracts the discount as an amount. The docstring, and
now the test, say it is a percentage. Change the last line so the
discount comes off as a fraction of the subtotal.

```{editor-replace}
:id: fix-discount
:path: orders.py
:match: return subtotal - discount
return subtotal * (100 - discount) / 100
```

Run the tests again.

```{execute}
:id: run-again
:wait: prompt
python -m pytest -q
```

Two dots. The test that caught the bug now guards against it coming
back, which is what a test is for.

```{verify}
:id: all-pass
:label: Both tests pass
:substrate: shell
:trigger: after:run-again
out=$(python -m pytest -q --color=no test_orders.py 2>&1) && { printf '%s\n' "$out" | tail -n 1; exit 0; }; printf '%s\n' "$out"; exit 1
```

That is the loop pytest is built around: write a test that says what
the code should do, watch it fail, make it pass. From here, the
[pytest documentation](https://docs.pytest.org/en/stable/getting-started.html)
covers fixtures and parametrised tests, which are the next two things
worth knowing.

editor-replace finds the text named by :match: in the file and swaps it for the body, leaving the new text selected in the editor so the learner can see exactly what changed. For a one line fix that is a much better experience than telling someone to edit line nine, and it also means the check can be certain about what the file now contains. The page ends by saying what was learned and where to go next, which is where every last page should end.

Lint

With the four pages written, the linter reads the manifest and every page and reports what is wrong:

jupyter workshop lint pytest-first-steps

For the workshop above it prints 0 error(s), 0 warning(s), which is not much of a demonstration, so I broke a copy of it. I removed kernel-exec from the manifest, which the shell checks need, misspelt the :session: option on one of the commands, and made a typo in the action id that the last page's check is triggered by:

pages/02-first-test.md:27: warning: Option "sesion" is not used by the execute directive
workshop.yaml: error: Pages use the "kernel-exec" capability (4 actions) but the manifest does not declare it
pages/04-fix-the-bug.md:30: error: "all-pass" is triggered by unknown action "run-agian"
2 error(s), 1 warning(s)

The last of those is the one I would least like to ship. A check whose trigger names an action that does not exist still works when the learner runs it by hand, so nothing looks broken, it just never runs by itself the way the page says it will. That is exactly the kind of silent mistake a linter is for. The linter is about the mechanics: capabilities, options, checks that are malformed, ids that name nothing, variables used before the form that sets them, a few danger heuristics such as piping a download into a shell. It cannot tell though whether the workshop makes sense, and it cannot tell whether the commands work.

Self-test

That second thing is what the self-test is for:

jupyter workshop test pytest-first-steps

It copies the workshop to a temporary directory, starts a JupyterLab of its own on a free port, opens the workshop in a headless browser with trust settled, and then does what a learner would do, page by page. It runs every action in order, waits for each terminal command to finish, answers the quiz correctly, and runs every check. The output for this workshop is one line per action:

PASS 01-set-up/01-set-up-1 (file-open, 0.1s)
PASS 01-set-up/01-set-up-2 (environment-create, 14.9s)  Environment ready with kernel "workshop-pytest-first-steps-fb5f5348"
PASS 01-set-up/pytest-version (execute, 0.2s)
PASS 01-set-up/pytest-available (verify, 1.5s)  pytest 9.1.1
PASS 02-first-test/write-test (file-write, 0.1s)
PASS 02-first-test/run-first-test (execute, 0.3s)
PASS 02-first-test/first-test-passes (verify, 0.3s)  1 passed in 0.00s
PASS 03-a-failing-test/add-discount-test (editor-insert, 0.0s)
PASS 03-a-failing-test/predict (quiz, 0.0s)  Each test runs on its own and is reported on its own, so the bug shows up in exactly the test that exercises it.
PASS 03-a-failing-test/run-second-test (execute, 0.4s)  The command exited with status 1
PASS 03-a-failing-test/one-fails (verify, 0.3s)  One failed, one passed, as expected
PASS 04-fix-the-bug/fix-discount (editor-replace, 0.0s)
PASS 04-fix-the-bug/run-again (execute, 0.3s)
PASS 04-fix-the-bug/all-pass (verify, 0.3s)  2 passed in 0.01s

14 passed, 0 failed, 0 skipped

The fifteen seconds on the second line is pip installing pytest into the new environment. Everything else is near enough instant. What the self-test gives you is that a workshop which drifts, because a tool changed its output or a package changed its behaviour, is caught before a learner finds it, and jupyter workshop init --ci writes a GitHub Actions workflow that runs the same thing on every push.

One warning that the documentation makes at some length and I will repeat. The temporary copy protects the workshop's own files and nothing else. Every command runs as you, on your machine, with your home directory and your environment. This workshop stays inside its own directory, so running it locally is fine. A workshop that changes global git configuration or installs things into your project's environment is better tested in CI, where every run gets a fresh machine.

Author mode in JupyterLab

Everything above was done with a text editor and a terminal, which is how I prefer to work and how an AI agent works too. The pages can equally be written from inside JupyterLab, with the workshop open in the panel. Author mode is turned on from the panel header, the pencil icon in the screenshot below, and adds a toolbar and marks the workshop as your own, so saving a page never asks again how far the workshop is to be trusted.

The Workshop panel in author mode, showing the toolbar and a gutter under each action.

Edit page opens the page source beside the panel and saving re-renders it. Insert is a form for an action: pick the type, fill in its options, write the body, and the block lands at the cursor. Capture turns what you just did in the session, the last terminal commands, the files you saved and the cells you ran, into actions on the page, which is a much better starting point than an empty file. Run actions and Run checks do for the current page what the self-test does for the whole workshop, in the session in front of you. Lint lists the findings for the workshop and can apply the fix for the mechanical ones itself, such as declaring a capability that is missing. Record goes further than Capture and records a whole session into draft pages, one action per step with a placeholder paragraph to fill in.

Files remain the source of truth throughout. Author mode reads and writes the same workshop.yaml and pages/*.md that the command line does, so you can move between the panel, an external editor and git without anything getting out of step. The same tools are also available to an AI agent over MCP, and the wrapture workshops were written that way, but that is a post of its own.

Publishing

Once the self-test is green, jupyter workshop publish builds an archive:

jupyter workshop publish pytest-first-steps
wrote dist/pytest-first-steps-0.1.0.tar.gz
sha256 c7fa2db6c260130926478a779eabb4efb94149f7f2178f03a57983b694ddf745
wrote dist/pytest-first-steps-0.1.0.collection.json

The archive holds the manifest, the pages, the shipped files and the requirements, and leaves out the workspace and the workshop's runtime state. It is built with fixed ownership and timestamps, so the hash is the same on every machine and can be checked by whoever installs it. The collection entry is a JSON snippet describing the workshop for a collection index, which is the list of workshops a learner is offered to choose from. Where the archive and the index go, and the alternative of pointing an index straight at a git repository so there are no archives at all, is the subject of the next post.

What's to be learned

The workshop is seven text files. A manifest, four pages and a function to test, plus a requirements file with one line in it. The tooling checks the mechanics, that the capabilities match, that the options are spelt right, that every command runs and every check passes, and it does that in about a minute without a person involved.

What the tooling does not do is decide that the workshop should have four pages rather than two, that the quiz should come before the command rather than after it, or that each page should end with a check that runs by itself. Those are the three content lessons from the talk, and they still fall to whoever writes the workshop. What the format does is make them cheap. A check is a few lines under the command it confirms. A quiz is a few lines of YAML. A page is a file, so splitting a step in two is a matter of where you put the front matter. When the right thing is that easy to do, it is more likely to get done, and that is about all a tool can offer.

18 Sep 2026 4:39am GMT

Graham Dumpleton: Introducing jupyterlab-workshop

When I released the 24 wrapture workshops last week, they ran in JupyterLab on mybinder with the instructions in a side panel, and I said at the end of that post that the panel deserved a post of its own. The panel is jupyterlab-workshop, a JupyterLab extension I wrote the week before. The workshops were the reason it exists, and the first thing built with it. If you know I have spent years working on Educates and are wondering why I did not simply use that, there is a reason, and I come to it near the end.

The extension has documentation on ReadTheDocs and is on PyPI. The short description is that it separates the instructions for a workshop from the work. The instructions live in a sidebar panel, one page at a time. Each step on a page is a clickable action that does something real in the JupyterLab session beside it, whether that is running a command in a terminal, writing a file, creating a notebook and running its cells, executing code in a kernel, or arranging the window. The workshop can check what the learner has done, ask them questions, and hold a page until the checks pass. A workshop is a directory of Markdown files and a manifest, and it runs wherever JupyterLab runs.

Why a notebook isn't a workshop

JupyterLab is already a natural place to teach, and the usual way to do it is a notebook. You write paragraphs of explanation with code cells between them and hand it to the learner to run. That works up to a point, and then it doesn't.

The first problem is that the learner reads down the page pressing Shift+Enter, or picks Run All, and finishes having done nothing. The notebook did the work and they watched. That is the passive walkthrough I argued in Hands-on learning in the age of AI is exactly the kind of content that no longer needs a person to write it.

The second is that everything has to be a cell, in the notebook's one language. A lesson cannot ask for a shell command to be run, a file to be edited by hand, a second notebook to be created, or for anything JupyterLab itself does. If the thing being taught is git, or a command line tool, or a Python package that has to be installed into a virtual environment, or JupyterLab, a notebook can only describe it. It cannot be the place it happens.

The third is that the instructions and the work are the same document. What the learner ends up with is neither a clean set of notes nor a clean piece of work, and there is no way to tell, from either side, whether a step was done, done right, or skipped. A mistake early on that does not fail outright goes unnoticed until something much later fails for no obvious reason, and by then there is nothing pointing back to the cause.

Instructions beside the work

The extension adds a Workshop panel as a sidebar tab. It shows one page of the workshop at a time, with Previous and Next buttons, a progress bar, and a drop-down for jumping between pages. The rest of the window is an ordinary JupyterLab session with terminals, the file browser, the editor, notebooks and kernels, all of which the learner would be using anyway.

JupyterLab with a workshop open in the Workshop panel on the right, a launcher and a terminal in the main area, and a check in the panel that has turned green.

The screenshot shows the kinds of things a page can do. The first action is a run in terminal block, marked done, with the two commands it ran. Clicking it opened the terminal in the main area, in the right directory, and typed the commands in, so the learner did not have to find the terminal or type anything. Below it is a check, which ran on its own the moment the terminal showed the command and turned green when the file appeared. This one is also on a timer, so it keeps watching. If the learner deletes the file it goes red, and if they make it again by hand it recovers. The two actions after that open the file the command wrote in the editor and reveal it in the file browser. The badge at the top of the panel shows the workshop was opened as trusted, which I come to below.

None of it is simulated. When a page says run this command, clicking the action runs it in a real terminal, and the learner can just as easily type it themselves, or type something else and see what happens. Actions cover the terminal, files and the editor, notebooks and kernels, the interface and layout, and guidance such as hints and guided tours of the interface. A page can also say how the window should be arranged when it opens, so a lesson can start with a README rendered above a terminal and nothing else in the way.

If you have used Educates, the idea of clickable actions driving a session will be familiar. That concept carried over. The implementation did not, since this is a JupyterLab extension written from scratch to work with what JupyterLab already provides.

Checking the work

The part that makes it a workshop rather than a nicely formatted document is that a page can check what the learner has done. A verify block runs a check, which can be Python code run in a kernel of its own, separate from any the learner is using, a script run on the server, a shell command, or a list of predicates over files and the interface, such as a file existing, a file containing some text, a notebook cell having been executed, or a terminal being open. A quiz asks a question and a form collects values which can then flow into the commands and text on later pages.

A page can require some of those to have passed before the learner moves on, and the workshop manifest says whether that gating is advisory or enforced. Leaving a page with its requirements met marks it as completed, and the record of which pages have been completed is what the progress shown to the learner is based on. A checkpoint block snapshots the learner's files so a later page can put them back, which is how a workshop can have someone deliberately break something and then recover.

In the PyCon talk I said people quit at step eight because of a typo at step three, and that the fix is to have them verify their work after anything that can silently go wrong. Checks which run on their own the moment the terminal shows the expected output are that fix, built into the format rather than left to the author to remember.

Since a workshop can run commands on your machine, the learner is asked, before anything runs, how far to trust it. Every action type needs a capability, such as terminal or write-files, which the manifest has to declare, and an action whose capability is not declared never runs. When a workshop is opened the learner is told where it came from and what it wants permission to do, and chooses how much of that to allow. At the more cautious level, commands are typed into the terminal but not run until the learner presses Enter, and writing files or running code asks first. I will come back to that in a later post on deployment.

What a workshop is made of

A workshop is a directory. There is a workshop.yaml manifest with the name, title, capabilities and the ordered list of pages, a pages directory with one Markdown file per page, and a files directory holding whatever ships to the learner, such as starter code or data. A work directory is generated when the workshop first opens, filled from files, and that is where the learner works. Restarting the workshop throws the contents away and fills it afresh, so a learner can always get back to a clean start. Everything is plain text, so a workshop lives happily in git.

A page is MyST Markdown, and the actions are fenced blocks with the action name in braces. This is a complete page, taken from the documentation:

---
title: Your first commit
requires: [verify:first-commit]
---

Record the commit with a message describing the change.

```{execute}
git commit -m "Add README"
```

```{verify}
:id: first-commit
:label: You have made a commit
:trigger: terminal-output "Add README"
import subprocess
out = subprocess.run(["git", "log", "--oneline"], capture_output=True, text=True).stdout
assert out.strip(), "No commits yet: run git commit"
```

The execute block runs the command in a workshop terminal when clicked. The verify block runs its Python in the checking kernel, is triggered on its own when the terminal output contains the commit message, and the requires line in the front matter asks for it to pass before the learner can move on. That is about as much of the format as I want to show here. Writing a workshop from nothing is the subject of the next post.

Where it runs

Anywhere JupyterLab runs, which is the point. On your own machine it installs into a virtual environment alongside JupyterLab with uv add jupyterlab jupyterlab-workshop, or the pip equivalent. If you only want to do workshops rather than write them, uv tool install "jupyterlab-workshop[lab]" gives you a jupyter-workshop launch command that starts JupyterLab with the extension and presents the workshops it found for the learner to choose from, with nothing else to set up.

Running on your own machine also opens up a use that is not teaching at all. A workshop makes a good setup wizard. For software with a fiddly install, a project could provide a workshop that walks through it with clickable actions instead of a page of instructions to copy from, checking after each step that it worked. Since a page can run a command in the background, capture its output into a variable, and show or hide what follows on that value, the instructions can adapt to the machine they are running on, finding out which shell, package manager or Python is present and showing only the steps that apply.

For workshops other people will do, the repository holding them can carry a Binder configuration, and mybinder.org will build it into a temporary JupyterLab in the browser for anyone who clicks the link, with no account and no cost to anyone. That is how the wrapture workshops are hosted and I have no server, container image or cluster of my own behind them.

Since the wrapture posts went out, the same repositories have also gained a devcontainer, so they can be opened in GitHub Codespaces. That needs a GitHub account and uses the account's monthly Codespaces allowance, but where a Binder session is thrown away when it ends, a codespace is yours and persists, so a workshop can be finished across several sittings. Workshops can equally be shipped in a JupyterHub image, or built into a JupyterLite site, which is JupyterLab compiled to run entirely in the browser with a Python kernel in WebAssembly, so a workshop becomes a set of static files on GitHub Pages with no server at all. The extension runs there unchanged, doing in the browser what its server side would otherwise do. Those options deserve a post of their own and will get one.

Why not Educates

I have worked on Educates for years, and it remains the platform I would reach for when a workshop needs a Kubernetes cluster behind it, with several services, a database with data in it, or an environment already broken for the learner to diagnose. The wrapture workshops needed nothing like that. They needed a terminal, an editor and a Python virtual environment, and JupyterLab already provides all three.

The honest observation, which I will expand on in a later post about the challenges of getting Educates adopted, is that it requires Kubernetes and that has always limited who could pick it up. Large organisations either build their own platform or pay a vendor so there is someone to hold to a contract. Small teams and individuals are not going to take on running a cluster for the sake of delivering training. Turning Educates into a hosted service that people pay for would have meant starting a company, which is not something I wanted to do.

The idea of delivering the same guided experience as an extension to JupyterLab or VS Code is one I had many years ago and shelved. When I floated it with others it was generally dismissed, and getting the Jupyter community to engage on anything to do with training tooling has not been easy, so it stayed shelved. What AI has allowed me to do is finally loop back and build it, since bringing an idea like this to life is no longer the amount of effort it once was. That made it worth doing just to see whether it was possible, and if nobody else is interested, I have something I can use myself. Starting fresh also gave me the chance to explore new ideas in this space, which is not easy to do within the constraints of Educates as it stands.

The two are complementary rather than one replacing the other, and I suspect they appeal to different people. Educates suits an organisation with a training function and a cluster to run it on. A workshop that is a directory of text files, runs wherever JupyterLab runs, and can be hosted for free on mybinder or in the learner's own codespace, is something the maintainer of an open source project could provide for their own project without ever thinking about hosting. The same maintainer could use it for the guided install described above. The wrapture workshops are the worked example. One person, one library, 24 workshops, no infrastructure.

Try it

The quickest way to see it is the showcase collection, three short workshops which show what the extension does and why, in a full JupyterLab with a real terminal. Launch it on Binder or in Codespaces. The showcase repository is also the pattern to copy for publishing a collection of your own. If you would rather not wait for a build, there is a JupyterLite demo of one workshop running entirely in the browser, started afresh on every visit.

For something more substantial, the wrapture workshops are on Binder and Codespaces as well. The getting started page covers a local install and scaffolds a workshop of your own, and the tutorial writes a small one from nothing and publishes it.

As with wrapture, the extension was developed with the help of AI coding assistants, working to my design and direction, with me reviewing what they produced. The wrapture workshops themselves were largely written by an AI agent using the extension's own authoring tooling, which is a story for a later post too. If you would rather not use software produced that way, that is understood.

What's next

There are a few posts to follow. One on writing a workshop from scratch, one on the ways of getting workshops in front of people without running a server, one on writing them with an AI agent, and one on the challenges I ran into trying to get Educates adopted over the years and what I took from them, which were in part the catalyst for this extension existing at all. The problem of getting anyone to do a workshop once it exists is one I wrote about after PyCon and have no new answer to. If anything it may be getting harder, since I now lean on AI both to build the software and to write the workshops, and for many people that alone is reason enough to stay away. What I can do is make it as easy as possible to try, and that part is done. If you do try it, the issue tracker is where I would like to hear what worked and what didn't.

18 Sep 2026 12:00am GMT

17 Sep 2026

feedPlanet Python

Python Software Foundation: Announcing the 2026 PSF Board Election Results!

The 2026 election for the PSF Board created an opportunity for conversations about the PSF's work to serve the global Python community. We appreciate community members' perspectives, passion, and engagement in the election process this year.

We want to send a big thanks to everyone who ran and was willing to serve on the PSF Board. Even if you were not elected, we appreciate all the time and effort you put into thinking about how to improve the PSF and represent the parts of the community you participate in. We hope that you will continue to think about these issues, share your ideas, and join a PSF Work Group or PSF initiative if you feel called to do so.

Board Members Elect

Congratulations to our three new and one returning Board members who have been elected!

We'll be in touch with all the elected candidates shortly to schedule onboarding. Newly elected PSF Board members are provided orientation for their service and will be joining the upcoming board meeting in October.

Thank you!

We'd like to take this opportunity to thank our outgoing board members. Cheuk Ting Ho has been a super engaged PSF Board member, participating in many committees, and helping out on many PSF Programs and projects during her time on the PSF Board. Chris Neugebauer has been a longtime board member and in particular has been the watch guard of our bylaws conversations and has always been ready to share institutional knowledge. Denny Perez has been instrumental on the PSF Board, serving on the Executive Committee, as Treasurer, and on various committees during her tenure. All three of you helped shape the PSF's Strategic Plan for the next 5 years, which was a massive undertaking. Thank you, Cheuk, Chris, and Denny for your leadership and dedication to the PSF and the Python community. You will be missed and are deeply appreciated!

Our heartfelt thanks go out to each of you who took the time to review the candidates and submit your votes. Your participation helps the PSF represent our community. We received 670 total ballots, easily reaching quorum-1/3 of affirmed voting members (1123). We're especially grateful for your patience with continuing to navigate the additions to the elections processes with the inaugural Python Packaging Council election.

We also want to thank everyone who helped promote this year's board election, especially Board Member KwonHan Bae, who took the initiative to cover this year's election and worked with PSF Staff to conduct written interviews with candidates. This promotional effort was inspired by the work of Python Community News in 2023. We also want to highlight the PSF staff members and PSF Board members who put in tons of effort each year as we work to continually improve the PSF elections.

What's next?

If you're interested in the complete tally, make sure to check the Python Software Foundation Board of Directors Election 2026 Results page. These results will be available until November 10, 2026.

The PSF Election team will conduct a retrospective of this year's election process to ensure we are improving year over year. We received valuable feedback about the process and tooling. We hope to be able to implement more changes for next year to ensure a smooth and accessible election process for everyone in our community. If you have feedback or comments about this year's PSF Board election, we welcome you to join the discussion on discuss.python.org or email psf-elections@pyfound.org.

Finally, it might feel a little early to mention this, but we will have at least 3 seats open again next year. If you're interested in running or learning more, we encourage you to contact a current PSF Board member or two this year and ask them about their experience serving on the board.

17 Sep 2026 8:48am GMT

feedDjango community aggregator: Community blog posts

September Python Leiden meetup summaries

Two summaries from the September 2026 https://pythonleiden.nl/.

Maintaining Python packages to attract free and open source software contributors - Steve Piercy

Steve is in the Netherlands for next week's Plone conference in Maastricht (NL). He's been involved in open source software for over two decades.

What are contributors? Contributors to open source software? Why do you do it? Learning (I myself got a good programming education out of contributing to Zope/Plone while still at the university). You might want to give back. You might like the community, you like to belong to the community. Finding your tribe. Perhaps build a resume or CV. Help other people.

Why not? Negative online reactions. You want to really make money out of it. AI slop. You might not have time. Too busy. Life happens. You might enjoy walking your dog or doing gardening more. Or perhaps you just don't know how! Or you don't feel experienced enough (imposter syndrome). Perhaps you don't think your English is good enough (he advocates just using Google Translate).

(He asked how many people had already contributed to open source: about half the room raised their hands.)

There are actually lots of ways to contribute. It is not all code! Read this page for some examples. You can report security vulnerabilities or issues. You can comment on issues and help getting them forward. Review pull requests. Extend the documentation. You can join discussions in forums or on Matrix. Write a blog post on a project. And... you can financially sponsor a project.

(He again asked who had contributed: now most hands went up!)

Where to start? Start with what you use and what you find interesting. This helps staying involved and getting into the community more and more. He himself liked web applications and especially forms: making sure they're intuitive and safe and well-validated. And he liked documentation, so some of his first contributions were documentation fixes for open source projects.

Recently, he volunteered to take over maintainership of an open source project (a sphinx extension). There were lots of open pull requests and he asked the owners to look at it again and perhaps tweak it a bit and... everybody responded and a few weeks later he could make a new release with lots of fixes.

What is a maintainer? Well, basically maintainers are contributors who make releases. But ideally you also have to "tend your garden" and try to get your small community to thrive. One thing to keep in mind: you have to put your ego aside. If it is open source, it is really owned by your users, not by you. What do you have to do? Show up when someone has a question. Document your project (how to contribute, how to report bugs and security issues, etc.).

He especially mentioned https://djangonaut.space , that's a great initiative to help people get involved with contributing. Same with https://djangogirls.org/ .

As a maintainer, you might also have to look for funding. There are options like "github sponsors". But also NLnet (from the Netherlands) and the German sovereign tech agency that sponsor lots of projects.

A best practice of good maintainers is to have good tooling. Automatic tests + coverage. Build documentation. Formatting checks. zest.releaser for good releases.

The most important part of being a maintainer: how do you treat people.

There's one big problem: AI. There are just too many pull requests and too many issues. You can never get through them if your project is reasonably project. Dealing with it is hard. Several projects made the choice to disallow AI contributions. Also read https://leidendeclaration.ai/ , for the same problems are entering math and science, too.

But on the other hand... in the icalendar project that he helps maintain, they got a new contributor that made his first contribution based on AI. He liked that: the person got enough confidence through AI. They now have an AI guideline in their contributor guide now.

There's are automated tools on github that can help you check pull requests and identify slop-generating accounts, for instance. A tip when confronted with suspected AI work: ask vague, clarifying questions. Just ask for a bit of clarification in a vague way: AIs can't respond to vague questions, but humans can.

"Once men turned their thinking over to machines in the hope that this would set them free. But that only permitted other men with machines to enslave them." - Frank Herbert, Dune. Funny that he could write that 50 years ago.

Something mentioned in the discussion: people are now bidding on real github accounts in order to let AI bots use accounts that appear real...

Watch out with AI. One of the links he shared: https://pivot-to-ai.com/2026/09/08/students-who-dont-use-ai-are-ahead-on-every-measure/ . And, look at the kids: it was bad enough with social media, but AI is much worse. And freelancers are getting buried with soulless AI slop cleanup: artists get to clean up AI-generated images, for instance.

Choose wisely. A quote he wanted to give us to think about: teach the world you want.

Monitoring my washing machine - Michiel Beijen

Michiel has a 2021 Samsung washing machine that plays Die Forelle by Schubert when it is finished washing... But the washing machine sits in the garage so he can't hear the sound. It is a smart machine, so there's an iphone app for that! "SmartThings". But the list of data it wants to collect about you is horrendeous. And the app is almost 1GB! No...

There's also "home assistant", written in python. There's even a SmartThings API connection. But... from October 2026 onwards you need a $5/month samsung subscription!?!

There's also "Matter". They call it vendor neutral, standards compliant. Samsung supports it. But only for connecting Matter devices to SmartThings, not the other way around.

Next try: put a small camera in front of the washing machine's display, add a bit of OCR text recognition to detect the remaining time from the image and add an http interface to it. An old webcam couldn't provide a good camera. A raspberry Pi Camera 3 did the trick. (He later thought it would perhaps have been better to pick some old smartphone.)

He showed some graphs detailing how the washing machine estimates the time it will finish, including the adjustments it made halfway (adjusting for load or so).

Another approach that a colleague of him took: use a monitoring plug that measures the electricity used. Once the electricity usage drops off, the wash is finished.

17 Sep 2026 4:00am GMT

16 Sep 2026

feedDjango community aggregator: Community blog posts

Django: serve the change password well-known URL

When a password manager detects that a user's password has been leaked or reused, it can prompt them to change it, but the password change URL varies by site. The web's answer to such discovery problems is the reserved /.well-known/ URL namespace (RFC 8615), home to machine-readable files and endpoints like security.txt. A Well-Known URL for Changing Passwords is the web specification that uses this namespace to fix password page discovery. It reserves the URL path /.well-known/change-password to redirect to your actual change password page, wherever that lives.

Password managers that use this URL include Apple's iCloud Keychain (in Safari since 2019), Google Password Manager (since Chrome 86, 2020), and 1Password. web.dev has an excellent article explaining the specification and showing the Google Password Manager feature in action.

In this post, we'll look at implementing the change password URL in a Django project.

Add the redirect

The specification asks that /.well-known/change-password redirect to your change password page with a temporary redirect status code, for which you can use Django's RedirectView. The pattern_name argument looks up the target URL by name, so the redirect stays correct even if you move the page.

So, to add a redirect, plop this path in your root URLconf:

from django.urls import path
from django.views.generic import RedirectView

urlpatterns = [
    # ...
    path(
        ".well-known/change-password",
        RedirectView.as_view(pattern_name="password_change"),
    ),
    # ...
]

Note the path has no trailing slash, per the specification and counter to Django's default pattern. There's also no need for a name= argument, since nothing on your site should link to the URL.

password_change is the URL name provided by django.contrib.auth.urls, which serves Django's built-in PasswordChangeView. This assumes your URLconf includes those URLs, conventionally mounted at accounts/:

path("accounts/", include("django.contrib.auth.urls")),

If you serve your change password page some other way, swap in the appropriate URL name for pattern_name, such as account_change_password if you use django-allauth. Django doesn't check pattern_name until a request arrives, so a wrong name here fails only when the URL is visited, hence the test below.

Check with runserver and visit http://localhost:8000/.well-known/change-password - you should land on your change password page (or the login page redirecting you there with ?next).

Password managers only ever visit the URL on your live site (with HTTPS), so after deploying, repeat the check on your production domain.

For an end-to-end check, Chrome's password checkup tool (under SettingsPasswords) shows a "Change password" button for compromised entries, which should open your page directly once the redirect is deployed. To make the button appear without waiting for a real breach, temporarily change your saved password for the site to a deliberately weak one, like password123, which Chrome flags in the check.

Public users only

By the way, this feature is for public users and their password change pages, not admins. So don't use this feature to redirect to your Django admin's password change view, or any other private page. That would advertise those URLs to the world, undoing the common hardening of hosting Django's admin at a non-default path.

Check the resource that should not exist

There's a second URL to be aware of, a check for whether your server is broken. Some misconfigured servers respond with 200 to every request, serving an error page instead of using a proper 404 status code. On such a server, a client fetching /.well-known/change-password can't tell whether it found a real change password page or an error page.

The specification solves this with a second reserved path, gloriously named:

/.well-known/resource-that-should-not-exist-whose-status-code-should-not-be-200

Clients may request this URL to detect broken servers. If it responds with a 200, the server's status codes are deemed meaningless, so the client ignores the change password URL and falls back to something cruder, like opening your homepage.

Django responds with a 404 for unmatched URLs, so your site should pass this check, with nothing to implement. But a catch-all URL pattern could break it, such as one serving pages from a CMS, or a single-page application fallback that serves index.html with a 200 for any path. So it's worth covering with a test, included below.

Add tests

As ever, it's best to include tests to guard against accidental breakage, such as removal of the URL. Here's a test case covering both URLs:

from http import HTTPStatus

from django.test import SimpleTestCase
from django.urls import resolve


class ChangePasswordWellKnownTests(SimpleTestCase):
    """
    Test the well-known URLs for changing passwords, per:
    https://adamj.eu/tech/2026/09/16/django-change-password-url/
    """

    def test_change_password(self):
        response = self.client.get("/.well-known/change-password")

        self.assertRedirects(
            response,
            "/accounts/password_change/",
            fetch_redirect_response=False,
        )
        resolve(response["Location"])  # Check it's a real URL

    def test_resource_that_should_not_exist(self):
        response = self.client.get(
            "/.well-known/resource-that-should-not-exist-whose-status-code-should-not-be-200"
        )

        self.assertEqual(response.status_code, HTTPStatus.NOT_FOUND)

Notes:

  • Neither view uses the database, so the test case uses SimpleTestCase, which blocks database access and runs a little faster.
  • assertRedirects checks both the status code, 302 by default, and the target URL.
  • The target URL is hardcoded, matching where the auth URLs were mounted earlier. If yours live elsewhere, adjust it. Hardcoding, rather than using reverse(), makes the test check what clients see, rather than using any internal details of your system.
  • Passing fetch_redirect_response=False stops the test client from following the redirect. Without it, assertRedirects would fetch the change password page and fail, because when logged out that page responds with a second redirect, to the login page.
  • resolve() raises Resolver404 if the target URL doesn't map to a view. Calling it makes up for the skipped fetch above, checking that the redirect points at a real page rather than a typo.

Check your form's autocomplete attributes

The web.dev article also recommends annotating your change password form fields with autocomplete attributes, so password managers can fill in the current password and suggest a generated replacement:

  • autocomplete="current-password" on the current password field
  • autocomplete="new-password" on the new password field(s)

If you use Django's built-in PasswordChangeForm, it's done for you, as the widgets there have included these attributes since Django 3.0.

But if you've built a custom form, it's worth checking that its fields carry the right attributes. You can set them through the attrs argument of each field's widget, for example:

from django import forms


class ChangePasswordForm(forms.Form):
    current_password = forms.CharField(
        widget=forms.PasswordInput(attrs={"autocomplete": "current-password"}),
    )
    new_password = forms.CharField(
        widget=forms.PasswordInput(attrs={"autocomplete": "new-password"}),
    )
    ...

If you're customizing Django's flow, prefer subclassing PasswordChangeForm, which carries those attributes already.

Fin

So there we go, a nice little standard to make your user's security a little easier. Add one URL entry and password managers can shepherd your users away from compromised passwords.

May your data never leak and your users passwords always be strong,

-Adam

16 Sep 2026 4:00am GMT

15 Sep 2026

feedDjango community aggregator: Community blog posts

Duff's device, part 2: copying within an array

Duff's device in JavaScript raced hand-written loops that copy one array into another. A reader asked the follow-up: how do they compare with Array#copyWithin, the built-in that copies a range inside a single array? That is a different workload, so it needs its own measurement. The short answer: on every Node and Deno we can install today, the built-in runs 47 to 84 times slower than the loop.

Duff’s device, part 2: copying within an array

15 Sep 2026 10:00am GMT

06 Sep 2026

feedPlanet Twisted

Glyph Lefkowitz: ... but what about video games?

I get asked this rhetorical question a lot, in various forms:

Sure, datacenters might use a lot of energy, but you don't have to use a hosted frontier model to do software development. What if I just run a local open-weights model to do some coding, with an open-source coding agent? Video games also use my GPU. Is local model development any worse than playing a video game?

So I want to write down my comprehensive answer to this: Yes, using an LLM to write some code is worse than playing a video game, for a few reasons.

Video Games Are Interactive, LLMs Are Batch Jobs

Video games use compute to respond to human input. You are using your GPU while you are looking at a screen, displaying an image. When you are done playing, you shut off the game, and your computer goes back to idle. It's much less energy. By contrast, agentic loops with evals (the only kind of "AI" that is meaningfully any good at coding) are running hot, for days. To use the most recent example of such a thing, a very rough first sketch of an implementation of a Windows graphics API backend to help port a paint program to other platforms, it took 3 weeks of Claude time, "day and night". Do you play a lot of video games for 500 hours to make it past the tutorial level, while also using other computers for other things, as well as the rest of your carbon footprint?

Video Games Need Development, LLMs Need Training

Video games use compute to respond to human input during development, too. Your game has to be made, but your LLM has to be trained. LLMs use a historically extreme amount of power, probably using more than the entire Internet, but it's kind of hard to say. Still, it seems a reasonable estimate to within several orders of magnitude that even over a multi-year project with hundreds of developers, the power used to develop an individual video game is nowhere close to training even a small LLM.

This is true even for local models. OpenAI has openly claimed that DeepSeek "stole its intellectual property", and I have heard grumblings that none of the open-weights generalist models could realistically exist without the massive lift that the frontier labs are doing with their training, in various other ways too. Secrecy throughout the industry makes this kind of impossible to understand rigorously, but it seems fair to say that you are partially culpable for all that famously energy-intensive frontier lab training if you're using a local model.

And They Keep Needing Training

You also can't dismiss this as a sunk cost, because in order to stay current with industry developments, models need to be updated with new information from the rest of the world, which means that you need to keep training them. Beyond the energy for your own use, if you want a real-life agentic workflow that actually does useful stuff, practically speaking you would still need to update your local models over and over again, at least once every few months, which means you would be incentivizing continued energy consumption by whoever was doing that training for you, including the energy cost of scraping.

Let's Be Real Here, You Aren't Actually Using A Local Model

This question is a hypothetical thought experiment. Despite synthetic benchmarks that keep showing there isn't much difference between open weight and frontier models, nobody's actually using local models for much of anything beyond sharing those talking points. Depending on which benchmark you're looking at, maybe it's good enough or maybe it's worse.

As an inveterate AI hater, all these systems seem pretty bad to me, but it seems that people who find them useful tend to subjectively believe the frontier models are worth the premium, and that's what they're actually using. Once you have accepted that it is OK to use LLMs for coding at all, it seems like a very quick slippery slope on down to "we'll go ahead and use the frontier models for now anyway, but we could be ethically better in the future by switching to an open weights one, that option is always available".

There's A Reason We Have Data Centers

Devolving power usage to local LLMs might be good to make users responsible for their costs and decrease the impacts to communities that are physically next to huge concentrations of power utilization, not to mention generation. However, there's a reason that it makes sense for the providers to build these giant facilities: economies of scale reduce total power consumption, they don't increase it. If you do all the same stuff with a local model that they have to do in hosted environments, it will probably take more power, even though you will be incentivized to do different stuff. This incentive to "do different stuff" is why although local models can hypothetically hold their own against the frontier labs for some tasks, when people or businesses take their inference costs in-house they often find that it's too painful and move back to hosted LLMs.

There Are Problems Other Than Power

These are subjects for a different post, but you have to consider a lot of other externalities: AI psychosis, de-skilling, comprehension debt, cultivating a dependency, introducing security defects, limiting your design space based on what LLMs can understand, context rot, wasting time on invalid solutions, introducing unpredictability into your workflows. You still have to consider the total cost benefit ratio.

To Sum Up

Local LLMs might alleviate some of the harms from using the hosted frontier providers. There are fewer privacy concerns, you can measure your power utilization and be more directly responsible for it, you can build interfaces with affordances that are less oriented towards addiction and dependency than the major frontier labs' harnesses.

But they're not automatically "the same as playing a video game" just because they can use the same GPU.

Acknowledgments

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!

06 Sep 2026 10:57pm GMT

06 Aug 2026

feedPlanet Twisted

Hynek Schlawack: Production-ready Python Docker Containers with uv

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

06 Aug 2026 12:00am GMT

23 Jun 2026

feedPlanet Twisted

Glyph Lefkowitz: Adversarial Communication

As I have discussed in previous posts, "AIs" can make mistakes. In fact, they do make mistakes, and their mistake-making patterns are such that where and how they will make mistakes is both uncertain and constantly changing.

Thus, in any scenario where you want to attempt to make "productive" use of "AI", you must have a system in place for checking every result. Not checking some results; checking every result. If each result might have a consequence for you (and if it didn't have a consequence, why bother automating it?) and you cannot predict in advance which kinds of results will need verification, then verification is always required.

The verification often ends up being just as expensive as doing the work in the first place, which means that if you want your usage of "AI" to be personally profitable, you have to find someone else to externalize the cost of verification onto. This person becomes your adversary, and, if you are successful, your "AI's" victim.

The Ladder-Climber And Their Reverse-Centaur Rungs

One way that this constellation of facts can straightforwardly assemble themselves into a dystopian nightmare is the phenomenon, described by Cory Doctorow, of the reverse centaur. This is when your employer non-consensually turns you into the verification system. The "AI" does the fun part of initially performing the work, and then you do the boring part where you check if the robot is right and clean up its messes, even if everyone already knows that it would, in aggregate, be cheaper for you to do the work in the first place.

Reverse centaurs can be made from any automation, not only "AI" automation. I think that there is a reason that this term happens to have emerged in the "age of AI", though, and not with earlier automation technologies (even those which were considerably more viscerally horrific). That reason is: the wrongness of "AI" output is not merely a technical feature that must be compensated for, it is a generalized externality.

As I mentioned above, if you are responsible for the entirety of the work, both extruding the "AI" output and checking it, it's usually cheaper to have humans do the entirety of the work to begin with. When humans do the writing directly, we can check as we go, and thus verification doesn't need to be as comprehensive.

When "AI" coding advocates say "code review is the bottleneck", what they are observing is that the LLM is still rolling the dice for each PR, and a human is still necessary to verify that each of those rolls is a winner. But calling this process "code review" is a bit of a misnomer; it's not really "code review" in the traditional sense, it's human understanding.

Before the advent of "AI", the human understanding was implicit in the process of writing the code in the first place1, and the code review was a way of diffusing and extending that understanding. Now that the code can be authored with no initial understanding taking place, that cost has not gone away, it has moved.

Human understanding was always the bottleneck.

However, this is taking a collaborative view of a software project, where satisfying the needs and solving the problems of your customers are the goals. We can see that "AI" is a bad tool to satisfy those goals, because all it's doing is converting the first half of the work, that of understanding the code as you write it, to understanding the agent's output as you read it.

What if, instead, we were to take the view that every software company is a Hobbesian nightmare, red in tooth and claw? In this view, the only goal of a software project is for the individual developers to make their promo cycles and get their bonuses. Given that there is only a certain amount of money to go around, this is a zero-sum game where each programmer wants to look more productive than their colleagues.

Pretty much every organization finds it easy to reward "productivity" as expressed by lines of code emitted, but the benefits of doing thorough and thoughtful design, analysis, and code review very difficult to reward. In this world, an LLM is an invaluable tool for the sociopathic ladder-climber, particularly if your legacy organization is still structuring their workflows as if the person prompting the bot is "writing" the code, and then they get to foist off the act of "reviewing" the code onto someone else.

Here, the prompter effectively externalizes the cost of the LLM's failures but internalizes any benefits. The prompter will vibe-code a big feature, so large that the assigned reviewer can't possibly comprehend it all effectively. When this happens, the reviewer will, eventually, be pressured to approve it, even if they can try to spot a few problems along the way. The reviewer has their own work to get back to, after all, the obligation to review the prompter's (read: the bot's) code is a drain on their time that they are not going to get rewarded for.

If this feature is a big success, the prompter gets a promotion. If it causes a big issue, well, the reviewer must not have been careful enough.

This is why LLMs are "good for coding", and also why their biggest promoters keep having outages.

The Generative Gish Galloper

Coding is the biggest "success story" of this type of adversarial communication, but it is by far not the only instance of such a thing. LLMs create a new form of leverage that can turn Brandolini's law from a linear advantage into an exponential one. If you are engaged in a political debate where you want to overwhelm the other side in nonsense, an LLM can generate bullshit faster than it is physically possible for a human being to type, let alone respond thoughtfully. There is an asymmetry to the utility of this weapon as well: only one side of the political spectrum wants to flood the zone and destroy trust in institutions and the concept of truth. There's a good reason that the fascists love it.

Straightforward Spam and Fraud

This is kind of obvious, but LLMs can generate lightly-customized, plausible-looking text much more quickly than any human being. This facilitates their use in fraud, spam, and scams. In a spamming or fraudulent interaction, once again, the costs are externalized onto the victim: the recipient of a spam message has to do all the work of "checking" the LLM's output. Spammers already expect very low hit rates from boilerplate, and if the LLM can increase those percentages from 1% to 5% the technology will pay for itself; they don't need anything like reliable accuracy.

Customer "Support"

If you have any kind of commercial relationship with a company, I probably don't even need to mention this: customer "support" bots are a misery. Everybody knows it at this point. But customer support is usually conceptualized by businesses as an adversarial interaction, because it is a cost center. They maintain internal metrics on time-to-resolution and try to optimize them. Implicitly, this creates a dynamic where the goal of the customer service agent's job is not to solve your problem, but to emit noise that will cause you to think your problem is resolved, or to give up, as fast as possible. Unsurprisingly, LLMs can emit this noise faster than humans can, getting those customers off the phone. But those customers will remember those interactions, and the story outside the TTR metrics is horrible.

Similarly to the situation in software development, LLMs can look very good on paper for customer support, but mostly what they are doing is illuminating the problems with the industry's existing metrics, by turning "winning the metrics battle against the customer" into a more obvious and immediate defeat for the company's long term reputation.

"Education"

In 2026 it is sadly a fact of life that students cheat all the time using "AI", and that this cheating is very successful, in that the teachers find it very hard to detect.

LLMs are great for cheating on schoolwork because the student is externalizing the work of the checking onto the teachers, who are often starting at a disadvantage to begin with, at least in the US.

My view is that this is happening because of a divergence in the way that students vs. teachers (or, more accurately, "the broader educational system") view grading.

When a student is asked to write an essay, the teachers see the effort as both intrinsically worthwhile for the student, as well as useful as a pedagogical tool to evaluate and react to the student's progress. The student, by contrast, sees a stumbling block designed to knock them off the path to success and into a permanent underclass. It is no wonder that the student sees "AI" as useful to their own goals and has no compunction about deploying it.

There is a bitter irony that the ability to understand the inherent value of actually writing the essay on their own is the sort of thing that students can really only learn by writing a bunch of essays. There's no way that I can think of which makes the benefit legible as long as a shortcut is available.

The net effect here is a downward spiral, where the already-wobbling educational system is sustaining an attack that it doesn't have the resources to recover from. The individual students' attacks against their teachers and their schools' grading systems might appear to momentarily succeed, but they will win the battle and lose the war.

Spamming "For Good"?

Usually when we talk about someone unilaterally choosing to enter into an adversarial relationship, that's an "attack" and for good reasons we have a negative impression of the attacker. However, I would be remiss if I did not point out that there are some cases where the relationship was already adversarial; just because you're the attacker doesn't mean that you are evil.

For example we might imagine use-cases like automatically filing appeals for prior authorizations against health insurance. It's relatively well-known at this point that the main way for-profit insurers maintain their margins is by denying claims right up to the line of the policies themselves being fraud, so using a spamming tool to fight them might be entirely justifiable2 in that case.

Similarly, using an LLM could be justified in a fight against a company refusing to honor a warranty. One could imagine using an LLM to immediately generate replies and escalations.

However, even in imagined cases like these, the underlying problem is that the insurers and the vendors already have a tremendous amount of structural power, so it is more likely that they will have the advantage in deploying a communications weapon like an LLM, as well as enacting policies to simply ignore any LLM-based communication that you might submit. Worse, if these strategies were to become widespread, they might provide an excuse to reject any communications by feeding them into an unreliable "LLM detector" and issuing an automated "computer says no" even to hand-written correspondence.

It is also worth stressing that these cases are imagined, as compared to the very real coworker-abuse, spam, scam, fraud, and disinformation campaigns being waged in real life today.

Therefore, while legitimate uses might exist, it's hard to imagine that there's anywhere they would be genuinely valuable and sustainable. In the best case "AI" will provide a temporary advantage for underdogs that will provoke an arms race which the resource-advantaged adversaries will win in the long run, in the worst case the arms race itself will cement permanent structural change that will make things worse.

"Search" By Stealing

Most of the adversarial utility of "AI" is on the "write" side, since write-amplification is more obviously aggressive than reading. But the "read" side of LLMs - summarization and question-answering - can be a form of attack as well.

To begin with, the act of reading itself is currently enormously destructive, but that's arguably not a fundamental aspect of this technology. They could set reasonable rate-limits and respect things like robots.txt, as search engines have for decades now. They could also refrain from committing criminal levels of copyright infringement. But, today, using "AI" tools does suborn this sort of out-of-control crawling.

More insidiously, consider the scenario described in this YouTube video. The LTT Bros decided to try Linux again, and in the course of so doing, they had problems. When trying to solve these problems, they were faced with a choice: they could consult Reddit, or they could ask an LLM. Asking an LLM would "gaslight the heck out of" them, but they still found it preferable, because they would at least get an answer without getting yelled at.

Initially this sounds great. But it also means that you want to extract knowledge from a community, while mechanically eliding any values or norms that the community may want to impart as part of offering that knowledge. As someone who spent many years in a community tech support role, this is worrying. Many requests for support are people asking how to do things that will momentarily solve a superficial problem but create a long-term reliability problem or even an immediate security risk, that the question-asker doesn't want to hear about. Consider the question "I'm tired of entering my password so much, how do I make it so my laptop unlocks automatically". An obsequious chatbot will helpfully tell you how to do this without pushback.

But, this is also a sort of ethically murky area. The Linux community is somewhat famously, for many years now, a toxic cesspool of general hostility, misogyny, etc. It is certainly a good thing that people can get access to this knowledge without subjecting themselves to abuse. But it also means that the people with the power and the privilege to change the community for the better can just quietly withdraw, rather than fixing the problems. It also means that the positive elements of culture cannot be transmitted, and people will have no opportunity to learn about unknown unknowns.

In this case, the "adversarial" communication is with society. The thing that using an LLM for search lets you do is withdraw from society and avoid forming any personal connections. There are some personal connections which are painful and annoying, and so that can feel like a momentary balm. But the need to make connections in general is, like, the concept of society itself.

Who Am I Hurting?

LLMs are good at adversarial communication. They are so good at it, relative to their other benefits, that they will tend to make communications adversarial if you are not remaining vigilant about the possibility that it might do so. My request to you, dear reader, if you are going to use such tools, is to always ask yourself, "who might I be hurting, if I use an LLM for this?"

If you're using an "AI", who is its adversary? If you haven't given it one yet, who might the "AI" turn into an adversary? Who might you overwhelm with an asymmetric amount of output, or, if you're receiving information and not sending it, who are you taking that information from without consulting?

Figure out the answers to these questions and conduct yourself accordingly; the answer might be "yourself".

Acknowledgments

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. One of the reasons that software developers tend to prefer greenfield development is that when you are given a blank page, you can project your own specific understanding onto it. You can structure the codebase in a way that works for your brain, down to the variable naming conventions and the module layouts. LLM-assisted development makes everything into instant brownfield work, which makes developers instantly miserable; even those who are excited about the technology will frequently complain about how it feels like their agency has been stolen and their joy in the work has been diminished. But I digress.

  2. Modulo the massive amount of other externalities involved in using LLMs, of course, but I don't have the time or energy to get into those here.

23 Jun 2026 8:06pm GMT