<?xml version="1.0" encoding="utf-8"?>
<feed xml:lang="en-us" xmlns="http://www.w3.org/2005/Atom"><title>Simon Willison's Weblog: pyodide</title><link href="http://simonwillison.net/" rel="alternate"/><link href="http://simonwillison.net/tags/pyodide.atom" rel="self"/><id>http://simonwillison.net/</id><updated>2026-07-18T17:19:10+00:00</updated><author><name>Simon Willison</name></author><entry><title>SQLite Query Explainer</title><link href="https://simonwillison.net/2026/Jul/18/sqlite-query-explainer/" rel="alternate"/><published>2026-07-18T17:19:10+00:00</published><updated>2026-07-18T17:19:10+00:00</updated><id>https://simonwillison.net/2026/Jul/18/sqlite-query-explainer/</id><summary type="html">
    
        &lt;p&gt;&lt;strong&gt;Tool:&lt;/strong&gt; &lt;a href="https://tools.simonwillison.net/sqlite-query-explainer"&gt;SQLite Query Explainer&lt;/a&gt;&lt;/p&gt;
        &lt;p&gt;Julia Evan's, in &lt;a href="https://jvns.ca/blog/2026/07/17/learning-about-running-sqlite/"&gt;Learning a few things about running SQLite&lt;/a&gt;:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Maybe one day I’ll learn to read a query plan.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Big same.... which inspired me to &lt;a href="https://github.com/simonw/tools/pull/299#issue-4919268017"&gt;have Fable build&lt;/a&gt; this interactive explain tool, which runs SQLite in Python in Pyodide in Web Assembly in the browser and adds a layer of explanation to the results of both EXPLAIN and EXPLAIN QUERY PLAN.&lt;/p&gt;
&lt;p&gt;Approach with caution, since I don't know enough about SQLite query plans to verify the results myself, but it seems cromulent enough to me.&lt;/p&gt;
    
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/sql"&gt;sql&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/sqlite"&gt;sqlite&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/tools"&gt;tools&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/julia-evans"&gt;julia-evans&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/pyodide"&gt;pyodide&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/claude-mythos-fable"&gt;claude-mythos-fable&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="sql"/><category term="sqlite"/><category term="tools"/><category term="julia-evans"/><category term="pyodide"/><category term="claude-mythos-fable"/></entry><entry><title>OPFS + Pyodide test harness</title><link href="https://simonwillison.net/2026/Jun/23/opfs-pyodide/" rel="alternate"/><published>2026-06-23T18:58:54+00:00</published><updated>2026-06-23T18:58:54+00:00</updated><id>https://simonwillison.net/2026/Jun/23/opfs-pyodide/</id><summary type="html">
    
        &lt;p&gt;&lt;strong&gt;Tool:&lt;/strong&gt; &lt;a href="https://tools.simonwillison.net/opfs-pyodide"&gt;OPFS + Pyodide test harness&lt;/a&gt;&lt;/p&gt;
        &lt;p&gt;I've been pondering if &lt;a href="https://lite.datasette.io/"&gt;Datasette Lite&lt;/a&gt; - the Python Datasette application run entirely in the browser using Pyodide and WebAssembly - might be able to edit persistent SQLite files stored on the user's computer.&lt;/p&gt;
&lt;p&gt;That's what &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/File_System_API/Origin_private_file_system"&gt;OFPS&lt;/a&gt; (Origin Private File System) is for, so I had Claude Code for web build me this playground UI to try it out in different browsers.&lt;/p&gt;
    
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/browsers"&gt;browsers&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/pyodide"&gt;pyodide&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette-lite"&gt;datasette-lite&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="browsers"/><category term="pyodide"/><category term="datasette-lite"/></entry><entry><title>Publishing WASM wheels to PyPI for use with Pyodide</title><link href="https://simonwillison.net/2026/Jun/13/publishing-wasm-wheels/" rel="alternate"/><published>2026-06-13T23:55:18+00:00</published><updated>2026-06-13T23:55:18+00:00</updated><id>https://simonwillison.net/2026/Jun/13/publishing-wasm-wheels/</id><summary type="html">
    &lt;p&gt;The &lt;a href="https://blog.pyodide.org/posts/314-release/"&gt;Pyodide 314.0 release announcement&lt;/a&gt; (via &lt;a href="https://news.ycombinator.com/item?id=48462759"&gt;Hacker News&lt;/a&gt;) includes news I've been looking forward to for a long time:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;You can now publish Python packages built for Pyodide (or any Python runtime compatible with &lt;a href="https://pyodide.org/en/stable/development/abi.html"&gt;the PyEmscripten platform defined in PEP 783&lt;/a&gt;) directly to PyPI and install them at runtime.&lt;/p&gt;
&lt;p&gt;Previously, the Pyodide maintainers had to maintain, build, and host over 300 packages ourselves. This created a significant burden on our maintainers and became a major bottleneck for the community, as every new package required manual review.&lt;/p&gt;
&lt;p&gt;Moving forward, package maintainers can simply build and publish Pyodide wheels to PyPI, just as they do for native wheels on Linux, macOS, or Windows.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Here's the &lt;a href="https://github.com/pypi/warehouse/pull/19804"&gt;PR to PyPI itself supporting this&lt;/a&gt;, which landed on April 21st.&lt;/p&gt;
&lt;p&gt;I adore &lt;a href="https://pyodide.org"&gt;Pyodide&lt;/a&gt;, and have been frustrated in the past by this limitation. It's possible to compile C or Rust extensions to WASM in a wheel file, but before now there was no easy way to distribute them.&lt;/p&gt;
&lt;p&gt;Thanks to the efforts of a whole lot of people, that's now been fixed!&lt;/p&gt;
&lt;h4 id="trying-it-out-with-luau-wasm"&gt;Trying it out with luau-wasm&lt;/h4&gt;
&lt;p&gt;I decided to celebrate by finding something I could package. I have quite a few experimental Pyodide projects lying around, but the best fit for this looked to be my &lt;a href="https://github.com/simonw/research/tree/main/pluau-wasm-pyodide#readme"&gt;Luau WebAssembly research spike&lt;/a&gt; from 9th March.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://luau.org"&gt;Luau&lt;/a&gt; is a "small, fast, and embeddable programming language based on Lua with a gradual type system", &lt;a href="https://luau.org/news/2022-11-04-luau-origins-and-evolution/"&gt;developed by Roblox&lt;/a&gt; and released under an MIT license.&lt;/p&gt;
&lt;p&gt;It's written in C++. I already knew it was possible to compile it to WebAssembly and get it running inside of Pyodide, so I &lt;a href="https://gist.github.com/simonw/1761eab6ba11d4053f56f955a28ad76b"&gt;set Codex + GPT-5.5 xhigh&lt;/a&gt; the task of packaging my experiment up and publishing it to PyPI using GitHub Actions.&lt;/p&gt;
&lt;p&gt;It took some iteration, but here's the result: &lt;a href="https://pypi.org/project/luau-wasm/"&gt;luau-wasm&lt;/a&gt; is a brand new PyPI package which publishes a 276KB &lt;code&gt;luau_wasm-0.1a0-cp314-cp314-pyemscripten_2026_0_wasm32.whl&lt;/code&gt; file which can be used in Pyodide like this:&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-k"&gt;import&lt;/span&gt; &lt;span class="pl-s1"&gt;micropip&lt;/span&gt;
&lt;span class="pl-k"&gt;await&lt;/span&gt; &lt;span class="pl-s1"&gt;micropip&lt;/span&gt;.&lt;span class="pl-c1"&gt;install&lt;/span&gt;(&lt;span class="pl-s"&gt;"luau-wasm"&lt;/span&gt;)
&lt;span class="pl-k"&gt;import&lt;/span&gt; &lt;span class="pl-s1"&gt;luau_wasm&lt;/span&gt;
&lt;span class="pl-en"&gt;print&lt;/span&gt;(&lt;span class="pl-s1"&gt;luau_wasm&lt;/span&gt;.&lt;span class="pl-c1"&gt;execute&lt;/span&gt;(&lt;span class="pl-s"&gt;r'''&lt;/span&gt;
&lt;span class="pl-s"&gt;local animals = {"fox", "owl", "frog", "rabbit"}&lt;/span&gt;
&lt;span class="pl-s"&gt;table.sort(animals, function(a, b) return #a &amp;lt; #b end)&lt;/span&gt;
&lt;span class="pl-s"&gt;for i, name in animals do print(i .. ". " .. name .. " (" .. #name .. ")") end&lt;/span&gt;
&lt;span class="pl-s"&gt;'''&lt;/span&gt;))&lt;/pre&gt;
&lt;p&gt;You can run that code &lt;a href="https://pyodide.org/en/stable/console.html"&gt;in the Pyodide REPL demo&lt;/a&gt; to see it in action.&lt;/p&gt;
&lt;p&gt;The &lt;a href="https://github.com/simonw/luau-wasm"&gt;GitHub repo for luau-wasm&lt;/a&gt; includes all of the build and deploy scripts (using the latest &lt;a href="https://github.com/pypa/cibuildwheel"&gt;cibuildwheel&lt;/a&gt;) and also deploys an HTML demo page which loads Pyodide, installs &lt;code&gt;luau-wasm&lt;/code&gt; and provides an interface for trying it out: &lt;a href="https://simonw.github.io/luau-wasm/"&gt;https://simonw.github.io/luau-wasm/&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2026/luau-wasm.jpg" alt="Screenshot of a web app titled &amp;quot;Luau WASM&amp;quot; with subtitle &amp;quot;Run Luau in the browser through Pyodide after installing the luau-wasm WebAssembly wheel from PyPI.&amp;quot; A green &amp;quot;Ready&amp;quot; status badge is at top right. Below are example buttons: &amp;quot;Hello World&amp;quot;, &amp;quot;Variables&amp;quot;, &amp;quot;Tables&amp;quot;, &amp;quot;Fibonacci&amp;quot;, &amp;quot;Runtime Error&amp;quot;. A &amp;quot;LUAU SOURCE&amp;quot; code editor contains: local function fib(n: number): number / if n &amp;lt; 2 then return n end / return fib(n - 1) + fib(n - 2) / end / local out = {} / for i = 0, 12 do / table.insert(out, tostring(fib(i))) / end / print(table.concat(out, &amp;quot;, &amp;quot;)). On the right is an &amp;quot;OUTPUT&amp;quot; panel with a &amp;quot;Copy&amp;quot; button showing dark terminal output: 0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144. At the bottom left are a blue &amp;quot;Run&amp;quot; button, a &amp;quot;Clear&amp;quot; button, and the text &amp;quot;6.0 ms&amp;quot;." style="max-width: 100%;" /&gt;&lt;/p&gt;
&lt;h4 id="how-many-packages-are-using-this-so-far-"&gt;How many packages are using this so far?&lt;/h4&gt;
&lt;p&gt;I was curious to see how many packages are currently publishing wheels for this platform.&lt;/p&gt;
&lt;p&gt;After some &lt;a href="https://chatgpt.com/share/6a2dee92-b110-83e8-9f53-ba9259b751ed"&gt;tinkering with ChatGPT&lt;/a&gt; I got to &lt;a href="https://gist.github.com/simonw/4a34a49fca63bc09c50bc31e17b3d33b?permalink_comment_id=6198602#gistcomment-6198602"&gt;this BigQuery SQL&lt;/a&gt; which I ran against PyPI's &lt;a href="https://packaging.python.org/en/latest/guides/analyzing-pypi-package-downloads/#public-dataset"&gt;public dataset on BigQuery&lt;/a&gt;. Here's the &lt;a href="https://gist.github.com/simonw/4a34a49fca63bc09c50bc31e17b3d33b"&gt;raw JSON&lt;/a&gt; of query results and here's a SQLite SQL query &lt;a href="https://lite.datasette.io/?json=https://gist.github.com/simonw/4a34a49fca63bc09c50bc31e17b3d33b#/data?sql=select%0A++name%2C%0A++platform_tag%2C%0A++matching_file_count%2C%0A++max%28latest_upload%29+as+latest_upload%2C%0A++example_files%0Afrom+%28%0A++--+your+existing+query+here%2C+without+order+by%0A++select%0A++++name%2C%0A++++platform_tag%2C%0A++++matching_file_count%2C%0A++++latest_upload%2C%0A++++example_files%0A++from+raw%0A%29%0Agroup+by+name%0Aorder+by+latest_upload+desc%3B"&gt;in Datasette Lite&lt;/a&gt; which dedupes packages by most recent upload date.&lt;/p&gt;
&lt;p&gt;If the query is right, there are currently 28 PyPI packages publishing with the new &lt;code&gt;pyemscripten_202*_wasm32&lt;/code&gt; tags:&lt;/p&gt;
&lt;p&gt;&lt;a href="https://pypi.org/project/luau-wasm/"&gt;luau-wasm&lt;/a&gt;, &lt;a href="https://pypi.org/project/uuid7-rs/"&gt;uuid7-rs&lt;/a&gt;, &lt;a href="https://pypi.org/project/cmm-16bit/"&gt;cmm-16bit&lt;/a&gt;, &lt;a href="https://pypi.org/project/pyOpenTTDAdmin/"&gt;pyOpenTTDAdmin&lt;/a&gt;, &lt;a href="https://pypi.org/project/imgui-bundle/"&gt;imgui-bundle&lt;/a&gt;, &lt;a href="https://pypi.org/project/numbertoolkit/"&gt;numbertoolkit&lt;/a&gt;, &lt;a href="https://pypi.org/project/bashkit/"&gt;bashkit&lt;/a&gt;, &lt;a href="https://pypi.org/project/geoarrow-rust-core/"&gt;geoarrow-rust-core&lt;/a&gt;, &lt;a href="https://pypi.org/project/arro3-io/"&gt;arro3-io&lt;/a&gt;, &lt;a href="https://pypi.org/project/arro3-core/"&gt;arro3-core&lt;/a&gt;, &lt;a href="https://pypi.org/project/arro3-compute/"&gt;arro3-compute&lt;/a&gt;, &lt;a href="https://pypi.org/project/onnx/"&gt;onnx&lt;/a&gt;, &lt;a href="https://pypi.org/project/powerfit-em/"&gt;powerfit-em&lt;/a&gt;, &lt;a href="https://pypi.org/project/tcod/"&gt;tcod&lt;/a&gt;, &lt;a href="https://pypi.org/project/chonkie-core/"&gt;chonkie-core&lt;/a&gt;, &lt;a href="https://pypi.org/project/tokie/"&gt;tokie&lt;/a&gt;, &lt;a href="https://pypi.org/project/robotraconteur/"&gt;robotraconteur&lt;/a&gt;, &lt;a href="https://pypi.org/project/pydantic_core/"&gt;pydantic_core&lt;/a&gt;, &lt;a href="https://pypi.org/project/yaml-rs/"&gt;yaml-rs&lt;/a&gt;, &lt;a href="https://pypi.org/project/cadquery-ocp-novtk-OCP.wasm/"&gt;cadquery-ocp-novtk-OCP.wasm&lt;/a&gt;, &lt;a href="https://pypi.org/project/uuid_utils/"&gt;uuid_utils&lt;/a&gt;, &lt;a href="https://pypi.org/project/base64_utils/"&gt;base64_utils&lt;/a&gt;, &lt;a href="https://pypi.org/project/pycdfpp/"&gt;pycdfpp&lt;/a&gt;, &lt;a href="https://pypi.org/project/lib3mf-OCP.wasm/"&gt;lib3mf-OCP.wasm&lt;/a&gt;, &lt;a href="https://pypi.org/project/typst/"&gt;typst&lt;/a&gt;, &lt;a href="https://pypi.org/project/toml-rs/"&gt;toml-rs&lt;/a&gt;, &lt;a href="https://pypi.org/project/onnx-weekly/"&gt;onnx-weekly&lt;/a&gt;, &lt;a href="https://pypi.org/project/dummy-pyodide-ext-test/"&gt;dummy-pyodide-ext-test&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Here's hoping we see a whole lot more of those showing up over the coming months and years.&lt;/p&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/lua"&gt;lua&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/pypi"&gt;pypi&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/python"&gt;python&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/sandboxing"&gt;sandboxing&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/webassembly"&gt;webassembly&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/github-actions"&gt;github-actions&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/pyodide"&gt;pyodide&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="lua"/><category term="pypi"/><category term="python"/><category term="sandboxing"/><category term="webassembly"/><category term="github-actions"/><category term="pyodide"/></entry><entry><title>luau-wasm 0.1a0</title><link href="https://simonwillison.net/2026/Jun/13/luau-wasm/" rel="alternate"/><published>2026-06-13T23:14:30+00:00</published><updated>2026-06-13T23:14:30+00:00</updated><id>https://simonwillison.net/2026/Jun/13/luau-wasm/</id><summary type="html">
    
        &lt;p&gt;&lt;strong&gt;Release:&lt;/strong&gt; &lt;a href="https://github.com/simonw/luau-wasm/releases/tag/0.1a0"&gt;luau-wasm 0.1a0&lt;/a&gt;&lt;/p&gt;
        &lt;p&gt;See &lt;a href="https://simonwillison.net/2026/Jun/13/publishing-wasm-wheels/"&gt;Publishing WASM wheels to PyPI for use with Pyodide&lt;/a&gt; for details.&lt;/p&gt;
    
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/lua"&gt;lua&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/webassembly"&gt;webassembly&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/pyodide"&gt;pyodide&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="lua"/><category term="webassembly"/><category term="pyodide"/></entry><entry><title>Running Python code in a sandbox with MicroPython and WASM</title><link href="https://simonwillison.net/2026/Jun/6/micropython-in-a-sandbox/" rel="alternate"/><published>2026-06-06T03:53:34+00:00</published><updated>2026-06-06T03:53:34+00:00</updated><id>https://simonwillison.net/2026/Jun/6/micropython-in-a-sandbox/</id><summary type="html">
    &lt;p&gt;I've been experimenting with different approaches to running code in a sandbox for several years now, but my latest attempt feels like it might finally have all of the characteristics I've been looking for. I've released it as an alpha package called &lt;a href="https://github.com/simonw/micropython-wasm"&gt;micropython-wasm&lt;/a&gt;, and I'm using it for a code execution sandbox plugin for &lt;a href="https://github.com/datasette/datasette-agent"&gt;Datasette Agent&lt;/a&gt; called &lt;a href="https://github.com/datasette/datasette-agent-micropython"&gt;datasette-agent-micropython&lt;/a&gt;.&lt;/p&gt;
&lt;ul&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2026/Jun/6/micropython-in-a-sandbox/#why-do-i-want-a-sandbox-"&gt;Why do I want a sandbox?&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2026/Jun/6/micropython-in-a-sandbox/#what-i-want-from-a-sandbox"&gt;What I want from a sandbox&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2026/Jun/6/micropython-in-a-sandbox/#webassembly-looks-really-promising-here"&gt;WebAssembly looks really promising here&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2026/Jun/6/micropython-in-a-sandbox/#micropython-in-webassembly"&gt;MicroPython in WebAssembly&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2026/Jun/6/micropython-in-a-sandbox/#building-the-first-version"&gt;Building the first version&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2026/Jun/6/micropython-in-a-sandbox/#try-it-yourself"&gt;Try it yourself&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2026/Jun/6/micropython-in-a-sandbox/#should-you-trust-my-vibe-coded-sandbox-"&gt;Should you trust my vibe-coded sandbox?&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id="why-do-i-want-a-sandbox-"&gt;Why do I want a sandbox?&lt;/h4&gt;
&lt;p&gt;My key open source projects - &lt;a href="https://datasette.io/"&gt;Datasette&lt;/a&gt;, &lt;a href="https://llm.datasette.io/"&gt;LLM&lt;/a&gt;, even &lt;a href="https://sqlite-utils.datasette.io/"&gt;sqlite-utils&lt;/a&gt; - all support plugins.&lt;/p&gt;
&lt;p&gt;I absolutely love plugins as a mechanism for extending software. A carefully designed plugin system reduces the risk involved in trying new things to almost nothing - even the wildest ideas won't leave a lasting influence on the core application itself. My software can grow a new feature overnight and I don't even have to review a pull request!&lt;/p&gt;
&lt;p&gt;There's one major drawback: my plugin systems all use Python and &lt;a href="https://pluggy.readthedocs.io/en/latest/"&gt;Pluggy&lt;/a&gt;, and plugin code executes with full privileges within my applications. A buggy or malicious plugin could break everything or leak private data.&lt;/p&gt;
&lt;p&gt;I'd love to be able to run plugin-style code in an environment where it is unable to read unapproved files, connect to a network, or generally operate in a way that's risky or harmful to the rest of the application or the user's computer.&lt;/p&gt;
&lt;p&gt;My interest covers more than just plugins. For Datasette in particular there are many features I'd like to support where arbitrary code execution would be useful. I've already experimented with this for &lt;a href="https://enrichments.datasette.io/"&gt;Datasette Enrichments&lt;/a&gt;, where code can be used to transform values stored in a table. I'd love to build a mechanism where you can run code on a schedule that fetches JSON from an approved location, runs a tiny bit of code to reformat it into a list of dictionaries, then inserts those as rows in a SQLite database table.&lt;/p&gt;
&lt;h4 id="what-i-want-from-a-sandbox"&gt;What I want from a sandbox&lt;/h4&gt;
&lt;p&gt;My goal is to execute code safely within my own Python applications. Here's what I need:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Dependencies that &lt;strong&gt;cleanly install from PyPI&lt;/strong&gt;, including binary wheels across multiple platforms if necessary. I don't want people using my software to have to take any extra steps beyond directly installing my Python package.&lt;/li&gt;
&lt;li&gt;Executed code must be subject to both &lt;strong&gt;memory&lt;/strong&gt; and &lt;strong&gt;CPU&lt;/strong&gt; limits. I don't want &lt;code&gt;while True: s += "longer string"&lt;/code&gt; to crash my application or the user's computer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;File access must be strictly controlled&lt;/strong&gt;. Either no filesystem access at all or I get to define exactly which files can be read and which files can be written to.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Network access is controlled as well&lt;/strong&gt;. Sandboxed code should not be able to communicate with anything without going through a layer I fully control.&lt;/li&gt;
&lt;li&gt;Support for interaction with &lt;strong&gt;host functions&lt;/strong&gt;. A sandbox isn't much use if I can't carefully expose selected platform features to the code that it's running.&lt;/li&gt;
&lt;li&gt;It has to be &lt;strong&gt;robust, supported, and clearly documented&lt;/strong&gt;. I've lost count of the number of sandbox projects I've seen in repos with warnings that they aren't actively maintained!&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id="webassembly-looks-really-promising-here"&gt;WebAssembly looks really promising here&lt;/h4&gt;
&lt;p&gt;Web browsers operate in the most hostile environment imaginable when it comes to malicious code. Their job is to download &lt;em&gt;and execute&lt;/em&gt; untrusted code from the web on almost every page load.&lt;/p&gt;
&lt;p&gt;Given this, JavaScript engines should be excellent candidates for sandboxes. Sadly those engines are also extremely complicated, and are not designed for easy embedding in other projects. Most of the V8-in-Python projects I've seen are infrequently maintained and come with warnings not to use them with completely untrusted code.&lt;/p&gt;
&lt;p&gt;WebAssembly is a &lt;em&gt;much better&lt;/em&gt; candidate. It was designed from the start to support all of the characteristics I care about and has been tested in browsers for nearly a decade. The &lt;a href="https://pypi.org/project/wasmtime"&gt;wasmtime&lt;/a&gt; Python library brings WASM to Python, is actively maintained, and has binary wheels.&lt;/p&gt;
&lt;h4 id="micropython-in-webassembly"&gt;MicroPython in WebAssembly&lt;/h4&gt;
&lt;p&gt;WebAssembly engines like wasmtime run WebAssembly binaries. Some programming languages like Rust are easy to compile directly to WebAssembly. Dynamic languages like JavaScript and Python are harder - they support language primitives like &lt;code&gt;eval()&lt;/code&gt;, which means they need a full interpreter available at runtime.&lt;/p&gt;
&lt;p&gt;To run Python we need a full Python interpreter compiled to WebAssembly, wired up in a way that makes it easy to feed it code, hook up host functions and access the results.&lt;/p&gt;
&lt;p&gt;Pyodide offers an outstanding package for running Python using WebAssembly in the browser, but using Pyodide in server-side Python isn't supported. The most recent advice I could find was &lt;a href="https://github.com/pyodide/pyodide/discussions/5145"&gt;from October 2024&lt;/a&gt; stating "Pyodide is built by the Emscripten toolchain and can only run in a browser or Node.js".&lt;/p&gt;
&lt;p&gt;The other day I decided to take a look at &lt;a href="https://micropython.org"&gt;MicroPython&lt;/a&gt; as an option for this. The MicroPython site says:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;MicroPython is a lean and efficient implementation of the Python 3 programming language that includes a small subset of the Python standard library and is optimised to run on microcontrollers and in constrained environments.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;WebAssembly sure feels like a constrained environment to me!&lt;/p&gt;
&lt;h4 id="building-the-first-version"&gt;Building the first version&lt;/h4&gt;
&lt;p&gt;I had GPT-5.5 Pro &lt;a href="https://chatgpt.com/share/6a1e2a5c-58b8-8328-ba1c-0e6aadb0a051"&gt;do some research for me&lt;/a&gt;, which turned up &lt;a href="https://github.com/micropython/micropython/pull/13676"&gt;this PR against MicroPython&lt;/a&gt; by &lt;a href="https://github.com/yamt"&gt;Yamamoto Takahashi&lt;/a&gt; titled "Experimental WASI support for ports/unix".&lt;/p&gt;
&lt;p&gt;It then produced this &lt;a href="https://github.com/simonw/micropython-wasm/blob/c08fbd2276b15dc8c9bdff82845f750971f45647/research.md"&gt;research.md document&lt;/a&gt;, so I let Codex Desktop and GPT-5.5 high &lt;a href="https://gist.github.com/simonw/27461a16d76f28f8619c609444d544fe"&gt;loose on it&lt;/a&gt; to see what would happen:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;read the research.md document and build this. You will probably need to write a script that compiles a custom WASM version of MicroPython as part of this project - fetch the MicroPython code to a /tmp directory for this as part of that script.&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;It worked. I now had a prototype Python library that could execute Python code inside a WebAssembly sandbox!&lt;/p&gt;
&lt;p&gt;The trickiest piece to solve was persistent interpreter state. The WASM build we are using here exposes a single entry point which starts the interpreter, runs the code and then stops the interpreter at the end.&lt;/p&gt;
&lt;p&gt;This works fine for one-off scripts, but for Datasette Agent I want variables and functions to stay resident in memory so I can reuse them across multiple code execution calls.&lt;/p&gt;
&lt;p&gt;A neat thing about working with coding agents is that you can get from an idea to a proof of concept quickly. I prompted:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;For keeping variables resident: what if we ran code inside micropython itself which called a host function get_next_python_code() and then passed that to eval() - and that host function blocked until new code was available, maybe by running in a thread with a queue? Could that or a similar idea help here?&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;After some iteration we got to a version of this that works! In Python code you can now do this:&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-k"&gt;from&lt;/span&gt; &lt;span class="pl-s1"&gt;micropython_wasm&lt;/span&gt; &lt;span class="pl-k"&gt;import&lt;/span&gt; &lt;span class="pl-v"&gt;MicroPythonSession&lt;/span&gt;

&lt;span class="pl-k"&gt;with&lt;/span&gt; &lt;span class="pl-en"&gt;MicroPythonSession&lt;/span&gt;() &lt;span class="pl-k"&gt;as&lt;/span&gt; &lt;span class="pl-s1"&gt;session&lt;/span&gt;:
    &lt;span class="pl-en"&gt;print&lt;/span&gt;(&lt;span class="pl-s1"&gt;session&lt;/span&gt;.&lt;span class="pl-c1"&gt;run&lt;/span&gt;(&lt;span class="pl-s"&gt;"x = 10&lt;span class="pl-cce"&gt;\n&lt;/span&gt;print(x)"&lt;/span&gt;).&lt;span class="pl-c1"&gt;stdout&lt;/span&gt;)
    &lt;span class="pl-en"&gt;print&lt;/span&gt;(&lt;span class="pl-s1"&gt;session&lt;/span&gt;.&lt;span class="pl-c1"&gt;run&lt;/span&gt;(&lt;span class="pl-s"&gt;"x += 5&lt;span class="pl-cce"&gt;\n&lt;/span&gt;print(x)"&lt;/span&gt;).&lt;span class="pl-c1"&gt;stdout&lt;/span&gt;)
    &lt;span class="pl-en"&gt;print&lt;/span&gt;(&lt;span class="pl-s1"&gt;session&lt;/span&gt;.&lt;span class="pl-c1"&gt;run&lt;/span&gt;(&lt;span class="pl-s"&gt;"print(x * 2)"&lt;/span&gt;).&lt;span class="pl-c1"&gt;stdout&lt;/span&gt;)&lt;/pre&gt;
&lt;p&gt;Under the hood this starts a thread, sets up a request queue and then sends messages to that queue for the &lt;code&gt;session.run()&lt;/code&gt; command, each time waiting on a reply queue for the result of that execution. Inside WASM the MicroPython interpreter blocks waiting for a &lt;code&gt;__session_next__()&lt;/code&gt; host function to return the next line of code, which it runs &lt;code&gt;eval()&lt;/code&gt; on before calling &lt;code&gt;__session_result__({"id": request_id, "ok": True})&lt;/code&gt; when each block has been successfully executed.&lt;/p&gt;
&lt;p&gt;The other piece of complexity was supporting host functions, so my Python library could selectively expose functions that could then be called by code running in MicroPython.&lt;/p&gt;
&lt;p&gt;Codex ended up solving this with &lt;a href="https://github.com/simonw/micropython-wasm/blob/0.1a1/micropython_wasm/usercmodule/host/hostmodule.c"&gt;78 lines of C&lt;/a&gt;, which ends up compiled into the &lt;a href="https://github.com/simonw/micropython-wasm/blob/0.1a1/micropython_wasm/artifacts/micropython-wasi.wasm"&gt;362KB WebAssembly blob&lt;/a&gt; I'm distributing with the package.&lt;/p&gt;
&lt;p&gt;I am by no means a C programmer, but I've read the C and had two different models explain it to me (here's &lt;a href="https://claude.ai/share/62f74371-cc3c-44f2-b406-33d03513de9e"&gt;Claude's explanation&lt;/a&gt;) and I've subjected it to a barrage of tests.&lt;/p&gt;
&lt;p&gt;The great thing about working with WebAssembly is that if the C turns out to be fatally flawed the worst that can happen is the WebAssembly execution will fail with an exception. I can live with that risk.&lt;/p&gt;
&lt;p&gt;Memory limits are directly supported by wasmtime. CPU limits are a little harder: wasmtime offers a "fuel" concept to limit how many operations a WebAssembly call can execute, and that's the correct fit for this problem, but the units are hard to reason about. I'm experimenting with a 20 million default "fuel" setting now but I'm not confident that it's the most appropriate value.&lt;/p&gt;
&lt;h4 id="try-it-yourself"&gt;Try it yourself&lt;/h4&gt;
&lt;p&gt;The &lt;code&gt;micropython-wasm&lt;/code&gt; alpha is now &lt;a href="https://pypi.org/project/micropython-wasm"&gt;live on PyPI&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;You can try it from your own Python code as &lt;a href="https://github.com/simonw/micropython-wasm"&gt;described in the README&lt;/a&gt;. I've also added a simple CLI mode in &lt;a href="https://github.com/simonw/micropython-wasm/releases/tag/0.1a2"&gt;version 0.1a2&lt;/a&gt; which means you can try it using &lt;code&gt;uvx&lt;/code&gt; without first installing it like so:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;uvx micropython-wasm -c &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;print("Hello world")&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;
&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; To see it run out of fuel:&lt;/span&gt;
uvx micropython-wasm -c &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;s = ""; while True: s += "longer"&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;
&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; Outputs: micropython-wasm: guest exited with code 1&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;You can also try it in &lt;a href="https://agent.datasette.io/"&gt;Datasette Agent&lt;/a&gt; like this:&lt;/p&gt;
&lt;div class="highlight highlight-source-shell"&gt;&lt;pre&gt;uvx llm keys &lt;span class="pl-c1"&gt;set&lt;/span&gt; openai
&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; Paste in an OpenAI key, then:&lt;/span&gt;
uvx --with datasette-agent \
  --with datasette-agent-micropython \
  --prerelease allow \
  datasette --internal internal.db \
    -s plugins.datasette-llm.default_model gpt-5.5 \
    --root -o&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Then navigate to &lt;a href="http://127.0.0.1:8001/-/agent"&gt;http://127.0.0.1:8001/-/agent&lt;/a&gt; and run the prompt:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;show me some micropython&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2026/micropython-in-datasette-agent.jpg" style="max-width: 100%" alt="Screenshot of a chat application interface with a dark blue-grey header reading &amp;quot;home&amp;quot; on the left and &amp;quot;root&amp;quot; with a hamburger menu icon on the right. Below is a navigation row with &amp;quot;← Back&amp;quot; and &amp;quot;Chat&amp;quot; on the left and an &amp;quot;EXPORT&amp;quot; button on the right. A blue user message bubble reads &amp;quot;show me some micropython&amp;quot;. Below it a collapsed thinking section reads &amp;quot;▸Thinking: … to show the result clearly. After that, I can wrap up with a brief explanation!&amp;quot; followed by a &amp;quot;▶ Tool: execute_micropython&amp;quot; label. A code block follows: &amp;quot;# A tiny MicroPython example: blink-style logic + Fibonacci&amp;quot; / &amp;quot;def fib(n):&amp;quot; / &amp;quot;    a, b = 0, 1&amp;quot; / &amp;quot;    out = []&amp;quot; / &amp;quot;    for _ in range(n):&amp;quot; / &amp;quot;        out.append(a)&amp;quot; / &amp;quot;        a, b = b, a + b&amp;quot; / &amp;quot;    return out&amp;quot; / &amp;#39;print(&amp;quot;Hello from MicroPython!&amp;quot;)&amp;#39; / &amp;#39;print(&amp;quot;First 10 Fibonacci numbers:&amp;quot;, fib(10))&amp;#39; / &amp;quot;# MicroPython often runs on microcontrollers, e.g.:&amp;quot; / &amp;quot;# from machine import Pin&amp;quot; / &amp;quot;# led = Pin(2, Pin.OUT)&amp;quot; / &amp;quot;# led.value(1)  # turn LED on&amp;quot; / &amp;quot;# led.value(0)  # turn LED off&amp;quot;. Below a horizontal divider is the output: &amp;quot;Hello from MicroPython!&amp;quot; / &amp;quot;First 10 Fibonacci numbers: [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]&amp;quot;, followed by a &amp;quot;▶ Result: execute_micropython&amp;quot; label. At the bottom is a text input field with placeholder &amp;quot;Type a message...&amp;quot; and a blue &amp;quot;Send&amp;quot; button." /&gt;&lt;/p&gt;

&lt;p&gt;You can try a live demo of that plugin running in Datasette Agent by signing into &lt;a href="https://agent.datasette.io"&gt;agent.datasette.io&lt;/a&gt; with your GitHub account.&lt;/p&gt;

&lt;h4 id="should-you-trust-my-vibe-coded-sandbox-"&gt;Should you trust my vibe-coded sandbox?&lt;/h4&gt;
&lt;p&gt;Having complained about immature, loosely-maintained sandboxing libraries, it's deeply ironic that I've now built my own!&lt;/p&gt;
&lt;p&gt;I deliberately slapped an alpha release version on it, and I'm not ready to recommend it to anyone who isn't willing to take a significant risk.&lt;/p&gt;
&lt;p&gt;I've put it through enough testing that I'm OK using it myself. I've shipped my first plugin that uses it, &lt;a href="https://github.com/datasette/datasette-agent-micropython"&gt;datasette-agent-micropython&lt;/a&gt;. I've also locked GPT-5.5 xhigh in that Datasette Agent plugin and &lt;a href="https://gist.github.com/simonw/5de497c44d25f9fd459c8aa2c959fe4a"&gt;challenged it to break out of the sandbox&lt;/a&gt; and so far it has not managed to.&lt;/p&gt;
&lt;p&gt;I'm hoping this implementation can convince some companies with professional security teams and high-stakes problems to commit to using Python in WebAssembly as a sandboxing approach and open source their own solutions.&lt;/p&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/python"&gt;python&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/sandboxing"&gt;sandboxing&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai"&gt;ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette"&gt;datasette&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/webassembly"&gt;webassembly&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/pyodide"&gt;pyodide&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/generative-ai"&gt;generative-ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llms"&gt;llms&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai-assisted-programming"&gt;ai-assisted-programming&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/codex"&gt;codex&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette-agent"&gt;datasette-agent&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/micropython"&gt;micropython&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="python"/><category term="sandboxing"/><category term="ai"/><category term="datasette"/><category term="webassembly"/><category term="pyodide"/><category term="generative-ai"/><category term="llms"/><category term="ai-assisted-programming"/><category term="codex"/><category term="datasette-agent"/><category term="micropython"/></entry><entry><title>Running Python ASGI apps in the browser via Pyodide + a service worker</title><link href="https://simonwillison.net/2026/May/30/pyodide-asgi-browser/" rel="alternate"/><published>2026-05-30T15:34:00+00:00</published><updated>2026-05-30T15:34:00+00:00</updated><id>https://simonwillison.net/2026/May/30/pyodide-asgi-browser/</id><summary type="html">
    
        &lt;p&gt;&lt;strong&gt;Research:&lt;/strong&gt; &lt;a href="https://github.com/simonw/research/tree/main/pyodide-asgi-browser#readme"&gt;Running Python ASGI apps in the browser via Pyodide + a service worker&lt;/a&gt;&lt;/p&gt;
        &lt;p&gt;&lt;a href="https://lite.datasette.io/"&gt;Datasette Lite&lt;/a&gt; is my version of Datasette that runs entirely in the browser using Pyodide in WebAssembly.&lt;/p&gt;
&lt;p&gt;When I first built it &lt;a href="https://simonwillison.net/2022/May/4/datasette-lite/"&gt;four years ago&lt;/a&gt; I used Web Workers and code that intercepts navigation operations and fetches the generated HTML by running the Python app.&lt;/p&gt;
&lt;p&gt;This worked, but had the disadvantage that any JavaScript in &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; tags would not be executed - breaking some Datasette functionality and a whole lot of Datasette plugins.&lt;/p&gt;
&lt;p&gt;This morning I &lt;a href="https://github.com/simonw/research/pull/112"&gt;set Claude Opus 4.8 the task&lt;/a&gt; (in Claude Code for web) of figuring out how to run Python ASGI apps in Pyodide using Service Workers instead, and it seems to work! Here's a &lt;a href="https://simonw.github.io/research/pyodide-asgi-browser/"&gt;basic ASGI FastCGI demo&lt;/a&gt; and here's &lt;a href="https://simonw.github.io/research/pyodide-asgi-browser/datasette.html"&gt;a demo that runs Datasette 1.0a31&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;I'm still getting my head around exactly how it works, but once I've done that I plan to upgrade Datasette Lite itself.&lt;/p&gt;
    
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/javascript"&gt;javascript&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/python"&gt;python&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette"&gt;datasette&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/asgi"&gt;asgi&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/webassembly"&gt;webassembly&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/service-workers"&gt;service-workers&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/pyodide"&gt;pyodide&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette-lite"&gt;datasette-lite&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/claude-code"&gt;claude-code&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="javascript"/><category term="python"/><category term="datasette"/><category term="asgi"/><category term="webassembly"/><category term="service-workers"/><category term="pyodide"/><category term="datasette-lite"/><category term="claude-code"/></entry><entry><title>Merge State Visualizer</title><link href="https://simonwillison.net/2026/Mar/22/manyana/" rel="alternate"/><published>2026-03-22T18:57:32+00:00</published><updated>2026-03-22T18:57:32+00:00</updated><id>https://simonwillison.net/2026/Mar/22/manyana/</id><summary type="html">
    
        &lt;p&gt;&lt;strong&gt;Tool:&lt;/strong&gt; &lt;a href="https://tools.simonwillison.net/manyana"&gt;Merge State Visualizer&lt;/a&gt;&lt;/p&gt;
        &lt;p&gt;Bram Cohen wrote about his &lt;a href="https://bramcohen.com/p/manyana"&gt;coherent vision for the future of version control&lt;/a&gt; using CRDTs, illustrated by &lt;a href="https://github.com/bramcohen/manyana/blob/main/manyana.py"&gt;470 lines of Python&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;I fed that Python (minus comments) into Claude and asked for &lt;a href="https://claude.ai/share/8ce777cd-c7c3-4656-a45e-acbf66bf994b"&gt;an explanation&lt;/a&gt;, then had it use Pyodide to build me an interactive UI for seeing how the algorithms work.&lt;/p&gt;
    
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/bram-cohen"&gt;bram-cohen&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/vcs"&gt;vcs&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/crdt"&gt;crdt&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/pyodide"&gt;pyodide&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="bram-cohen"/><category term="vcs"/><category term="crdt"/><category term="pyodide"/></entry><entry><title>Luau WebAssembly: Browser Playground + Python wasmtime</title><link href="https://simonwillison.net/2026/Mar/9/pluau-wasm-pyodide/" rel="alternate"/><published>2026-03-09T21:46:00+00:00</published><updated>2026-03-09T21:46:00+00:00</updated><id>https://simonwillison.net/2026/Mar/9/pluau-wasm-pyodide/</id><summary type="html">
    
        &lt;p&gt;&lt;strong&gt;Research:&lt;/strong&gt; &lt;a href="https://github.com/simonw/research/tree/main/pluau-wasm-pyodide#readme"&gt;Luau WebAssembly: Browser Playground + Python wasmtime&lt;/a&gt;&lt;/p&gt;
        &lt;p&gt;&lt;a href="https://luau.org/"&gt;Luau&lt;/a&gt; is an MIT licensed "small, fast, and embeddable programming language based on Lua with a gradual type system", &lt;a href="https://luau.org/news/2022-11-04-luau-origins-and-evolution/"&gt;created by Roblox&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;As part of my ongoing obsession with sandboxed runtimes I decided to see if I could get it working via WebAssembly in both Python and Pyodide.&lt;/p&gt;
    
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/lua"&gt;lua&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/python"&gt;python&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/sandboxing"&gt;sandboxing&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/webassembly"&gt;webassembly&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/pyodide"&gt;pyodide&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="lua"/><category term="python"/><category term="sandboxing"/><category term="webassembly"/><category term="pyodide"/></entry><entry><title>Python Comment Stripper</title><link href="https://simonwillison.net/2026/Feb/14/python-comment-stripper/" rel="alternate"/><published>2026-02-14T00:52:13+00:00</published><updated>2026-02-14T00:52:13+00:00</updated><id>https://simonwillison.net/2026/Feb/14/python-comment-stripper/</id><summary type="html">
    
        &lt;p&gt;&lt;strong&gt;Tool:&lt;/strong&gt; &lt;a href="https://tools.simonwillison.net/python-comment-stripper"&gt;Python Comment Stripper&lt;/a&gt;&lt;/p&gt;
        
    
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/pyodide"&gt;pyodide&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="pyodide"/></entry><entry><title>cysqlite - a new sqlite driver</title><link href="https://simonwillison.net/2026/Feb/11/cysqlite/" rel="alternate"/><published>2026-02-11T17:34:40+00:00</published><updated>2026-02-11T17:34:40+00:00</updated><id>https://simonwillison.net/2026/Feb/11/cysqlite/</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://charlesleifer.com/blog/cysqlite---a-new-sqlite-driver/"&gt;cysqlite - a new sqlite driver&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
Charles Leifer has been maintaining &lt;a href="https://github.com/coleifer/pysqlite3"&gt;pysqlite3&lt;/a&gt; - a fork of the Python standard library's &lt;code&gt;sqlite3&lt;/code&gt; module that makes it much easier to run upgraded SQLite versions - since 2018.&lt;/p&gt;
&lt;p&gt;He's been working on a ground-up &lt;a href="https://cython.org/"&gt;Cython&lt;/a&gt; rewrite called &lt;a href="https://github.com/coleifer/cysqlite"&gt;cysqlite&lt;/a&gt; for almost as long, but it's finally at a stage where it's ready for people to try out.&lt;/p&gt;
&lt;p&gt;The biggest change from the &lt;code&gt;sqlite3&lt;/code&gt; module involves transactions. Charles explains his discomfort with the &lt;code&gt;sqlite3&lt;/code&gt; implementation at length - that library provides two different variants neither of which exactly match the autocommit mechanism in SQLite itself.&lt;/p&gt;
&lt;p&gt;I'm particularly excited about the support for &lt;a href="https://cysqlite.readthedocs.io/en/latest/api.html#tablefunction"&gt;custom virtual tables&lt;/a&gt;, a feature I'd love to see in &lt;code&gt;sqlite3&lt;/code&gt; itself.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;cysqlite&lt;/code&gt; provides a Python extension compiled from C, which means it normally wouldn't be available in Pyodide. I &lt;a href="https://github.com/simonw/research/tree/main/cysqlite-wasm-wheel"&gt;set Claude Code on it&lt;/a&gt; (here's &lt;a href="https://github.com/simonw/research/pull/79#issue-3923792518"&gt;the prompt&lt;/a&gt;) and it built me &lt;a href="https://github.com/simonw/research/blob/main/cysqlite-wasm-wheel/cysqlite-0.1.4-cp311-cp311-emscripten_3_1_46_wasm32.whl"&gt;cysqlite-0.1.4-cp311-cp311-emscripten_3_1_46_wasm32.whl&lt;/a&gt;, a 688KB wheel file with a WASM build of the library that can be loaded into Pyodide like this:&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-k"&gt;import&lt;/span&gt; &lt;span class="pl-s1"&gt;micropip&lt;/span&gt;
&lt;span class="pl-k"&gt;await&lt;/span&gt; &lt;span class="pl-s1"&gt;micropip&lt;/span&gt;.&lt;span class="pl-c1"&gt;install&lt;/span&gt;(
    &lt;span class="pl-s"&gt;"https://simonw.github.io/research/cysqlite-wasm-wheel/cysqlite-0.1.4-cp311-cp311-emscripten_3_1_46_wasm32.whl"&lt;/span&gt;
)
&lt;span class="pl-k"&gt;import&lt;/span&gt; &lt;span class="pl-s1"&gt;cysqlite&lt;/span&gt;
&lt;span class="pl-en"&gt;print&lt;/span&gt;(&lt;span class="pl-s1"&gt;cysqlite&lt;/span&gt;.&lt;span class="pl-c1"&gt;connect&lt;/span&gt;(&lt;span class="pl-s"&gt;":memory:"&lt;/span&gt;).&lt;span class="pl-c1"&gt;execute&lt;/span&gt;(
    &lt;span class="pl-s"&gt;"select sqlite_version()"&lt;/span&gt;
).&lt;span class="pl-c1"&gt;fetchone&lt;/span&gt;())&lt;/pre&gt;

&lt;p&gt;(I also learned that wheels like this have to be built for the emscripten version used by that edition of Pyodide - my experimental wheel loads in Pyodide 0.25.1 but fails in 0.27.5 with a &lt;code&gt;Wheel was built with Emscripten v3.1.46 but Pyodide was built with Emscripten v3.1.58&lt;/code&gt; error.)&lt;/p&gt;
&lt;p&gt;You can try my wheel in &lt;a href="https://7ebbff98.tools-b1q.pages.dev/pyodide-repl"&gt;this new Pyodide REPL&lt;/a&gt; i had Claude build as a mobile-friendly alternative to Pyodide's &lt;a href="https://pyodide.org/en/stable/console.html"&gt;own hosted console&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;I also had Claude build &lt;a href="https://simonw.github.io/research/cysqlite-wasm-wheel/demo.html"&gt;this demo page&lt;/a&gt; that executes the original test suite in the browser and displays the results:&lt;/p&gt;
&lt;p&gt;&lt;img alt="Screenshot of the cysqlite WebAssembly Demo page with a dark theme. Title reads &amp;quot;cysqlite — WebAssembly Demo&amp;quot; with subtitle &amp;quot;Testing cysqlite compiled to WebAssembly via Emscripten, running in Pyodide in the browser.&amp;quot; Environment section shows Pyodide 0.25.1, Python 3.11.3, cysqlite 0.1.4, SQLite 3.51.2, Platform Emscripten-3.1.46-wasm32-32bit, Wheel file cysqlite-0.1.4-cp311-cp311-emscripten_3_1_46_wasm32.wh (truncated). A green progress bar shows &amp;quot;All 115 tests passed! (1 skipped)&amp;quot; at 100%, with Passed: 115, Failed: 0, Errors: 0, Skipped: 1, Total: 116. Test Results section lists TestBackup 1/1 passed, TestBlob 6/6 passed, TestCheckConnection 4/4 passed, TestDataTypesTableFunction 1/1 passed, all with green badges." src="https://static.simonwillison.net/static/2026/cysqlite-tests.jpg" /&gt;

    &lt;p&gt;&lt;small&gt;&lt;/small&gt;Via &lt;a href="https://lobste.rs/s/gipvta/cysqlite_new_sqlite_driver"&gt;lobste.rs&lt;/a&gt;&lt;/small&gt;&lt;/p&gt;


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/python"&gt;python&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/sqlite"&gt;sqlite&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/charles-leifer"&gt;charles-leifer&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/webassembly"&gt;webassembly&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/pyodide"&gt;pyodide&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai-assisted-programming"&gt;ai-assisted-programming&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/claude-code"&gt;claude-code&lt;/a&gt;&lt;/p&gt;



</summary><category term="python"/><category term="sqlite"/><category term="charles-leifer"/><category term="webassembly"/><category term="pyodide"/><category term="ai-assisted-programming"/><category term="claude-code"/></entry><entry><title>Pyodide REPL</title><link href="https://simonwillison.net/2026/Feb/10/pyodide-repl/" rel="alternate"/><published>2026-02-10T22:54:18+00:00</published><updated>2026-02-10T22:54:18+00:00</updated><id>https://simonwillison.net/2026/Feb/10/pyodide-repl/</id><summary type="html">
    
        &lt;p&gt;&lt;strong&gt;Tool:&lt;/strong&gt; &lt;a href="https://tools.simonwillison.net/pyodide-repl"&gt;Pyodide REPL&lt;/a&gt;&lt;/p&gt;
        
    
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/webassembly"&gt;webassembly&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/pyodide"&gt;pyodide&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="webassembly"/><category term="pyodide"/></entry><entry><title>cysqlite WebAssembly Wheel</title><link href="https://simonwillison.net/2026/Feb/10/cysqlite-wasm-wheel/" rel="alternate"/><published>2026-02-10T22:11:00+00:00</published><updated>2026-02-10T22:11:00+00:00</updated><id>https://simonwillison.net/2026/Feb/10/cysqlite-wasm-wheel/</id><summary type="html">
    
        &lt;p&gt;&lt;strong&gt;Research:&lt;/strong&gt; &lt;a href="https://github.com/simonw/research/tree/main/cysqlite-wasm-wheel#readme"&gt;cysqlite WebAssembly Wheel&lt;/a&gt;&lt;/p&gt;
        
    
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/webassembly"&gt;webassembly&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/pyodide"&gt;pyodide&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="webassembly"/><category term="pyodide"/></entry><entry><title>Running Pydantic's Monty Rust sandboxed Python subset in WebAssembly</title><link href="https://simonwillison.net/2026/Feb/6/pydantic-monty/" rel="alternate"/><published>2026-02-06T22:31:31+00:00</published><updated>2026-02-06T22:31:31+00:00</updated><id>https://simonwillison.net/2026/Feb/6/pydantic-monty/</id><summary type="html">
    &lt;p&gt;There's a jargon-filled headline for you! Everyone's &lt;a href="https://simonwillison.net/2026/Jan/8/llm-predictions-for-2026/#1-year-we-re-finally-going-to-solve-sandboxing"&gt;building sandboxes&lt;/a&gt; for running untrusted code right now, and Pydantic's latest attempt, &lt;a href="https://github.com/pydantic/monty"&gt;Monty&lt;/a&gt;, provides a custom Python-like language (a subset of Python) in Rust and makes it available as both a Rust library and a Python package. I got it working in WebAssembly, providing a sandbox-in-a-sandbox.&lt;/p&gt;
&lt;p&gt;Here's &lt;a href="https://github.com/pydantic/monty"&gt;how they describe Monty&lt;/a&gt;:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Monty avoids the cost, latency, complexity and general faff of using full container based sandbox for running LLM generated code.&lt;/p&gt;
&lt;p&gt;Instead, it let's you safely run Python code written by an LLM embedded in your agent, with startup times measured in single digit microseconds not hundreds of milliseconds.&lt;/p&gt;
&lt;p&gt;What Monty &lt;strong&gt;can&lt;/strong&gt; do:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Run a reasonable subset of Python code - enough for your agent to express what it wants to do&lt;/li&gt;
&lt;li&gt;Completely block access to the host environment: filesystem, env variables and network access are all implemented via external function calls the developer can control&lt;/li&gt;
&lt;li&gt;Call functions on the host - only functions you give it access to [...]&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;A quick way to try it out is via &lt;a href="https://github.com/astral-sh/uv"&gt;uv&lt;/a&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;uv run --with pydantic-monty python -m asyncio
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then paste this into the Python interactive prompt - the &lt;code&gt;-m asyncio&lt;/code&gt; enables top-level await:&lt;/p&gt;
&lt;pre&gt;&lt;span&gt;import&lt;/span&gt; &lt;span&gt;pydantic_monty&lt;/span&gt;
&lt;span&gt;code&lt;/span&gt; &lt;span&gt;=&lt;/span&gt; &lt;span&gt;pydantic_monty&lt;/span&gt;.&lt;span&gt;Monty&lt;/span&gt;(&lt;span&gt;'print("hello " + str(4 * 5))'&lt;/span&gt;)
&lt;span&gt;await&lt;/span&gt; &lt;span&gt;pydantic_monty&lt;/span&gt;.&lt;span&gt;run_monty_async&lt;/span&gt;(&lt;span&gt;code&lt;/span&gt;)&lt;/pre&gt;
&lt;p&gt;Monty supports a &lt;em&gt;very&lt;/em&gt; small subset of Python - it doesn't even support class declarations yet!&lt;/p&gt;
&lt;p&gt;But, given its target use-case, that's not actually a problem.&lt;/p&gt;
&lt;p&gt;The neat thing about providing tools like this for LLMs is that they're really good at iterating against error messages. A coding agent can run some Python code, get an error message telling it that classes aren't supported and then try again with a different approach.&lt;/p&gt;
&lt;p&gt;I wanted to try this in a browser, so I fired up &lt;a href="https://simonwillison.net/2025/Nov/6/async-code-research/"&gt;a code research task&lt;/a&gt; in Claude Code for web and kicked it off with the following:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Clone &lt;a href="https://github.com/pydantic/monty"&gt;https://github.com/pydantic/monty&lt;/a&gt; to /tmp and figure out how to compile it into a python WebAssembly wheel that can then be loaded in Pyodide. The wheel file itself should be checked into the repo along with build scripts and passing pytest playwright test scripts that load Pyodide from a CDN and the wheel from a “python -m http.server” localhost and demonstrate it working&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Then a little later:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;I want an additional WASM file that works independently of Pyodide, which is also usable in a web browser - build that too along with playwright tests that show it working. Also build two HTML files - one called demo.html and one called pyodide-demo.html - these should work similar to &lt;a href="https://tools.simonwillison.net/micropython"&gt;https://tools.simonwillison.net/micropython&lt;/a&gt; (download that code with curl to inspect it) - one should load the WASM build, the other should load Pyodide and have it use the WASM wheel. These will be served by GitHub Pages so they can load the WASM and wheel from a relative path since the .html files will be served from the same folder as the wheel and WASM file&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Here's &lt;a href="https://gisthost.github.io/?22d88e6367d7e002c4fb383c213c2df2/page-001.html"&gt;the transcript&lt;/a&gt;, and the &lt;a href="https://github.com/simonw/research/tree/main/monty-wasm-pyodide"&gt;final research report&lt;/a&gt; it produced.&lt;/p&gt;
&lt;p&gt;I now have the Monty Rust code compiled to WebAssembly in two different shapes - as a &lt;code&gt;.wasm&lt;/code&gt; bundle you can load and call from JavaScript, and as a &lt;code&gt;monty-wasm-pyodide/pydantic_monty-0.0.3-cp313-cp313-emscripten_4_0_9_wasm32.whl&lt;/code&gt; wheel file which can be loaded into &lt;a href="https://pyodide.org/"&gt;Pyodide&lt;/a&gt; and then called from Python in Pyodide in WebAssembly in a browser.&lt;/p&gt;
&lt;p&gt;Here are those two demos, hosted on GitHub Pages:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://simonw.github.io/research/monty-wasm-pyodide/demo.html"&gt;Monty WASM demo&lt;/a&gt; - a UI over JavaScript that loads the Rust WASM module directly.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://simonw.github.io/research/monty-wasm-pyodide/pyodide-demo.html"&gt;Monty Pyodide demo&lt;/a&gt; - this one provides an identical interface but here the code is &lt;a href="https://github.com/simonw/research/blob/3add1ffec70b530711fa237d91f546da5bcf1f1c/monty-wasm-pyodide/pyodide-demo.html#L257-L280"&gt;loading Pyodide and then installing the Monty WASM wheel&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2026/monty-pyodide.jpg" alt="Screenshot of a web app titled &amp;quot;Monty via Pyodide&amp;quot; with description &amp;quot;Run Monty (a sandboxed Python interpreter by Pydantic) inside Pyodide (CPython compiled to WebAssembly). This loads the pydantic-monty wheel and uses its full Python API. Code is saved in the URL for sharing.&amp;quot; A green banner reads &amp;quot;Code executed successfully!&amp;quot; Below are example buttons labeled &amp;quot;Basic&amp;quot;, &amp;quot;Inputs&amp;quot;, &amp;quot;Reuse&amp;quot;, &amp;quot;Error Handling&amp;quot;, &amp;quot;Fibonacci&amp;quot;, and &amp;quot;Classes&amp;quot;. A code editor labeled &amp;quot;Python Code (runs inside Monty sandbox via Pyodide):&amp;quot; contains: &amp;quot;import pydantic_monty\n\n# Create interpreter with input variables\nm = pydantic_monty.Monty('x + y', inputs=['x', 'y'])\n\n# Run with different inputs\nresult1 = m.run(inputs={&amp;quot;x&amp;quot;: 10, &amp;quot;y&amp;quot;: 20})\nprint(f&amp;quot;10 + 20 = {result1}&amp;quot;)\n\nresult2 = m.run(inputs={&amp;quot;x&amp;quot;: 100, &amp;quot;y&amp;quot;: 200})&amp;quot; with &amp;quot;Run Code&amp;quot; and &amp;quot;Clear&amp;quot; buttons. The Output section shows &amp;quot;10 + 20 = 30&amp;quot; and &amp;quot;100 + 200 = 300&amp;quot; with a &amp;quot;Copy&amp;quot; button. Footer reads &amp;quot;Executed in 4.0ms&amp;quot;." style="max-width: 100%;" /&gt;&lt;/p&gt;
&lt;p&gt;As a connoisseur of sandboxes - the more options the better! - this new entry from Pydantic ticks a lot of my boxes. It's small, fast, widely available (thanks to Rust and WebAssembly) and provides strict limits on memory usage, CPU time and access to disk and network.&lt;/p&gt;
&lt;p&gt;It was also a great excuse to spin up another demo showing how easy it is these days to turn compiled code like C or Rust into WebAssembly that runs in both a browser and a Pyodide environment.&lt;/p&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/javascript"&gt;javascript&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/python"&gt;python&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/sandboxing"&gt;sandboxing&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai"&gt;ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/rust"&gt;rust&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/webassembly"&gt;webassembly&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/pyodide"&gt;pyodide&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/generative-ai"&gt;generative-ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llms"&gt;llms&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai-assisted-programming"&gt;ai-assisted-programming&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/pydantic"&gt;pydantic&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/coding-agents"&gt;coding-agents&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/claude-code"&gt;claude-code&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="javascript"/><category term="python"/><category term="sandboxing"/><category term="ai"/><category term="rust"/><category term="webassembly"/><category term="pyodide"/><category term="generative-ai"/><category term="llms"/><category term="ai-assisted-programming"/><category term="pydantic"/><category term="coding-agents"/><category term="claude-code"/></entry><entry><title>Monty WASM + Pyodide</title><link href="https://simonwillison.net/2026/Feb/6/monty-wasm-pyodide/" rel="alternate"/><published>2026-02-06T18:05:00+00:00</published><updated>2026-02-06T18:05:00+00:00</updated><id>https://simonwillison.net/2026/Feb/6/monty-wasm-pyodide/</id><summary type="html">
    
        &lt;p&gt;&lt;strong&gt;Research:&lt;/strong&gt; &lt;a href="https://github.com/simonw/research/tree/main/monty-wasm-pyodide#readme"&gt;Monty WASM + Pyodide&lt;/a&gt;&lt;/p&gt;
        
    
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/webassembly"&gt;webassembly&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/pyodide"&gt;pyodide&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="webassembly"/><category term="pyodide"/></entry><entry><title>Building PyO3/Maturin Rust Extension Modules as WebAssembly Wheels for Pyodide</title><link href="https://simonwillison.net/2026/Feb/6/pyo3-pyodide-wasm/" rel="alternate"/><published>2026-02-06T15:38:00+00:00</published><updated>2026-02-06T15:38:00+00:00</updated><id>https://simonwillison.net/2026/Feb/6/pyo3-pyodide-wasm/</id><summary type="html">
    
        &lt;p&gt;&lt;strong&gt;Research:&lt;/strong&gt; &lt;a href="https://github.com/simonw/research/tree/main/pyo3-pyodide-wasm#readme"&gt;Building PyO3/Maturin Rust Extension Modules as WebAssembly Wheels for Pyodide&lt;/a&gt;&lt;/p&gt;
        
    
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/rust"&gt;rust&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/webassembly"&gt;webassembly&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/pyodide"&gt;pyodide&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="rust"/><category term="webassembly"/><category term="pyodide"/></entry><entry><title>SQLite AST</title><link href="https://simonwillison.net/2026/Jan/30/sqlite-ast/" rel="alternate"/><published>2026-01-30T06:26:43+00:00</published><updated>2026-01-30T06:26:43+00:00</updated><id>https://simonwillison.net/2026/Jan/30/sqlite-ast/</id><summary type="html">
    
        &lt;p&gt;&lt;strong&gt;Tool:&lt;/strong&gt; &lt;a href="https://tools.simonwillison.net/sqlite-ast"&gt;SQLite AST&lt;/a&gt;&lt;/p&gt;
        
    
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/pyodide"&gt;pyodide&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="pyodide"/></entry><entry><title>MicroQuickJS</title><link href="https://simonwillison.net/2025/Dec/23/microquickjs/" rel="alternate"/><published>2025-12-23T20:53:40+00:00</published><updated>2025-12-23T20:53:40+00:00</updated><id>https://simonwillison.net/2025/Dec/23/microquickjs/</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/bellard/mquickjs"&gt;MicroQuickJS&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
New project from programming legend Fabrice Bellard, of ffmpeg and QEMU and QuickJS and &lt;a href="https://bellard.org"&gt;so much more&lt;/a&gt; fame:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;MicroQuickJS (aka. MQuickJS) is a Javascript engine targetted at embedded systems. It compiles and runs Javascript programs with as low as 10 kB of RAM. The whole engine requires about 100 kB of ROM (ARM Thumb-2 code) including the C library. The speed is comparable to QuickJS.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;It supports &lt;a href="https://github.com/bellard/mquickjs/blob/17ce6fe54c1ea4f500f26636bd22058fce2ce61a/README.md#javascript-subset-reference"&gt;a subset of full JavaScript&lt;/a&gt;, though it looks like a rich and full-featured subset to me.&lt;/p&gt;
&lt;p&gt;One of my ongoing interests is sandboxing: mechanisms for executing untrusted code - from end users or generated by LLMs - in an environment that restricts memory usage and applies a strict time limit and restricts file or network access. Could MicroQuickJS be useful in that context?&lt;/p&gt;
&lt;p&gt;I fired up Claude Code for web (on my iPhone) and kicked off &lt;a href="https://simonwillison.net/2025/Nov/6/async-code-research/"&gt;an asynchronous research project&lt;/a&gt; to see explore that question:&lt;/p&gt;
&lt;p&gt;My full prompt &lt;a href="https://github.com/simonw/research/pull/50#issue-3757781692"&gt;is here&lt;/a&gt;. It started like this:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;Clone https://github.com/bellard/mquickjs to /tmp&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Investigate this code as the basis for a safe sandboxing environment for running untrusted code such that it cannot exhaust memory or CPU or access files or the network&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;First try building python bindings for this using FFI - write a script that builds these by checking out the code to /tmp and building against that, to avoid copying the C code in this repo permanently. Write and execute tests with pytest to exercise it as a sandbox&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Then build a "real" Python extension not using FFI and experiment with that&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Then try compiling the C to WebAssembly and exercising it via both node.js and Deno, with a similar suite of tests [...]&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;I later added to the interactive session:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;Does it have a regex engine that might allow a resource exhaustion attack from an expensive regex?&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;(The answer was no - the regex engine calls the interrupt handler even during pathological expression backtracking, meaning that any configured time limit should still hold.)&lt;/p&gt;
&lt;p&gt;Here's &lt;a href="https://gistpreview.github.io/?6e07c54db7bb8ed8aa0eccfe4a384679"&gt;the full transcript&lt;/a&gt; and the &lt;a href="https://github.com/simonw/research/blob/main/mquickjs-sandbox/README.md"&gt;final report&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Some key observations:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;MicroQuickJS is &lt;em&gt;very&lt;/em&gt; well suited to the sandbox problem. It has robust near and time limits baked in, it doesn't expose any dangerous primitive like filesystem of network access and even has a regular expression engine that protects against exhaustion attacks (provided you configure a time limit).&lt;/li&gt;
&lt;li&gt;Claude span up and tested a Python library that calls a MicroQuickJS shared library (involving a little bit of extra C), a compiled a Python binding and a library that uses the original MicroQuickJS CLI tool. All of those approaches work well.&lt;/li&gt;
&lt;li&gt;Compiling to WebAssembly was a little harder. It got a version working in Node.js and Deno and Pyodide, but the Python libraries wasmer and wasmtime proved harder, apparently because "mquickjs uses setjmp/longjmp for error handling". It managed to get to a working wasmtime version with &lt;a href="https://github.com/simonw/research/blob/main/mquickjs-sandbox/README.md#working-solution"&gt;a gross hack&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I'm really excited about this. MicroQuickJS is tiny, full featured, looks robust and comes from excellent pedigree. I think this makes for a very solid new entrant in the quest for a robust sandbox.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Update&lt;/strong&gt;: I had Claude Code build &lt;a href="https://tools.simonwillison.net/microquickjs"&gt;tools.simonwillison.net/microquickjs&lt;/a&gt;, an interactive web playground for trying out the WebAssembly build of MicroQuickJS, adapted from my previous &lt;a href="https://tools.simonwillison.net/quickjs"&gt;QuickJS plaground&lt;/a&gt;. My QuickJS page loads 2.28 MB (675 KB transferred). The MicroQuickJS one loads 303 KB (120 KB transferred).&lt;/p&gt;
&lt;p&gt;Here are &lt;a href="https://github.com/simonw/tools/pull/180#issue-3758595291"&gt;the prompts I used&lt;/a&gt; for that.


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/c"&gt;c&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/javascript"&gt;javascript&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/nodejs"&gt;nodejs&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/python"&gt;python&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/regular-expressions"&gt;regular-expressions&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/sandboxing"&gt;sandboxing&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai"&gt;ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/webassembly"&gt;webassembly&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/deno"&gt;deno&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/pyodide"&gt;pyodide&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/generative-ai"&gt;generative-ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llms"&gt;llms&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/claude-code"&gt;claude-code&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/fabrice-bellard"&gt;fabrice-bellard&lt;/a&gt;&lt;/p&gt;



</summary><category term="c"/><category term="javascript"/><category term="nodejs"/><category term="python"/><category term="regular-expressions"/><category term="sandboxing"/><category term="ai"/><category term="webassembly"/><category term="deno"/><category term="pyodide"/><category term="generative-ai"/><category term="llms"/><category term="claude-code"/><category term="fabrice-bellard"/></entry><entry><title>JustHTML Playground - HTML5 Parser</title><link href="https://simonwillison.net/2025/Dec/13/justhtml/" rel="alternate"/><published>2025-12-13T17:48:21+00:00</published><updated>2025-12-13T17:48:21+00:00</updated><id>https://simonwillison.net/2025/Dec/13/justhtml/</id><summary type="html">
    
        &lt;p&gt;&lt;strong&gt;Tool:&lt;/strong&gt; &lt;a href="https://tools.simonwillison.net/justhtml"&gt;JustHTML Playground - HTML5 Parser&lt;/a&gt;&lt;/p&gt;
        
    
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/pyodide"&gt;pyodide&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="pyodide"/></entry><entry><title>Datasette Lite NPM Package Investigation</title><link href="https://simonwillison.net/2025/Dec/8/datasette-lite-npm-package/" rel="alternate"/><published>2025-12-08T03:58:00+00:00</published><updated>2025-12-08T03:58:00+00:00</updated><id>https://simonwillison.net/2025/Dec/8/datasette-lite-npm-package/</id><summary type="html">
    
        &lt;p&gt;&lt;strong&gt;Research:&lt;/strong&gt; &lt;a href="https://github.com/simonw/research/tree/main/datasette-lite-npm-package#readme"&gt;Datasette Lite NPM Package Investigation&lt;/a&gt;&lt;/p&gt;
        
    
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/pyodide"&gt;pyodide&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="pyodide"/></entry><entry><title>Self-Hosting Datasette Lite: Research Report</title><link href="https://simonwillison.net/2025/Nov/28/self-host-datasette-lite/" rel="alternate"/><published>2025-11-28T12:01:00+00:00</published><updated>2025-11-28T12:01:00+00:00</updated><id>https://simonwillison.net/2025/Nov/28/self-host-datasette-lite/</id><summary type="html">
    
        &lt;p&gt;&lt;strong&gt;Research:&lt;/strong&gt; &lt;a href="https://github.com/simonw/research/tree/main/self-host-datasette-lite#readme"&gt;Self-Hosting Datasette Lite: Research Report&lt;/a&gt;&lt;/p&gt;
        
    
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/pyodide"&gt;pyodide&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="pyodide"/></entry><entry><title>LLM Pyodide OpenAI Plugin</title><link href="https://simonwillison.net/2025/Nov/9/llm-pyodide-openai-plugin/" rel="alternate"/><published>2025-11-09T20:59:00+00:00</published><updated>2025-11-09T20:59:00+00:00</updated><id>https://simonwillison.net/2025/Nov/9/llm-pyodide-openai-plugin/</id><summary type="html">
    
        &lt;p&gt;&lt;strong&gt;Research:&lt;/strong&gt; &lt;a href="https://github.com/simonw/research/tree/main/llm-pyodide-openai-plugin#readme"&gt;LLM Pyodide OpenAI Plugin&lt;/a&gt;&lt;/p&gt;
        
    
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/pyodide"&gt;pyodide&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="pyodide"/></entry><entry><title>cmarkgfm in Pyodide - ✅ WORKING!</title><link href="https://simonwillison.net/2025/Oct/22/cmarkgfm-in-pyodide/" rel="alternate"/><published>2025-10-22T23:24:00+00:00</published><updated>2025-10-22T23:24:00+00:00</updated><id>https://simonwillison.net/2025/Oct/22/cmarkgfm-in-pyodide/</id><summary type="html">
    
        &lt;p&gt;&lt;strong&gt;Research:&lt;/strong&gt; &lt;a href="https://github.com/simonw/research/tree/main/cmarkgfm-in-pyodide#readme"&gt;cmarkgfm in Pyodide - ✅ WORKING!&lt;/a&gt;&lt;/p&gt;
        
    
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/webassembly"&gt;webassembly&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/pyodide"&gt;pyodide&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="webassembly"/><category term="pyodide"/></entry><entry><title>Pyodide Simple Demo</title><link href="https://simonwillison.net/2025/Oct/20/node-pyodide/" rel="alternate"/><published>2025-10-20T03:19:00+00:00</published><updated>2025-10-20T03:19:00+00:00</updated><id>https://simonwillison.net/2025/Oct/20/node-pyodide/</id><summary type="html">
    
        &lt;p&gt;&lt;strong&gt;Research:&lt;/strong&gt; &lt;a href="https://github.com/simonw/research/tree/main/node-pyodide#readme"&gt;Pyodide Simple Demo&lt;/a&gt;&lt;/p&gt;
        
    
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/webassembly"&gt;webassembly&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/pyodide"&gt;pyodide&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="webassembly"/><category term="pyodide"/></entry><entry><title>NumPy Vectors &amp; Matrices — Pyodide Lab</title><link href="https://simonwillison.net/2025/Sep/24/numpy-pyodide-lab/" rel="alternate"/><published>2025-09-24T23:35:16+00:00</published><updated>2025-09-24T23:35:16+00:00</updated><id>https://simonwillison.net/2025/Sep/24/numpy-pyodide-lab/</id><summary type="html">
    
        &lt;p&gt;&lt;strong&gt;Tool:&lt;/strong&gt; &lt;a href="https://tools.simonwillison.net/numpy-pyodide-lab"&gt;NumPy Vectors &amp;amp; Matrices — Pyodide Lab&lt;/a&gt;&lt;/p&gt;
        
    
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/pyodide"&gt;pyodide&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="pyodide"/></entry><entry><title>Recreating the Apollo AI adoption rate chart with GPT-5, Python and Pyodide</title><link href="https://simonwillison.net/2025/Sep/9/apollo-ai-adoption/" rel="alternate"/><published>2025-09-09T06:47:49+00:00</published><updated>2025-09-09T06:47:49+00:00</updated><id>https://simonwillison.net/2025/Sep/9/apollo-ai-adoption/</id><summary type="html">
    &lt;p&gt;Apollo Global Management's "Chief Economist" Dr. Torsten Sløk released &lt;a href="https://www.apolloacademy.com/ai-adoption-rate-trending-down-for-large-companies/"&gt;this interesting chart&lt;/a&gt; which appears to show a slowdown in AI adoption rates among large (&amp;gt;250 employees) companies:&lt;/p&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2025/apollo-ai-chart.jpg" alt="AI adoption rates starting to decline for larger firms. A chart of AI adoption rate by firm size. Includes lines for 250+, 100-249, 50-99, 20-49, 10-19, 5-8 and 1-4 sized organizations. Chart starts in November 2023 with percentages ranging from 3 to 5, then all groups grow through August 2025 albeit with the 250+ group having a higher score than the others. That 25+ group peaks in Jul5 2025 at around 14% and then appears to slope slightly downwards to 12% by August. Some of the other lines also start to tip down, though not as much." style="max-width: 100%;" /&gt;&lt;/p&gt;
&lt;p&gt;Here's the full description that accompanied the chart:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;The US Census Bureau conducts a biweekly survey of 1.2 million firms, and one question is whether a business has used AI tools such as machine learning, natural language processing, virtual agents or voice recognition to help produce goods or services in the past two weeks. Recent data by firm size shows that AI adoption has been declining among companies with more than 250 employees, see chart below.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;(My first thought on seeing that chart is that I hope it represents the &lt;em&gt;peak of inflated expectations&lt;/em&gt; leading into the &lt;em&gt;trough of dissillusionment&lt;/em&gt; in the &lt;a href="https://en.wikipedia.org/wiki/Gartner_hype_cycle"&gt;Gartner Hype Cycle&lt;/a&gt; (which Wikipedia calls "largely disputed, with studies pointing to it being inconsistently true at best"), since that means we might be reaching the end of the initial hype phase and heading towards the &lt;em&gt;slope of enlightenment&lt;/em&gt;.)&lt;/p&gt;
&lt;h4 id="finding-the-us-census-data-with-gpt-5-search"&gt;Finding the US Census data with GPT-5 search&lt;/h4&gt;
&lt;p&gt;This is the first I'd heard of the US Census Bureau running a biweekly (that's once every two weeks) survey about AI!&lt;/p&gt;
&lt;p&gt;I decided to track down the numbers and see if I could recreate the chart myself. And since &lt;a href="https://simonwillison.net/2025/Sep/6/research-goblin/"&gt;GPT-5 is really good at search now&lt;/a&gt; I fed it the following prompt to see how well it could do:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;&amp;gt; The US Census Bureau conducts a biweekly survey of 1.2 million firms, and one question is whether a business has used AI tools such as machine learning, natural language processing, virtual agents or voice recognition to help produce goods or services in the past two weeks. Recent data by firm size shows that AI adoption has been declining among companies with more than 250 employees, see chart below.&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Find me that original data&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;It took 1m19s to &lt;a href="https://chatgpt.com/share/68bf48cf-0e70-8006-a045-96fa8e7ddfc1"&gt;find exactly what I needed&lt;/a&gt;:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Here’s the original U.S. Census Bureau source for that series:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;BTOS “Employment size class” time series (XLSX)&lt;/strong&gt; — biweekly shares of firms answering &lt;strong&gt;Yes&lt;/strong&gt; to “In the last two weeks, did this business use Artificial Intelligence (AI) in producing goods or services?”, broken out by firm-size buckets (incl. &lt;strong&gt;250+ employees&lt;/strong&gt;). Coverage: &lt;strong&gt;Sep 11, 2023 → Aug 24, 2025&lt;/strong&gt;. (&lt;a href="https://www.census.gov/hfp/btos/data_downloads"&gt;Census.gov&lt;/a&gt;) [...]&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;That &lt;a href="https://www.census.gov/hfp/btos/data_downloads"&gt;Census page&lt;/a&gt; was not &lt;em&gt;at all&lt;/em&gt; obvious. Thankfully GPT-5 had tipped me off to the "Employment size class" file, this link here:&lt;/p&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2025/census-page.jpg" alt="US Census website. Business Trends and Outlook Survey, Updated August 28, 2025. Current Data has 6 visible XLSX files with names like WFH Supplement, WFH Questions 27-29, National, Sectur, Subsector and Emplomyent size class. A red arrow highlights that last one." style="max-width: 100%;" /&gt;&lt;/p&gt;
&lt;p&gt;So I downloaded that file, and confirmed that it was indeed a spreadsheet containing the data I wanted (in among all sorts of other survey questions). Here's &lt;a href="https://static.simonwillison.net/static/cors-allow/2025/Employment-Size-Class-Sep-2025.xlsx"&gt;a 374KB XLSX copy&lt;/a&gt; of the file I downloaded.&lt;/p&gt;
&lt;h4 id="recreating-the-chart-with-gpt-5-code-interpreter"&gt;Recreating the chart with GPT-5 code interpreter&lt;/h4&gt;
&lt;p&gt;So what should I do with it now? I decided to see if GPT-5 could turn the spreadsheet back into that original chart, using Python running in its &lt;a href="https://simonwillison.net/tags/code-interpreter/"&gt;code interpreter&lt;/a&gt; tool.&lt;/p&gt;
&lt;p&gt;So I uploaded the XLSX file back to ChatGPT, dropped in a screenshot of the Apollo chart and prompted:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;Use this data to recreate this chart using python&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2025/chart-prompt.jpg" alt="ChatGPT. I dropped in a screenshot of the chart, uploaded the spreadsheet which turned into an inline table browser UI and prompted it to recreate the chart using python." style="max-width: 100%;" /&gt;&lt;/p&gt;
&lt;p&gt;I thought this was a pretty tall order, but it's always worth throwing big challenges at an LLM to learn from how well it does.&lt;/p&gt;
&lt;p&gt;It &lt;em&gt;really worked hard on this&lt;/em&gt;. I didn't time it exactly but it spent at least 7 minutes "reasoning" across 5 different thinking blocks, interspersed with over a dozen Python analysis sessions. It used &lt;code&gt;pandas&lt;/code&gt; and &lt;code&gt;numpy&lt;/code&gt; to explore the uploaded spreadsheet and find the right figures, then tried several attempts at plotting with &lt;code&gt;matplotlib&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;As far as I can tell GPT-5 in ChatGPT can now feed charts it creates back into its own vision model, because it appeared to render a broken (empty) chart and then keep on trying to get it working.&lt;/p&gt;
&lt;p&gt;It found a data dictionary in the last tab of the spreadsheet and used that to build a lookup table matching the letters &lt;code&gt;A&lt;/code&gt; through &lt;code&gt;G&lt;/code&gt; to the actual employee size buckets.&lt;/p&gt;
&lt;p&gt;At the end of the process it spat out this chart:&lt;/p&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2025/recreated-chart-1.jpg" alt="matplotlib chart. The title is AI adoption rates starting to decline for larger firms, though there's a typography glitch in that title. It has a neat legend for the different size ranges, then a set of lines that look about right compared to the above graph - but they are more spiky and the numbers appear to trend up again at the end of the chart." style="max-width: 100%;" /&gt;&lt;/p&gt;
&lt;p&gt;At first glance I thought it had nailed it... but then I compared the chart more closely with the Apollo original and spotted some definite discrepancies. GPT-5's chart peaked at 14.5% but the highest value in Apollo's was more like 13.5%. The GPT-5 chart was spikier - and most interestingly it included a clear uptick in the last data point where Apollo's had trended downwards.&lt;/p&gt;
&lt;p&gt;I decided it was time to look at the actual data. I opened up the spreadsheet in Numbers, found the AI question columns and manually reviewed them. They seemed to match the GPT-5 chart results - so why the difference to Apollo's?&lt;/p&gt;
&lt;p&gt;Then I noticed a crucial detail in the Apollo chart that I had cropped out of my original screenshot!&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Note: Data is six-survey moving average.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;So I told ChatGPT:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;Do the first question, plot it as a six survey rolling average&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;I asked for the first question because it turned out there were two that were relevant in the survey spreadsheet.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;In the last two weeks, did this business use Artificial Intelligence (AI) in producing goods or services? (Examples of AI: machine learning, natural language processing, virtual agents, voice recognition, etc.)&lt;/li&gt;
&lt;li&gt;During the next six months, do you think this business will be using Artificial Intelligence (AI) in producing goods or services? (Examples of AI: machine learning, natural language processing, virtual agents, voice recognition, etc.)&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;It churned away for a little longer, added this code to the script:&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-c"&gt;# Compute 6-survey rolling average (biweekly cadence → ~12 weeks)&lt;/span&gt;
&lt;span class="pl-s1"&gt;rolled&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;wide&lt;/span&gt;.&lt;span class="pl-c1"&gt;rolling&lt;/span&gt;(&lt;span class="pl-s1"&gt;window&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-c1"&gt;6&lt;/span&gt;, &lt;span class="pl-s1"&gt;min_periods&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-c1"&gt;6&lt;/span&gt;).&lt;span class="pl-c1"&gt;mean&lt;/span&gt;()&lt;/pre&gt;
&lt;p&gt;And popped out this chart (after I told it to fix the glitch in the title):&lt;/p&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2025/recreated-chart-2.jpg" alt="Second chart. This time the lines are basically an exact match for the Apollo one." style="max-width: 100%;" /&gt;&lt;/p&gt;
&lt;p&gt;I think it's done it! This is a very solid match for the Apollo original, recreated using &lt;code&gt;matplotlib&lt;/code&gt; and &lt;code&gt;pandas&lt;/code&gt; from the same underlying source data from the US Census.&lt;/p&gt;
&lt;p&gt;Here's the full Python code it wrote, which I think is quite readable (in as much as Pandas code can be):&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-k"&gt;import&lt;/span&gt; &lt;span class="pl-s1"&gt;pandas&lt;/span&gt; &lt;span class="pl-k"&gt;as&lt;/span&gt; &lt;span class="pl-s1"&gt;pd&lt;/span&gt;
&lt;span class="pl-k"&gt;import&lt;/span&gt; &lt;span class="pl-s1"&gt;matplotlib&lt;/span&gt;.&lt;span class="pl-s1"&gt;pyplot&lt;/span&gt; &lt;span class="pl-k"&gt;as&lt;/span&gt; &lt;span class="pl-s1"&gt;plt&lt;/span&gt;
&lt;span class="pl-k"&gt;from&lt;/span&gt; &lt;span class="pl-s1"&gt;matplotlib&lt;/span&gt;.&lt;span class="pl-s1"&gt;ticker&lt;/span&gt; &lt;span class="pl-k"&gt;import&lt;/span&gt; &lt;span class="pl-v"&gt;PercentFormatter&lt;/span&gt;

&lt;span class="pl-s1"&gt;path&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s"&gt;"/mnt/data/Employment Size Class.xlsx"&lt;/span&gt;

&lt;span class="pl-s1"&gt;resp&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;pd&lt;/span&gt;.&lt;span class="pl-c1"&gt;read_excel&lt;/span&gt;(&lt;span class="pl-s1"&gt;path&lt;/span&gt;, &lt;span class="pl-s1"&gt;sheet_name&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-s"&gt;"Response Estimates"&lt;/span&gt;)
&lt;span class="pl-s1"&gt;dates&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;pd&lt;/span&gt;.&lt;span class="pl-c1"&gt;read_excel&lt;/span&gt;(&lt;span class="pl-s1"&gt;path&lt;/span&gt;, &lt;span class="pl-s1"&gt;sheet_name&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-s"&gt;"Collection and Reference Dates"&lt;/span&gt;)

&lt;span class="pl-s1"&gt;is_current&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;resp&lt;/span&gt;[&lt;span class="pl-s"&gt;"Question"&lt;/span&gt;].&lt;span class="pl-c1"&gt;astype&lt;/span&gt;(&lt;span class="pl-s1"&gt;str&lt;/span&gt;).&lt;span class="pl-c1"&gt;str&lt;/span&gt;.&lt;span class="pl-c1"&gt;strip&lt;/span&gt;().&lt;span class="pl-c1"&gt;str&lt;/span&gt;.&lt;span class="pl-c1"&gt;startswith&lt;/span&gt;(&lt;span class="pl-s"&gt;"In the last two weeks"&lt;/span&gt;)
&lt;span class="pl-s1"&gt;ai_yes&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;resp&lt;/span&gt;[&lt;span class="pl-s1"&gt;is_current&lt;/span&gt; &lt;span class="pl-c1"&gt;&amp;amp;&lt;/span&gt; &lt;span class="pl-s1"&gt;resp&lt;/span&gt;[&lt;span class="pl-s"&gt;"Answer"&lt;/span&gt;].&lt;span class="pl-c1"&gt;astype&lt;/span&gt;(&lt;span class="pl-s1"&gt;str&lt;/span&gt;).&lt;span class="pl-c1"&gt;str&lt;/span&gt;.&lt;span class="pl-c1"&gt;strip&lt;/span&gt;().&lt;span class="pl-c1"&gt;str&lt;/span&gt;.&lt;span class="pl-c1"&gt;lower&lt;/span&gt;().&lt;span class="pl-c1"&gt;eq&lt;/span&gt;(&lt;span class="pl-s"&gt;"yes"&lt;/span&gt;)].&lt;span class="pl-c1"&gt;copy&lt;/span&gt;()

&lt;span class="pl-s1"&gt;code_to_bucket&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; {&lt;span class="pl-s"&gt;"A"&lt;/span&gt;:&lt;span class="pl-s"&gt;"1-4"&lt;/span&gt;,&lt;span class="pl-s"&gt;"B"&lt;/span&gt;:&lt;span class="pl-s"&gt;"5-9"&lt;/span&gt;,&lt;span class="pl-s"&gt;"C"&lt;/span&gt;:&lt;span class="pl-s"&gt;"10-19"&lt;/span&gt;,&lt;span class="pl-s"&gt;"D"&lt;/span&gt;:&lt;span class="pl-s"&gt;"20-49"&lt;/span&gt;,&lt;span class="pl-s"&gt;"E"&lt;/span&gt;:&lt;span class="pl-s"&gt;"50-99"&lt;/span&gt;,&lt;span class="pl-s"&gt;"F"&lt;/span&gt;:&lt;span class="pl-s"&gt;"100-249"&lt;/span&gt;,&lt;span class="pl-s"&gt;"G"&lt;/span&gt;:&lt;span class="pl-s"&gt;"250 or more employees"&lt;/span&gt;}
&lt;span class="pl-s1"&gt;ai_yes&lt;/span&gt;[&lt;span class="pl-s"&gt;"Bucket"&lt;/span&gt;] &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;ai_yes&lt;/span&gt;[&lt;span class="pl-s"&gt;"Empsize"&lt;/span&gt;].&lt;span class="pl-c1"&gt;map&lt;/span&gt;(&lt;span class="pl-s1"&gt;code_to_bucket&lt;/span&gt;)

&lt;span class="pl-s1"&gt;period_cols&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; [&lt;span class="pl-s1"&gt;c&lt;/span&gt; &lt;span class="pl-k"&gt;for&lt;/span&gt; &lt;span class="pl-s1"&gt;c&lt;/span&gt; &lt;span class="pl-c1"&gt;in&lt;/span&gt; &lt;span class="pl-s1"&gt;ai_yes&lt;/span&gt;.&lt;span class="pl-c1"&gt;columns&lt;/span&gt; &lt;span class="pl-k"&gt;if&lt;/span&gt; &lt;span class="pl-en"&gt;str&lt;/span&gt;(&lt;span class="pl-s1"&gt;c&lt;/span&gt;).&lt;span class="pl-c1"&gt;isdigit&lt;/span&gt;() &lt;span class="pl-c1"&gt;and&lt;/span&gt; &lt;span class="pl-en"&gt;len&lt;/span&gt;(&lt;span class="pl-en"&gt;str&lt;/span&gt;(&lt;span class="pl-s1"&gt;c&lt;/span&gt;))&lt;span class="pl-c1"&gt;==&lt;/span&gt;&lt;span class="pl-c1"&gt;6&lt;/span&gt;]
&lt;span class="pl-s1"&gt;long&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;ai_yes&lt;/span&gt;.&lt;span class="pl-c1"&gt;melt&lt;/span&gt;(&lt;span class="pl-s1"&gt;id_vars&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;[&lt;span class="pl-s"&gt;"Bucket"&lt;/span&gt;], &lt;span class="pl-s1"&gt;value_vars&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-s1"&gt;period_cols&lt;/span&gt;, &lt;span class="pl-s1"&gt;var_name&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-s"&gt;"Smpdt"&lt;/span&gt;, &lt;span class="pl-s1"&gt;value_name&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-s"&gt;"value"&lt;/span&gt;)

&lt;span class="pl-s1"&gt;dates&lt;/span&gt;[&lt;span class="pl-s"&gt;"Smpdt"&lt;/span&gt;] &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;dates&lt;/span&gt;[&lt;span class="pl-s"&gt;"Smpdt"&lt;/span&gt;].&lt;span class="pl-c1"&gt;astype&lt;/span&gt;(&lt;span class="pl-s1"&gt;str&lt;/span&gt;)
&lt;span class="pl-s1"&gt;long&lt;/span&gt;[&lt;span class="pl-s"&gt;"Smpdt"&lt;/span&gt;] &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;long&lt;/span&gt;[&lt;span class="pl-s"&gt;"Smpdt"&lt;/span&gt;].&lt;span class="pl-c1"&gt;astype&lt;/span&gt;(&lt;span class="pl-s1"&gt;str&lt;/span&gt;)
&lt;span class="pl-s1"&gt;merged&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;long&lt;/span&gt;.&lt;span class="pl-c1"&gt;merge&lt;/span&gt;(&lt;span class="pl-s1"&gt;dates&lt;/span&gt;[[&lt;span class="pl-s"&gt;"Smpdt"&lt;/span&gt;,&lt;span class="pl-s"&gt;"Ref End"&lt;/span&gt;]], &lt;span class="pl-s1"&gt;on&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-s"&gt;"Smpdt"&lt;/span&gt;, &lt;span class="pl-s1"&gt;how&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-s"&gt;"left"&lt;/span&gt;)
&lt;span class="pl-s1"&gt;merged&lt;/span&gt;[&lt;span class="pl-s"&gt;"date"&lt;/span&gt;] &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;pd&lt;/span&gt;.&lt;span class="pl-c1"&gt;to_datetime&lt;/span&gt;(&lt;span class="pl-s1"&gt;merged&lt;/span&gt;[&lt;span class="pl-s"&gt;"Ref End"&lt;/span&gt;], &lt;span class="pl-s1"&gt;errors&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-s"&gt;"coerce"&lt;/span&gt;)

&lt;span class="pl-s1"&gt;merged&lt;/span&gt;[&lt;span class="pl-s"&gt;"value"&lt;/span&gt;] &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;pd&lt;/span&gt;.&lt;span class="pl-c1"&gt;to_numeric&lt;/span&gt;(&lt;span class="pl-s1"&gt;long&lt;/span&gt;[&lt;span class="pl-s"&gt;"value"&lt;/span&gt;].&lt;span class="pl-c1"&gt;astype&lt;/span&gt;(&lt;span class="pl-s1"&gt;str&lt;/span&gt;).&lt;span class="pl-c1"&gt;str&lt;/span&gt;.&lt;span class="pl-c1"&gt;replace&lt;/span&gt;(&lt;span class="pl-s"&gt;"%"&lt;/span&gt;,&lt;span class="pl-s"&gt;""&lt;/span&gt;,&lt;span class="pl-s1"&gt;regex&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-c1"&gt;False&lt;/span&gt;).&lt;span class="pl-c1"&gt;str&lt;/span&gt;.&lt;span class="pl-c1"&gt;strip&lt;/span&gt;(), &lt;span class="pl-s1"&gt;errors&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-s"&gt;"coerce"&lt;/span&gt;)

&lt;span class="pl-s1"&gt;order&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; [&lt;span class="pl-s"&gt;"250 or more employees"&lt;/span&gt;,&lt;span class="pl-s"&gt;"100-249"&lt;/span&gt;,&lt;span class="pl-s"&gt;"50-99"&lt;/span&gt;,&lt;span class="pl-s"&gt;"20-49"&lt;/span&gt;,&lt;span class="pl-s"&gt;"10-19"&lt;/span&gt;,&lt;span class="pl-s"&gt;"5-9"&lt;/span&gt;,&lt;span class="pl-s"&gt;"1-4"&lt;/span&gt;]
&lt;span class="pl-s1"&gt;wide&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;merged&lt;/span&gt;.&lt;span class="pl-c1"&gt;pivot_table&lt;/span&gt;(&lt;span class="pl-s1"&gt;index&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-s"&gt;"date"&lt;/span&gt;, &lt;span class="pl-s1"&gt;columns&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-s"&gt;"Bucket"&lt;/span&gt;, &lt;span class="pl-s1"&gt;values&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-s"&gt;"value"&lt;/span&gt;, &lt;span class="pl-s1"&gt;aggfunc&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-s"&gt;"mean"&lt;/span&gt;).&lt;span class="pl-c1"&gt;sort_index&lt;/span&gt;()
&lt;span class="pl-s1"&gt;wide&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;wide&lt;/span&gt;[[&lt;span class="pl-s1"&gt;c&lt;/span&gt; &lt;span class="pl-k"&gt;for&lt;/span&gt; &lt;span class="pl-s1"&gt;c&lt;/span&gt; &lt;span class="pl-c1"&gt;in&lt;/span&gt; &lt;span class="pl-s1"&gt;order&lt;/span&gt; &lt;span class="pl-k"&gt;if&lt;/span&gt; &lt;span class="pl-s1"&gt;c&lt;/span&gt; &lt;span class="pl-c1"&gt;in&lt;/span&gt; &lt;span class="pl-s1"&gt;wide&lt;/span&gt;.&lt;span class="pl-c1"&gt;columns&lt;/span&gt;]]
&lt;span class="pl-s1"&gt;rolled&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;wide&lt;/span&gt;.&lt;span class="pl-c1"&gt;rolling&lt;/span&gt;(&lt;span class="pl-s1"&gt;window&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-c1"&gt;6&lt;/span&gt;, &lt;span class="pl-s1"&gt;min_periods&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-c1"&gt;6&lt;/span&gt;).&lt;span class="pl-c1"&gt;mean&lt;/span&gt;()

&lt;span class="pl-s1"&gt;start&lt;/span&gt;, &lt;span class="pl-s1"&gt;end&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;pd&lt;/span&gt;.&lt;span class="pl-c1"&gt;Timestamp&lt;/span&gt;(&lt;span class="pl-s"&gt;"2023-11-01"&lt;/span&gt;), &lt;span class="pl-s1"&gt;pd&lt;/span&gt;.&lt;span class="pl-c1"&gt;Timestamp&lt;/span&gt;(&lt;span class="pl-s"&gt;"2025-08-31"&lt;/span&gt;)
&lt;span class="pl-s1"&gt;rolled_win&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;rolled&lt;/span&gt;.&lt;span class="pl-c1"&gt;loc&lt;/span&gt;[(&lt;span class="pl-s1"&gt;rolled&lt;/span&gt;.&lt;span class="pl-c1"&gt;index&lt;/span&gt; &lt;span class="pl-c1"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;start&lt;/span&gt;) &lt;span class="pl-c1"&gt;&amp;amp;&lt;/span&gt; (&lt;span class="pl-s1"&gt;rolled&lt;/span&gt;.&lt;span class="pl-c1"&gt;index&lt;/span&gt; &lt;span class="pl-c1"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;end&lt;/span&gt;)]

&lt;span class="pl-s1"&gt;fig&lt;/span&gt;, &lt;span class="pl-s1"&gt;ax&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;plt&lt;/span&gt;.&lt;span class="pl-c1"&gt;subplots&lt;/span&gt;(&lt;span class="pl-s1"&gt;figsize&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;(&lt;span class="pl-c1"&gt;12&lt;/span&gt;, &lt;span class="pl-c1"&gt;6&lt;/span&gt;))
&lt;span class="pl-k"&gt;for&lt;/span&gt; &lt;span class="pl-s1"&gt;col&lt;/span&gt; &lt;span class="pl-c1"&gt;in&lt;/span&gt; &lt;span class="pl-s1"&gt;order&lt;/span&gt;:
    &lt;span class="pl-k"&gt;if&lt;/span&gt; &lt;span class="pl-s1"&gt;col&lt;/span&gt; &lt;span class="pl-c1"&gt;in&lt;/span&gt; &lt;span class="pl-s1"&gt;rolled_win&lt;/span&gt;.&lt;span class="pl-c1"&gt;columns&lt;/span&gt;:
        &lt;span class="pl-s1"&gt;ax&lt;/span&gt;.&lt;span class="pl-c1"&gt;plot&lt;/span&gt;(&lt;span class="pl-s1"&gt;rolled_win&lt;/span&gt;.&lt;span class="pl-c1"&gt;index&lt;/span&gt;, &lt;span class="pl-s1"&gt;rolled_win&lt;/span&gt;[&lt;span class="pl-s1"&gt;col&lt;/span&gt;], &lt;span class="pl-s1"&gt;label&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-s1"&gt;col&lt;/span&gt;, &lt;span class="pl-s1"&gt;linewidth&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-c1"&gt;2&lt;/span&gt;)

&lt;span class="pl-s1"&gt;ax&lt;/span&gt;.&lt;span class="pl-c1"&gt;set_title&lt;/span&gt;(&lt;span class="pl-s"&gt;"AI adoption (last two weeks) — 6‑survey rolling average"&lt;/span&gt;, &lt;span class="pl-s1"&gt;pad&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-c1"&gt;16&lt;/span&gt;)
&lt;span class="pl-s1"&gt;ax&lt;/span&gt;.&lt;span class="pl-c1"&gt;yaxis&lt;/span&gt;.&lt;span class="pl-c1"&gt;set_major_formatter&lt;/span&gt;(&lt;span class="pl-en"&gt;PercentFormatter&lt;/span&gt;(&lt;span class="pl-c1"&gt;100&lt;/span&gt;))
&lt;span class="pl-s1"&gt;ax&lt;/span&gt;.&lt;span class="pl-c1"&gt;set_ylabel&lt;/span&gt;(&lt;span class="pl-s"&gt;"%"&lt;/span&gt;)
&lt;span class="pl-s1"&gt;ax&lt;/span&gt;.&lt;span class="pl-c1"&gt;set_xlabel&lt;/span&gt;(&lt;span class="pl-s"&gt;""&lt;/span&gt;)
&lt;span class="pl-s1"&gt;ax&lt;/span&gt;.&lt;span class="pl-c1"&gt;grid&lt;/span&gt;(&lt;span class="pl-c1"&gt;True&lt;/span&gt;, &lt;span class="pl-s1"&gt;alpha&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-c1"&gt;0.25&lt;/span&gt;, &lt;span class="pl-s1"&gt;linestyle&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-s"&gt;"--"&lt;/span&gt;)
&lt;span class="pl-s1"&gt;ax&lt;/span&gt;.&lt;span class="pl-c1"&gt;legend&lt;/span&gt;(&lt;span class="pl-s1"&gt;title&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-c1"&gt;None&lt;/span&gt;, &lt;span class="pl-s1"&gt;loc&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-s"&gt;"upper left"&lt;/span&gt;, &lt;span class="pl-s1"&gt;ncols&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-c1"&gt;2&lt;/span&gt;, &lt;span class="pl-s1"&gt;frameon&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-c1"&gt;False&lt;/span&gt;)
&lt;span class="pl-s1"&gt;plt&lt;/span&gt;.&lt;span class="pl-c1"&gt;tight_layout&lt;/span&gt;()

&lt;span class="pl-s1"&gt;png_path&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s"&gt;"/mnt/data/ai_adoption_rolling6_by_firm_size.png"&lt;/span&gt;
&lt;span class="pl-s1"&gt;svg_path&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s"&gt;"/mnt/data/ai_adoption_rolling6_by_firm_size.svg"&lt;/span&gt;
&lt;span class="pl-s1"&gt;plt&lt;/span&gt;.&lt;span class="pl-c1"&gt;savefig&lt;/span&gt;(&lt;span class="pl-s1"&gt;png_path&lt;/span&gt;, &lt;span class="pl-s1"&gt;dpi&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-c1"&gt;200&lt;/span&gt;, &lt;span class="pl-s1"&gt;bbox_inches&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-s"&gt;"tight"&lt;/span&gt;)
&lt;span class="pl-s1"&gt;plt&lt;/span&gt;.&lt;span class="pl-c1"&gt;savefig&lt;/span&gt;(&lt;span class="pl-s1"&gt;svg_path&lt;/span&gt;, &lt;span class="pl-s1"&gt;bbox_inches&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-s"&gt;"tight"&lt;/span&gt;)&lt;/pre&gt;
&lt;p&gt;I like how it generated &lt;a href="https://static.simonwillison.net/static/2025/ai_adoption_rolling6_by_firm_size.svg"&gt;an SVG version&lt;/a&gt; of the chart without me even asking for it.&lt;/p&gt;
&lt;p&gt;You can access &lt;a href="https://chatgpt.com/share/68bf48cf-0e70-8006-a045-96fa8e7ddfc1"&gt;the ChatGPT transcript&lt;/a&gt; to see full details of everything it did.&lt;/p&gt;
&lt;h4 id="rendering-that-chart-client-side-using-pyodide"&gt;Rendering that chart client-side using Pyodide&lt;/h4&gt;
&lt;p&gt;I had one more challenge to try out. Could I render that same chart entirely in the browser using &lt;a href="https://pyodide.org/en/stable/"&gt;Pyodide&lt;/a&gt;, which can execute both Pandas and Matplotlib?&lt;/p&gt;
&lt;p&gt;I fired up a new ChatGPT GPT-5 session and prompted:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;Build a canvas that loads Pyodide and uses it to render an example bar chart with pandas and matplotlib and then displays that on the page&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;My goal here was simply to see if I could get a proof of concept of a chart rendered, ideally using the Canvas feature of ChatGPT. Canvas is OpenAI's version of Claude Artifacts, which lets the model write and then execute HTML and JavaScript directly in the ChatGPT interface.&lt;/p&gt;
&lt;p&gt;It worked! Here's &lt;a href="https://chatgpt.com/c/68bf2993-ca94-832a-a95e-fb225911c0a6"&gt;the transcript&lt;/a&gt; and here's &lt;a href="https://tools.simonwillison.net/pyodide-bar-chart"&gt;what it built me&lt;/a&gt;, exported  to my &lt;a href="https://tools.simonwillison.net/"&gt;tools.simonwillison.net&lt;/a&gt; GitHub Pages site (&lt;a href="https://github.com/simonw/tools/blob/main/pyodide-bar-chart.html"&gt;source code here&lt;/a&gt;).&lt;/p&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2025/pyodide-matplotlib.jpg" alt="Screenshot of a web application demonstrating Pyodide integration. Header reads &amp;quot;Pyodide + pandas + matplotlib — Bar Chart&amp;quot; with subtitle &amp;quot;This page loads Pyodide in the browser, uses pandas to prep some data, renders a bar chart with matplotlib, and displays it below — all client-side.&amp;quot; Left panel shows terminal output: &amp;quot;Ready&amp;quot;, &amp;quot;# Python environment ready&amp;quot;, &amp;quot;• pandas 2.2.0&amp;quot;, &amp;quot;• numpy 1.26.4&amp;quot;, &amp;quot;• matplotlib 3.5.2&amp;quot;, &amp;quot;Running chart code...&amp;quot;, &amp;quot;Done. Chart updated.&amp;quot; with &amp;quot;Re-run demo&amp;quot; and &amp;quot;Show Python&amp;quot; buttons. Footer note: &amp;quot;CDN: pyodide, pandas, numpy, matplotlib are fetched on demand. First run may take a few seconds.&amp;quot; Right panel displays a bar chart titled &amp;quot;Example Bar Chart (pandas + matplotlib in Pyodide)&amp;quot; showing blue bars for months Jan through Jun with values approximately: Jan(125), Feb(130), Mar(80), Apr(85), May(85), Jun(120). Y-axis labeled &amp;quot;Streams&amp;quot; ranges 0-120, X-axis labeled &amp;quot;Month&amp;quot;." style="max-width: 100%;" /&gt;&lt;/p&gt;
&lt;p&gt;I've now proven to myself that I can render those Python charts directly in the browser. Next step: recreate the Apollo chart.&lt;/p&gt;
&lt;p&gt;I knew it would need a way to load the spreadsheet that was CORS-enabled. I uploaded my copy to my &lt;code&gt;/static/cors-allow/2025/...&lt;/code&gt; directory (configured in Cloudflare to serve CORS headers), pasted in the finished plotting code from earlier and told ChatGPT:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;Now update it to have less explanatory text and a less exciting design (black on white is fine) and run the equivalent of this:&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;(... pasted in Python code from earlier ...)&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Load the XLSX sheet from https://static.simonwillison.net/static/cors-allow/2025/Employment-Size-Class-Sep-2025.xlsx&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;It didn't quite work - I got an error about &lt;code&gt;openpyxl&lt;/code&gt; which I manually researched the fix for and prompted:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;Use await micropip.install("openpyxl") to install openpyxl - instead of using loadPackage&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;I had to paste in another error message:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;zipfile.BadZipFile: File is not a zip file&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Then one about a &lt;code&gt;SyntaxError: unmatched ')'&lt;/code&gt; and a &lt;code&gt;TypeError: Legend.__init__() got an unexpected keyword argument 'ncols'&lt;/code&gt; - copying and pasting error messages remains a frustrating but necessary part of the vibe-coding loop.&lt;/p&gt;
&lt;p&gt;... but with those fixes in place, the resulting code worked! Visit &lt;a href="https://tools.simonwillison.net/ai-adoption"&gt;tools.simonwillison.net/ai-adoption&lt;/a&gt; to see the final result:&lt;/p&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2025/recreated-chart-pyodide.jpg" alt="Web page. Title is AI adoption - 6-survey rolling average. Has a Run, Downlaed PNG, Downlaod SVG button. Panel on the left says Loading Python... Fetcing packages numpy, pandas, matplotlib. Installing openpyxl via micropop... ready. Running. Done. Right hand panel shows the rendered chart." style="max-width: 100%;" /&gt;&lt;/p&gt;
&lt;p&gt;Here's the code for that page, &lt;a href="https://github.com/simonw/tools/blob/main/ai-adoption.html"&gt;170 lines&lt;/a&gt; all-in of HTML, CSS, JavaScript and Python.&lt;/p&gt;
&lt;h4 id="what-i-ve-learned-from-this"&gt;What I've learned from this&lt;/h4&gt;
&lt;p&gt;This was another of those curiosity-inspired investigations that turned into a whole set of useful lessons.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;GPT-5 is great at tracking down US Census data, no matter how difficult their site is to understand if you don't work with their data often&lt;/li&gt;
&lt;li&gt;It can do a very good job of turning data + a screenshot of a chart into a recreation of that chart using code interpreter, Pandas and matplotlib&lt;/li&gt;
&lt;li&gt;Running Python + matplotlib in a browser via Pyodide is very easy and only takes a few dozen lines of code&lt;/li&gt;
&lt;li&gt;Fetching an XLSX sheet into Pyodide is only a small extra step using &lt;code&gt;pyfetch&lt;/code&gt; and &lt;code&gt;openpyxl&lt;/code&gt;:
&lt;pre style="margin-top: 0.5em"&gt;&lt;span class="pl-k"&gt;import&lt;/span&gt; &lt;span class="pl-s1"&gt;micropip&lt;/span&gt;
&lt;span class="pl-k"&gt;await&lt;/span&gt; &lt;span class="pl-s1"&gt;micropip&lt;/span&gt;.&lt;span class="pl-c1"&gt;install&lt;/span&gt;(&lt;span class="pl-s"&gt;"openpyxl"&lt;/span&gt;)
&lt;span class="pl-k"&gt;from&lt;/span&gt; &lt;span class="pl-s1"&gt;pyodide&lt;/span&gt;.&lt;span class="pl-s1"&gt;http&lt;/span&gt; &lt;span class="pl-k"&gt;import&lt;/span&gt; &lt;span class="pl-s1"&gt;pyfetch&lt;/span&gt;
&lt;span class="pl-s1"&gt;resp_fetch&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-k"&gt;await&lt;/span&gt; &lt;span class="pl-en"&gt;pyfetch&lt;/span&gt;(&lt;span class="pl-c1"&gt;URL&lt;/span&gt;)
&lt;span class="pl-s1"&gt;wb_bytes&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-k"&gt;await&lt;/span&gt; &lt;span class="pl-s1"&gt;resp_fetch&lt;/span&gt;.&lt;span class="pl-c1"&gt;bytes&lt;/span&gt;()
&lt;span class="pl-s1"&gt;xf&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;pd&lt;/span&gt;.&lt;span class="pl-c1"&gt;ExcelFile&lt;/span&gt;(&lt;span class="pl-s1"&gt;io&lt;/span&gt;.&lt;span class="pl-c1"&gt;BytesIO&lt;/span&gt;(&lt;span class="pl-s1"&gt;wb_bytes&lt;/span&gt;), &lt;span class="pl-s1"&gt;engine&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-s"&gt;'openpyxl'&lt;/span&gt;)&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;Another new-to-me pattern: you can render an image to the DOM from Pyodide code &lt;a href="https://github.com/simonw/tools/blob/cf26ed8a6f243159bdc90a3d88f818261732103f/ai-adoption.html#L124"&gt;like this&lt;/a&gt;:
&lt;pre style="margin-top: 0.5em"&gt;&lt;span class="pl-k"&gt;from&lt;/span&gt; &lt;span class="pl-s1"&gt;js&lt;/span&gt; &lt;span class="pl-k"&gt;import&lt;/span&gt; &lt;span class="pl-s1"&gt;document&lt;/span&gt;
&lt;span class="pl-s1"&gt;document&lt;/span&gt;.&lt;span class="pl-c1"&gt;getElementById&lt;/span&gt;(&lt;span class="pl-s"&gt;'plot'&lt;/span&gt;).&lt;span class="pl-c1"&gt;src&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s"&gt;'data:image/png;base64,'&lt;/span&gt; &lt;span class="pl-c1"&gt;+&lt;/span&gt; &lt;span class="pl-s1"&gt;img_b64&lt;/span&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I will most definitely be using these techniques again in future.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Update&lt;/strong&gt;: Coincidentally Claude released their own upgraded equivalent to ChatGPT Code Interpreter later on the day that I published this story, so I &lt;a href="https://simonwillison.net/2025/Sep/9/claude-code-interpreter/#something-much-harder-recreating-the-ai-adoption-chart"&gt;ran the same chart recreation experiment&lt;/a&gt; against Claude Sonnet 4 to see how it compared.&lt;/p&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/census"&gt;census&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/data-journalism"&gt;data-journalism&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/javascript"&gt;javascript&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/python"&gt;python&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/tools"&gt;tools&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/visualization"&gt;visualization&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai"&gt;ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/pyodide"&gt;pyodide&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/openai"&gt;openai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/generative-ai"&gt;generative-ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/chatgpt"&gt;chatgpt&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llms"&gt;llms&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai-assisted-programming"&gt;ai-assisted-programming&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/code-interpreter"&gt;code-interpreter&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llm-reasoning"&gt;llm-reasoning&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/vibe-coding"&gt;vibe-coding&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai-assisted-search"&gt;ai-assisted-search&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/gpt-5"&gt;gpt-5&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/gpt"&gt;gpt&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="census"/><category term="data-journalism"/><category term="javascript"/><category term="python"/><category term="tools"/><category term="visualization"/><category term="ai"/><category term="pyodide"/><category term="openai"/><category term="generative-ai"/><category term="chatgpt"/><category term="llms"/><category term="ai-assisted-programming"/><category term="code-interpreter"/><category term="llm-reasoning"/><category term="vibe-coding"/><category term="ai-assisted-search"/><category term="gpt-5"/><category term="gpt"/></entry><entry><title>AI Adoption Rolling Avg — Pyodide</title><link href="https://simonwillison.net/2025/Sep/8/ai-adoption/" rel="alternate"/><published>2025-09-08T20:14:03+00:00</published><updated>2025-09-08T20:14:03+00:00</updated><id>https://simonwillison.net/2025/Sep/8/ai-adoption/</id><summary type="html">
    
        &lt;p&gt;&lt;strong&gt;Tool:&lt;/strong&gt; &lt;a href="https://tools.simonwillison.net/ai-adoption"&gt;AI Adoption Rolling Avg — Pyodide&lt;/a&gt;&lt;/p&gt;
        
    
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/pyodide"&gt;pyodide&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="pyodide"/></entry><entry><title>Pyodide Bar Chart Demo (pandas + matplotlib)</title><link href="https://simonwillison.net/2025/Sep/8/pyodide-bar-chart/" rel="alternate"/><published>2025-09-08T19:17:27+00:00</published><updated>2025-09-08T19:17:27+00:00</updated><id>https://simonwillison.net/2025/Sep/8/pyodide-bar-chart/</id><summary type="html">
    
        &lt;p&gt;&lt;strong&gt;Tool:&lt;/strong&gt; &lt;a href="https://tools.simonwillison.net/pyodide-bar-chart"&gt;Pyodide Bar Chart Demo (pandas + matplotlib)&lt;/a&gt;&lt;/p&gt;
        
    
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/pyodide"&gt;pyodide&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="pyodide"/></entry><entry><title>Giving software away for free</title><link href="https://simonwillison.net/2025/Apr/28/give-it-away-for-free/" rel="alternate"/><published>2025-04-28T16:10:58+00:00</published><updated>2025-04-28T16:10:58+00:00</updated><id>https://simonwillison.net/2025/Apr/28/give-it-away-for-free/</id><summary type="html">
    &lt;p&gt;If you want to create completely &lt;strong&gt;free software&lt;/strong&gt; for other people to use, the absolute best delivery mechanism right now is static HTML and JavaScript served from a free web host with an established reputation.&lt;/p&gt;
&lt;p&gt;Thanks to WebAssembly the set of potential software that can be served in this way is vast and, I think, under appreciated. &lt;a href="https://pyodide.org/"&gt;Pyodide&lt;/a&gt; means we can ship client-side Python applications now!&lt;/p&gt;
&lt;p&gt;This assumes that you would like your gift to the world to keep working for as long as possible, while granting you the freedom to lose interest and move onto other projects without needing to keep covering expenses far into the future.&lt;/p&gt;
&lt;p&gt;Even the cheapest hosting plan requires you to monitor and update billing details every few years. Domains have to be renewed. Anything that runs server-side will inevitably need to be upgraded someday - and the longer you wait between upgrades the harder those become.&lt;/p&gt;
&lt;p&gt;My top choice for this kind of thing in 2025 is GitHub, using GitHub Pages. It's free for public repositories and I haven't seen GitHub break a working URL that they have hosted in the 17+ years since they first launched.&lt;/p&gt;
&lt;p&gt;A few years ago I'd have recommended Heroku on the basis that their free plan had stayed reliable for more than a decade, but Salesforce took that accumulated goodwill and &lt;a href="https://blog.heroku.com/next-chapter"&gt;incinerated it in 2022&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;It almost goes without saying that you should release it under an open source license. The license alone is not enough to ensure regular human beings can make use of what you have built though: give people a link to something that works!&lt;/p&gt;

    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/github"&gt;github&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/html"&gt;html&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/javascript"&gt;javascript&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/open-source"&gt;open-source&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/web-standards"&gt;web-standards&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/heroku"&gt;heroku&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/webassembly"&gt;webassembly&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/pyodide"&gt;pyodide&lt;/a&gt;&lt;/p&gt;



</summary><category term="github"/><category term="html"/><category term="javascript"/><category term="open-source"/><category term="web-standards"/><category term="heroku"/><category term="webassembly"/><category term="pyodide"/></entry><entry><title>MCP Run Python</title><link href="https://simonwillison.net/2025/Apr/18/mcp-run-python/" rel="alternate"/><published>2025-04-18T04:51:20+00:00</published><updated>2025-04-18T04:51:20+00:00</updated><id>https://simonwillison.net/2025/Apr/18/mcp-run-python/</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/pydantic/pydantic-ai/tree/main/mcp-run-python"&gt;MCP Run Python&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
Pydantic AI's MCP server for running LLM-generated Python code in a sandbox. They ended up using a trick I explored &lt;a href="https://til.simonwillison.net/deno/pyodide-sandbox"&gt;two years ago&lt;/a&gt;: using a &lt;a href="https://deno.com/"&gt;Deno&lt;/a&gt; process to run &lt;a href="https://pyodide.org/"&gt;Pyodide&lt;/a&gt; in a WebAssembly sandbox.&lt;/p&gt;
&lt;p&gt;Here's a bit of a wild trick: since Deno loads code on-demand from &lt;a href="https://jsr.io/"&gt;JSR&lt;/a&gt;, and &lt;a href="https://docs.astral.sh/uv/guides/scripts/"&gt;uv run&lt;/a&gt; can install Python dependencies on demand via the &lt;code&gt;--with&lt;/code&gt; option... here's a one-liner you can paste into a macOS shell (provided you have Deno and &lt;code&gt;uv&lt;/code&gt; installed already) which will run the example from &lt;a href="https://github.com/pydantic/pydantic-ai/blob/v0.1.2/mcp-run-python/README.md"&gt;their README&lt;/a&gt; - calculating the number of days between two dates in the most complex way imaginable:&lt;/p&gt;
&lt;pre&gt;ANTHROPIC_API_KEY=&lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;sk-ant-...&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt; \
uv run --with pydantic-ai python -c &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;
&lt;span class="pl-s"&gt;import asyncio&lt;/span&gt;
&lt;span class="pl-s"&gt;from pydantic_ai import Agent&lt;/span&gt;
&lt;span class="pl-s"&gt;from pydantic_ai.mcp import MCPServerStdio&lt;/span&gt;
&lt;span class="pl-s"&gt;&lt;/span&gt;
&lt;span class="pl-s"&gt;server = MCPServerStdio(&lt;/span&gt;
&lt;span class="pl-s"&gt;    "deno",&lt;/span&gt;
&lt;span class="pl-s"&gt;    args=[&lt;/span&gt;
&lt;span class="pl-s"&gt;        "run",&lt;/span&gt;
&lt;span class="pl-s"&gt;        "-N",&lt;/span&gt;
&lt;span class="pl-s"&gt;        "-R=node_modules",&lt;/span&gt;
&lt;span class="pl-s"&gt;        "-W=node_modules",&lt;/span&gt;
&lt;span class="pl-s"&gt;        "--node-modules-dir=auto",&lt;/span&gt;
&lt;span class="pl-s"&gt;        "jsr:@pydantic/mcp-run-python",&lt;/span&gt;
&lt;span class="pl-s"&gt;        "stdio",&lt;/span&gt;
&lt;span class="pl-s"&gt;    ],&lt;/span&gt;
&lt;span class="pl-s"&gt;)&lt;/span&gt;
&lt;span class="pl-s"&gt;agent = Agent("claude-3-5-haiku-latest", mcp_servers=[server])&lt;/span&gt;
&lt;span class="pl-s"&gt;&lt;/span&gt;
&lt;span class="pl-s"&gt;async def main():&lt;/span&gt;
&lt;span class="pl-s"&gt;    async with agent.run_mcp_servers():&lt;/span&gt;
&lt;span class="pl-s"&gt;        result = await agent.run("How many days between 2000-01-01 and 2025-03-18?")&lt;/span&gt;
&lt;span class="pl-s"&gt;    print(result.output)&lt;/span&gt;
&lt;span class="pl-s"&gt;&lt;/span&gt;
&lt;span class="pl-s"&gt;asyncio.run(main())&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;

&lt;p&gt;I ran that just now and got:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;The number of days between January 1st, 2000 and March 18th, 2025 is 9,208 days.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;I thoroughly enjoy how tools like &lt;code&gt;uv&lt;/code&gt; and Deno enable throwing together shell one-liner demos like this one.&lt;/p&gt;
&lt;p&gt;Here's &lt;a href="https://gist.github.com/simonw/54fc42ef9a7fb8f777162bbbfbba4f23"&gt;an extended version&lt;/a&gt; of this example which adds pretty-printed logging of the messages exchanged with the LLM to illustrate exactly what happened. The most important piece is this tool call where Claude 3.5 Haiku asks for Python code to be executed my the MCP server:&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-en"&gt;ToolCallPart&lt;/span&gt;(
    &lt;span class="pl-s1"&gt;tool_name&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-s"&gt;'run_python_code'&lt;/span&gt;,
    &lt;span class="pl-s1"&gt;args&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;{
        &lt;span class="pl-s"&gt;'python_code'&lt;/span&gt;: (
            &lt;span class="pl-s"&gt;'from datetime import date&lt;span class="pl-cce"&gt;\n&lt;/span&gt;'&lt;/span&gt;
            &lt;span class="pl-s"&gt;'&lt;span class="pl-cce"&gt;\n&lt;/span&gt;'&lt;/span&gt;
            &lt;span class="pl-s"&gt;'date1 = date(2000, 1, 1)&lt;span class="pl-cce"&gt;\n&lt;/span&gt;'&lt;/span&gt;
            &lt;span class="pl-s"&gt;'date2 = date(2025, 3, 18)&lt;span class="pl-cce"&gt;\n&lt;/span&gt;'&lt;/span&gt;
            &lt;span class="pl-s"&gt;'&lt;span class="pl-cce"&gt;\n&lt;/span&gt;'&lt;/span&gt;
            &lt;span class="pl-s"&gt;'days_between = (date2 - date1).days&lt;span class="pl-cce"&gt;\n&lt;/span&gt;'&lt;/span&gt;
            &lt;span class="pl-s"&gt;'print(f"Number of days between {date1} and {date2}: {days_between}")'&lt;/span&gt;
        ),
    },
    &lt;span class="pl-s1"&gt;tool_call_id&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-s"&gt;'toolu_01TXXnQ5mC4ry42DrM1jPaza'&lt;/span&gt;,
    &lt;span class="pl-s1"&gt;part_kind&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-s"&gt;'tool-call'&lt;/span&gt;,
)&lt;/pre&gt;

&lt;p&gt;I also managed to run it against &lt;a href="https://ollama.com/library/mistral-small3.1"&gt;Mistral Small 3.1&lt;/a&gt; (15GB) running locally using &lt;a href="https://ollama.com/"&gt;Ollama&lt;/a&gt; (I had to add "Use your python tool" to the prompt to get it to work):&lt;/p&gt;
&lt;pre&gt;ollama pull mistral-small3.1:24b

uv run --with devtools --with pydantic-ai python -c &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;
&lt;span class="pl-s"&gt;import asyncio&lt;/span&gt;
&lt;span class="pl-s"&gt;from devtools import pprint&lt;/span&gt;
&lt;span class="pl-s"&gt;from pydantic_ai import Agent, capture_run_messages&lt;/span&gt;
&lt;span class="pl-s"&gt;from pydantic_ai.models.openai import OpenAIModel&lt;/span&gt;
&lt;span class="pl-s"&gt;from pydantic_ai.providers.openai import OpenAIProvider&lt;/span&gt;
&lt;span class="pl-s"&gt;from pydantic_ai.mcp import MCPServerStdio&lt;/span&gt;
&lt;span class="pl-s"&gt;&lt;/span&gt;
&lt;span class="pl-s"&gt;server = MCPServerStdio(&lt;/span&gt;
&lt;span class="pl-s"&gt;    "deno",&lt;/span&gt;
&lt;span class="pl-s"&gt;    args=[&lt;/span&gt;
&lt;span class="pl-s"&gt;        "run",&lt;/span&gt;
&lt;span class="pl-s"&gt;        "-N",&lt;/span&gt;
&lt;span class="pl-s"&gt;        "-R=node_modules",&lt;/span&gt;
&lt;span class="pl-s"&gt;        "-W=node_modules",&lt;/span&gt;
&lt;span class="pl-s"&gt;        "--node-modules-dir=auto",&lt;/span&gt;
&lt;span class="pl-s"&gt;        "jsr:@pydantic/mcp-run-python",&lt;/span&gt;
&lt;span class="pl-s"&gt;        "stdio",&lt;/span&gt;
&lt;span class="pl-s"&gt;    ],&lt;/span&gt;
&lt;span class="pl-s"&gt;)&lt;/span&gt;
&lt;span class="pl-s"&gt;&lt;/span&gt;
&lt;span class="pl-s"&gt;agent = Agent( &lt;/span&gt;
&lt;span class="pl-s"&gt;    OpenAIModel(                          &lt;/span&gt;
&lt;span class="pl-s"&gt;        model_name="mistral-small3.1:latest",&lt;/span&gt;
&lt;span class="pl-s"&gt;        provider=OpenAIProvider(base_url="http://localhost:11434/v1"),                &lt;/span&gt;
&lt;span class="pl-s"&gt;    ),            &lt;/span&gt;
&lt;span class="pl-s"&gt;    mcp_servers=[server],&lt;/span&gt;
&lt;span class="pl-s"&gt;)&lt;/span&gt;
&lt;span class="pl-s"&gt;&lt;/span&gt;
&lt;span class="pl-s"&gt;async def main():&lt;/span&gt;
&lt;span class="pl-s"&gt;    with capture_run_messages() as messages:&lt;/span&gt;
&lt;span class="pl-s"&gt;        async with agent.run_mcp_servers():&lt;/span&gt;
&lt;span class="pl-s"&gt;            result = await agent.run("How many days between 2000-01-01 and 2025-03-18? Use your python tool.")&lt;/span&gt;
&lt;span class="pl-s"&gt;    pprint(messages)&lt;/span&gt;
&lt;span class="pl-s"&gt;    print(result.output)&lt;/span&gt;
&lt;span class="pl-s"&gt;&lt;/span&gt;
&lt;span class="pl-s"&gt;asyncio.run(main())&lt;span class="pl-pds"&gt;'&lt;/span&gt;&lt;/span&gt;&lt;/pre&gt;

&lt;p&gt;Here's &lt;a href="https://gist.github.com/simonw/e444a81440bda2f37b0fef205780074a"&gt;the full output&lt;/a&gt; including the debug logs.

    &lt;p&gt;&lt;small&gt;&lt;/small&gt;Via &lt;a href="https://news.ycombinator.com/item?id=43691230"&gt;Hacker News&lt;/a&gt;&lt;/small&gt;&lt;/p&gt;


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/python"&gt;python&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/sandboxing"&gt;sandboxing&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai"&gt;ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/deno"&gt;deno&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/pyodide"&gt;pyodide&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/generative-ai"&gt;generative-ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/local-llms"&gt;local-llms&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llms"&gt;llms&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/claude"&gt;claude&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/mistral"&gt;mistral&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llm-tool-use"&gt;llm-tool-use&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/uv"&gt;uv&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ollama"&gt;ollama&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/pydantic"&gt;pydantic&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/model-context-protocol"&gt;model-context-protocol&lt;/a&gt;&lt;/p&gt;



</summary><category term="python"/><category term="sandboxing"/><category term="ai"/><category term="deno"/><category term="pyodide"/><category term="generative-ai"/><category term="local-llms"/><category term="llms"/><category term="claude"/><category term="mistral"/><category term="llm-tool-use"/><category term="uv"/><category term="ollama"/><category term="pydantic"/><category term="model-context-protocol"/></entry><entry><title>URL-addressable Pyodide Python environments</title><link href="https://simonwillison.net/2025/Feb/13/url-addressable-python/" rel="alternate"/><published>2025-02-13T05:13:27+00:00</published><updated>2025-02-13T05:13:27+00:00</updated><id>https://simonwillison.net/2025/Feb/13/url-addressable-python/</id><summary type="html">
    &lt;p&gt;This evening I spotted &lt;a href="https://github.com/simonw/datasette/issues/2466"&gt;an obscure bug&lt;/a&gt; in &lt;a href="https://datasette.io/"&gt;Datasette&lt;/a&gt;, using &lt;a href="https://github.com/simonw/datasette-lite"&gt;Datasette Lite&lt;/a&gt;. I figure it's a good opportunity to highlight how useful it is to have a URL-addressable Python environment, powered by Pyodide and WebAssembly.&lt;/p&gt;
&lt;p&gt;Here's the page that helped me discover the bug:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;&lt;a href="https://lite.datasette.io/?install=datasette-visible-internal-db&amp;amp;ref=1.0a17#/_internal/catalog_columns?_facet=database_name"&gt;https://lite.datasette.io/?install=datasette-visible-internal-db&amp;amp;ref=1.0a17#/_internal/catalog_columns?_facet=database_name&lt;/a&gt;&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;To explain what's going on here, let's first review the individual components.&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2025/Feb/13/url-addressable-python/#datasette-lite"&gt;Datasette Lite&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2025/Feb/13/url-addressable-python/#the-datasette-1-0-alphas"&gt;The Datasette 1.0 alphas&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2025/Feb/13/url-addressable-python/#this-works-for-plugins-too"&gt;This works for plugins, too&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2025/Feb/13/url-addressable-python/#datasette-visible-internal-db"&gt;datasette-visible-internal-db&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2025/Feb/13/url-addressable-python/#spotting-the-bug"&gt;Spotting the bug&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2025/Feb/13/url-addressable-python/#fixing-the-bug"&gt;Fixing the bug&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href="https://simonwillison.net/2025/Feb/13/url-addressable-python/#url-addressable-steps-to-reproduce"&gt;URL-addressable Steps To Reproduce&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;


&lt;h4 id="datasette-lite"&gt;Datasette Lite&lt;/h4&gt;
&lt;p&gt;&lt;a href="https://lite.datasette.io/"&gt;Datasette Lite&lt;/a&gt; is a version of &lt;a href="https://datasette.io/"&gt;Datasette&lt;/a&gt; that runs entirely in your browser. It runs on &lt;a href="https://pyodide.org/"&gt;Pyodide&lt;/a&gt;, which I think is still the most underappreciated project in the Python ecosystem.&lt;/p&gt;
&lt;p&gt;I built Datasette Lite &lt;a href="https://simonwillison.net/2022/May/4/datasette-lite/"&gt;almost three years ago&lt;/a&gt; as a weekend hack project to try and see if I could get Datasette - a server-side Python web application - to run entirely in the browser.&lt;/p&gt;
&lt;p&gt;I've added a bunch of features since then, &lt;a href="https://github.com/simonw/datasette-lite/blob/main/README.md"&gt;described in the README&lt;/a&gt; - most significantly the ability to load SQLite databases, CSV files, JSON files or Parquet files by passing a URL to a query string parameter.&lt;/p&gt;
&lt;p&gt;I built Datasette Lite almost as a joke, thinking nobody would want to wait for a full Python interpreter to download to their browser each time they wanted to explore some data. It turns out internet connections are fast these days and having a version of Datasette that needs a browser, GitHub Pages and &lt;em&gt;nothing else&lt;/em&gt; is actually extremely useful.&lt;/p&gt;
&lt;p&gt;Just the other day &lt;a href="https://bsky.app/profile/obtusatum.bsky.social/post/3lhyeuqmpns22"&gt;I saw Logan Williams&lt;/a&gt; of Bellingcat using it to share a better version of &lt;a href="https://www.commerce.senate.gov/2025/2/cruz-led-investigation-uncovers-2-billion-in-woke-dei-grants-at-nsf-releases-full-database"&gt;this Excel sheet&lt;/a&gt;:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;The NSF grants that Ted Cruz has singled out for advancing "neo-Marxist class warfare propaganda," in Datasette-Lite: &lt;a href="https://lite.datasette.io/?url=https://data-house-lake.nyc3.cdn.digitaloceanspaces.com/cruz_nhs.db#/cruz_nhs/grants"&gt;lite.datasette.io?url=https://...&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Let's look at that URL in full:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;https://lite.datasette.io/?url=https://data-house-lake.nyc3.cdn.digitaloceanspaces.com/cruz_nhs.db#/cruz_nhs/grants&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;?url=&lt;/code&gt; parameter there poins to a SQLite database file, hosted on DigitalOcean Spaces and served with the all-important &lt;code&gt;access-control-allow-origin: *&lt;/code&gt; header which allows Datasette Lite to load it across domains.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;#/cruz_nhs/grants&lt;/code&gt; part of the URL tells Datasette Lite which page to load when you visit the link.&lt;/p&gt;
&lt;p&gt;Anything after the &lt;code&gt;#&lt;/code&gt; in Datasette Lite is a URL that gets passed on to the WebAssembly-hosted Datasette instance. Any query string items before that can be used to affect the initial state of the Datasette instance, to import data or even to install additional plugins.&lt;/p&gt;
&lt;h4 id="the-datasette-1-0-alphas"&gt;The Datasette 1.0 alphas&lt;/h4&gt;

&lt;p&gt;I've shipped &lt;em&gt;a lot&lt;/em&gt; of Datasette alphas - the most recent is &lt;a href="https://docs.datasette.io/en/latest/changelog.html#a17-2025-02-06"&gt;Datasette 1.0a17&lt;/a&gt;. Those alphas get published to &lt;a href="https://pypi.org/"&gt;PyPI&lt;/a&gt;, which means they can be installed using &lt;code&gt;pip install datasette==1.0a17&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;A while back &lt;a href="https://github.com/simonw/datasette-lite/issues/75"&gt;I added the same ability&lt;/a&gt; to Datasette Lite itself. You can now pass &lt;code&gt;&amp;amp;ref=1.0a17&lt;/code&gt; to the Datasette Lite URL to load that specific version of Datasette.&lt;/p&gt;
&lt;p&gt;This works thanks to the magic of Pyodide's &lt;a href="https://micropip.pyodide.org/"&gt;micropip&lt;/a&gt; mechanism. Every time you load Datasette Lite in your browser it's actually using &lt;code&gt;micropip&lt;/code&gt; to install the packages it needs directly from PyPI. The code looks something like this:&lt;/p&gt;
&lt;div class="highlight highlight-source-js"&gt;&lt;pre&gt;&lt;span class="pl-k"&gt;await&lt;/span&gt; &lt;span class="pl-s1"&gt;pyodide&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;loadPackage&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s"&gt;'micropip'&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt;&lt;span class="pl-c1"&gt;messageCallback&lt;/span&gt;: &lt;span class="pl-s1"&gt;log&lt;/span&gt;&lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
&lt;span class="pl-k"&gt;let&lt;/span&gt; &lt;span class="pl-s1"&gt;datasetteToInstall&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s"&gt;'datasette'&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
&lt;span class="pl-k"&gt;let&lt;/span&gt; &lt;span class="pl-s1"&gt;pre&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s"&gt;'False'&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
&lt;span class="pl-k"&gt;if&lt;/span&gt; &lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;settings&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;ref&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt;
  &lt;span class="pl-k"&gt;if&lt;/span&gt; &lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;settings&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;ref&lt;/span&gt; &lt;span class="pl-c1"&gt;==&lt;/span&gt; &lt;span class="pl-s"&gt;'pre'&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt;
    &lt;span class="pl-s1"&gt;pre&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s"&gt;'True'&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
  &lt;span class="pl-kos"&gt;}&lt;/span&gt; &lt;span class="pl-k"&gt;else&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt;
    &lt;span class="pl-s1"&gt;datasetteToInstall&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s"&gt;`datasette==&lt;span class="pl-s1"&gt;&lt;span class="pl-kos"&gt;${&lt;/span&gt;&lt;span class="pl-s1"&gt;settings&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;ref&lt;/span&gt;&lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;/span&gt;`&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
  &lt;span class="pl-kos"&gt;}&lt;/span&gt;
&lt;span class="pl-kos"&gt;}&lt;/span&gt;
&lt;span class="pl-k"&gt;await&lt;/span&gt; &lt;span class="pl-s1"&gt;self&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;pyodide&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;runPythonAsync&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s"&gt;`&lt;/span&gt;
&lt;span class="pl-s"&gt;import micropip&lt;/span&gt;
&lt;span class="pl-s"&gt;await micropip.install("&lt;span class="pl-s1"&gt;&lt;span class="pl-kos"&gt;${&lt;/span&gt;&lt;span class="pl-s1"&gt;datasetteToInstall&lt;/span&gt;&lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;/span&gt;", pre=&lt;span class="pl-s1"&gt;&lt;span class="pl-kos"&gt;${&lt;/span&gt;&lt;span class="pl-s1"&gt;pre&lt;/span&gt;&lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;/span&gt;)&lt;/span&gt;
&lt;span class="pl-s"&gt;`&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;&lt;a href="https://github.com/simonw/datasette-lite/blob/main/webworker.js"&gt;Full code here&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;That &lt;code&gt;settings&lt;/code&gt; object has been passed to the Web Worker that loads Datasette, incorporating various query string parameters.&lt;/p&gt;
&lt;p&gt;This all means I can pass &lt;code&gt;?ref=1.0a17&lt;/code&gt; to Datasette Lite to load a specific version, or &lt;code&gt;?ref=pre&lt;/code&gt; to get the most recently released pre-release version.&lt;/p&gt;
&lt;h4 id="this-works-for-plugins-too"&gt;This works for plugins, too&lt;/h4&gt;
&lt;p&gt;Since loading extra packages from PyPI via &lt;code&gt;micropip&lt;/code&gt; is so easy, I went a step further and added plugin support.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;?install=&lt;/code&gt; parameter can be passed multiple times, each time specifying a Datasette plugin from PyPI that should be installed into the browser.&lt;/p&gt;
&lt;p&gt;The README includes &lt;a href="https://github.com/simonw/datasette-lite?tab=readme-ov-file#installing-plugins"&gt;a bunch of examples&lt;/a&gt; of this mechanism in action. Here's a fun one &lt;a href="https://lite.datasette.io/?install=datasette-mp3-audio&amp;amp;csv=https://gist.githubusercontent.com/simonw/0a30d52feeb3ff60f7d8636b0bde296b/raw/c078a9e5a0151331e2e46c04c1ebe7edc9f45e8c/scotrail-announcements.csv#/data/scotrail-announcements"&gt;that loads datasette-mp3-audio&lt;/a&gt; to provide inline MP3 playing widgets, originally created for my &lt;a href="https://simonwillison.net/2022/Aug/21/scotrail/"&gt;ScotRail audio announcements&lt;/a&gt; project.&lt;/p&gt;
&lt;p&gt;This only works for some plugins. They need to be pure Python wheels - getting plugins with compiled binary dependencies to work in Pyodide WebAssembly requires a whole set of steps that I haven't quite figured out.&lt;/p&gt;
&lt;p&gt;Frustratingly, it doesn't work for plugins that run their own JavaScript yet! I may need to rearchitect significant chunks of both Datasette and Datasette Lite to make that work.&lt;/p&gt;
&lt;p&gt;It's also worth noting that this is a remote code execution security hole. I don't think that's a problem here, because &lt;code&gt;lite.datasette.io&lt;/code&gt; is deliberately hosted on the subdomain of a domain that I &lt;em&gt;never&lt;/em&gt; intend to use cookies on. It's possible to vandalize the visual display of &lt;code&gt;lite.datasette.io&lt;/code&gt; but it shouldn't be possible to steal any private data or do any lasting damage.&lt;/p&gt;
&lt;h4 id="datasette-visible-internal-db"&gt;datasette-visible-internal-db&lt;/h4&gt;
&lt;p&gt;This evening's debugging exercise used a plugin called &lt;a href="https://pypi.org/project/datasette-visible-internal-db/"&gt;datasette-visible-internal-db&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Datasette's &lt;a href="https://docs.datasette.io/en/latest/internals.html#datasette-s-internal-database"&gt;internal database&lt;/a&gt; is an invisible SQLite database that sits at the heart of Datasette, tracking things like loaded metadata and the schemas of the currently attached tables.&lt;/p&gt;
&lt;p&gt;Being invisible means we can use it for features that shouldn't be visible to users - plugins that record API secrets or permissions or track comments or data import progress, for example.&lt;/p&gt;
&lt;p&gt;In Python code it's accessed like this:&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-s1"&gt;internal_db&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;datasette&lt;/span&gt;.&lt;span class="pl-c1"&gt;get_internal_database&lt;/span&gt;()&lt;/pre&gt;
&lt;p&gt;As opposed to Datasette's other databases which are accessed like so:&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-s1"&gt;db&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;datasette&lt;/span&gt;.&lt;span class="pl-c1"&gt;get_database&lt;/span&gt;(&lt;span class="pl-s"&gt;"my-database"&lt;/span&gt;)&lt;/pre&gt;
&lt;p&gt;Sometimes, when hacking on Datasette, it's useful to be able to browse the internal database using the default Datasette UI.&lt;/p&gt;
&lt;p&gt;That's what &lt;code&gt;datasette-visible-internal-db&lt;/code&gt; does. The plugin implementation is &lt;a href="https://github.com/datasette/datasette-visible-internal-db/blob/759e7001f91d3076d9f42eddb03fbaf6d1c7b9bb/datasette_visible_internal_db.py"&gt;just five lines of code&lt;/a&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-k"&gt;import&lt;/span&gt; &lt;span class="pl-s1"&gt;datasette&lt;/span&gt;

&lt;span class="pl-en"&gt;@&lt;span class="pl-s1"&gt;datasette&lt;/span&gt;.&lt;span class="pl-c1"&gt;hookimpl&lt;/span&gt;&lt;/span&gt;
&lt;span class="pl-k"&gt;def&lt;/span&gt; &lt;span class="pl-en"&gt;startup&lt;/span&gt;(&lt;span class="pl-s1"&gt;datasette&lt;/span&gt;):
    &lt;span class="pl-s1"&gt;db&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;datasette&lt;/span&gt;.&lt;span class="pl-c1"&gt;get_internal_database&lt;/span&gt;()
    &lt;span class="pl-s1"&gt;datasette&lt;/span&gt;.&lt;span class="pl-c1"&gt;add_database&lt;/span&gt;(&lt;span class="pl-s1"&gt;db&lt;/span&gt;, &lt;span class="pl-s1"&gt;name&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-s"&gt;"_internal"&lt;/span&gt;, &lt;span class="pl-s1"&gt;route&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-s"&gt;"_internal"&lt;/span&gt;)&lt;/pre&gt;
&lt;p&gt;On startup the plugin grabs a reference to that internal database and then registers it using Datasette's &lt;a href="https://docs.datasette.io/en/latest/internals.html#add-database-db-name-none-route-none"&gt;add_database() method&lt;/a&gt;. That's all it takes to have it show up as a visible database on the &lt;code&gt;/_internal&lt;/code&gt; path within Datasette.&lt;/p&gt;
&lt;h4 id="spotting-the-bug"&gt;Spotting the bug&lt;/h4&gt;
&lt;p&gt;I was poking around with this today out of pure curiosity - I hadn't tried &lt;code&gt;?install=datasette-visible-internal-db&lt;/code&gt; with Datasette Lite before and I wanted to see if it worked.&lt;/p&gt;
&lt;p&gt;Here's &lt;a href="https://lite.datasette.io/?install=datasette-visible-internal-db&amp;amp;ref=1.0a17#/_internal/catalog_columns?_facet=database_name"&gt;that URL from earlier&lt;/a&gt;, this time with commentary:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;https://lite.datasette.io/ // Datasette Lite
  ?install=datasette-visible-internal-db // Install the visible internal DB plugin
  &amp;amp;ref=1.0a17 // Load the 1.0a17 alpha release
  #/_internal/catalog_columns // Navigate to the /_internal/catalog_columns table page
  &amp;amp;_facet=database_name // Facet by database_name for good measure
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And this is what I saw:&lt;/p&gt;
&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2025/datasette-lite-bug.jpg" alt="Screenshot of Datasette Lite. catalog_columns table has 382 rows. database_name facet shows content 237, fixtures 97, _internal 48. A table shows columns for Link, database_name, table_name, cid and name - a red arrow points to a hyperlinked _internal in the database_name column." style="max-width: 100%;" /&gt;&lt;/p&gt;
&lt;p&gt;This all looked good... until I clicked on that &lt;code&gt;_internal&lt;/code&gt; link in the &lt;code&gt;database_name&lt;/code&gt; column... and it took me to &lt;a href="https://lite.datasette.io/?install=datasette-visible-internal-db&amp;amp;ref=1.0a17#/_internal/databases/_internal"&gt;this /_internal/databases/_internal 404 page&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Why was that a 404? Datasette introspects the SQLite table schema to identify foreign key relationships, then turns those into hyperlinks. The SQL schema for that &lt;code&gt;catalog_columns&lt;/code&gt; table (displayed at the bottom of the table page) looked like this:&lt;/p&gt;
&lt;div class="highlight highlight-source-sql"&gt;&lt;pre&gt;&lt;span class="pl-k"&gt;CREATE&lt;/span&gt; &lt;span class="pl-k"&gt;TABLE&lt;/span&gt; &lt;span class="pl-en"&gt;catalog_columns&lt;/span&gt; (
    database_name &lt;span class="pl-k"&gt;TEXT&lt;/span&gt;,
    table_name &lt;span class="pl-k"&gt;TEXT&lt;/span&gt;,
    cid &lt;span class="pl-k"&gt;INTEGER&lt;/span&gt;,
    name &lt;span class="pl-k"&gt;TEXT&lt;/span&gt;,
    type &lt;span class="pl-k"&gt;TEXT&lt;/span&gt;,
    &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;notnull&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt; &lt;span class="pl-k"&gt;INTEGER&lt;/span&gt;,
    default_value &lt;span class="pl-k"&gt;TEXT&lt;/span&gt;, &lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;--&lt;/span&gt; renamed from dflt_value&lt;/span&gt;
    is_pk &lt;span class="pl-k"&gt;INTEGER&lt;/span&gt;, &lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;--&lt;/span&gt; renamed from pk&lt;/span&gt;
    hidden &lt;span class="pl-k"&gt;INTEGER&lt;/span&gt;,
    &lt;span class="pl-k"&gt;PRIMARY KEY&lt;/span&gt; (database_name, table_name, name),
    &lt;span class="pl-k"&gt;FOREIGN KEY&lt;/span&gt; (database_name) &lt;span class="pl-k"&gt;REFERENCES&lt;/span&gt; databases(database_name),
    &lt;span class="pl-k"&gt;FOREIGN KEY&lt;/span&gt; (database_name, table_name) &lt;span class="pl-k"&gt;REFERENCES&lt;/span&gt; tables(database_name, table_name)
);&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Those foreign key references are a bug! I renamed the internal tables from &lt;code&gt;databases&lt;/code&gt; and &lt;code&gt;tables&lt;/code&gt; to &lt;code&gt;catalog_databases&lt;/code&gt; and &lt;code&gt;catalog_tables&lt;/code&gt; quite a while ago, but apparently forgot to update the references - and SQLite let me get away with it.&lt;/p&gt;
&lt;h4 id="fixing-the-bug"&gt;Fixing the bug&lt;/h4&gt;
&lt;p&gt;I fixed the bug &lt;a href="https://github.com/simonw/datasette/commit/e59fd0175708f2b14d4e3c08ea16631bda0aaed3"&gt;in this commit&lt;/a&gt;. As is often the case the most interesting part of the fix is &lt;a href="https://github.com/simonw/datasette/blob/e59fd0175708f2b14d4e3c08ea16631bda0aaed3/tests/test_internal_db.py#L65-L84"&gt;the accompanying test&lt;/a&gt;. I decided to use the introspection helpers in &lt;a href="https://sqlite-utils.datasette.io/"&gt;sqlite-utils&lt;/a&gt; to guard against every making another mistake like this again in the future:&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-en"&gt;@&lt;span class="pl-s1"&gt;pytest&lt;/span&gt;.&lt;span class="pl-c1"&gt;mark&lt;/span&gt;.&lt;span class="pl-c1"&gt;asyncio&lt;/span&gt;&lt;/span&gt;
&lt;span class="pl-k"&gt;async&lt;/span&gt; &lt;span class="pl-k"&gt;def&lt;/span&gt; &lt;span class="pl-en"&gt;test_internal_foreign_key_references&lt;/span&gt;(&lt;span class="pl-s1"&gt;ds_client&lt;/span&gt;):
    &lt;span class="pl-s1"&gt;internal_db&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-k"&gt;await&lt;/span&gt; &lt;span class="pl-en"&gt;ensure_internal&lt;/span&gt;(&lt;span class="pl-s1"&gt;ds_client&lt;/span&gt;)
    &lt;span class="pl-k"&gt;def&lt;/span&gt; &lt;span class="pl-en"&gt;inner&lt;/span&gt;(&lt;span class="pl-s1"&gt;conn&lt;/span&gt;):
        &lt;span class="pl-s1"&gt;db&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;sqlite_utils&lt;/span&gt;.&lt;span class="pl-c1"&gt;Database&lt;/span&gt;(&lt;span class="pl-s1"&gt;conn&lt;/span&gt;)
        &lt;span class="pl-s1"&gt;table_names&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;db&lt;/span&gt;.&lt;span class="pl-c1"&gt;table_names&lt;/span&gt;()
        &lt;span class="pl-k"&gt;for&lt;/span&gt; &lt;span class="pl-s1"&gt;table&lt;/span&gt; &lt;span class="pl-c1"&gt;in&lt;/span&gt; &lt;span class="pl-s1"&gt;db&lt;/span&gt;.&lt;span class="pl-c1"&gt;tables&lt;/span&gt;:
            &lt;span class="pl-k"&gt;for&lt;/span&gt; &lt;span class="pl-s1"&gt;fk&lt;/span&gt; &lt;span class="pl-c1"&gt;in&lt;/span&gt; &lt;span class="pl-s1"&gt;table&lt;/span&gt;.&lt;span class="pl-c1"&gt;foreign_keys&lt;/span&gt;:
                &lt;span class="pl-s1"&gt;other_table&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;fk&lt;/span&gt;.&lt;span class="pl-c1"&gt;other_table&lt;/span&gt;
                &lt;span class="pl-s1"&gt;other_column&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;fk&lt;/span&gt;.&lt;span class="pl-c1"&gt;other_column&lt;/span&gt;
                &lt;span class="pl-s1"&gt;message&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s"&gt;'Column "{}.{}" references other column "{}.{}" which does not exist'&lt;/span&gt;.&lt;span class="pl-c1"&gt;format&lt;/span&gt;(
                    &lt;span class="pl-s1"&gt;table&lt;/span&gt;.&lt;span class="pl-c1"&gt;name&lt;/span&gt;, &lt;span class="pl-s1"&gt;fk&lt;/span&gt;.&lt;span class="pl-c1"&gt;column&lt;/span&gt;, &lt;span class="pl-s1"&gt;other_table&lt;/span&gt;, &lt;span class="pl-s1"&gt;other_column&lt;/span&gt;
                )
                &lt;span class="pl-k"&gt;assert&lt;/span&gt; &lt;span class="pl-s1"&gt;other_table&lt;/span&gt; &lt;span class="pl-c1"&gt;in&lt;/span&gt; &lt;span class="pl-s1"&gt;table_names&lt;/span&gt;, &lt;span class="pl-s1"&gt;message&lt;/span&gt; &lt;span class="pl-c1"&gt;+&lt;/span&gt; &lt;span class="pl-s"&gt;" (bad table)"&lt;/span&gt;
                &lt;span class="pl-k"&gt;assert&lt;/span&gt; &lt;span class="pl-s1"&gt;other_column&lt;/span&gt; &lt;span class="pl-c1"&gt;in&lt;/span&gt; &lt;span class="pl-s1"&gt;db&lt;/span&gt;[&lt;span class="pl-s1"&gt;other_table&lt;/span&gt;].&lt;span class="pl-c1"&gt;columns_dict&lt;/span&gt;, (
                    &lt;span class="pl-s1"&gt;message&lt;/span&gt; &lt;span class="pl-c1"&gt;+&lt;/span&gt; &lt;span class="pl-s"&gt;" (bad column)"&lt;/span&gt;
                )
    &lt;span class="pl-k"&gt;await&lt;/span&gt; &lt;span class="pl-s1"&gt;internal_db&lt;/span&gt;.&lt;span class="pl-c1"&gt;execute_fn&lt;/span&gt;(&lt;span class="pl-s1"&gt;inner&lt;/span&gt;)&lt;/pre&gt;
&lt;p&gt;This uses Datasette's &lt;a href="https://docs.datasette.io/en/latest/internals.html#await-db-execute-fn-fn"&gt;await db.execute_fn()&lt;/a&gt; method, which lets you run Python code that accesses SQLite in a thread. That code can then use the blocking &lt;code&gt;sqlite-utils&lt;/code&gt; &lt;a href="https://sqlite-utils.datasette.io/en/stable/python-api.html#introspecting-tables-and-views"&gt;introspection methods&lt;/a&gt; - here I'm looping through every table in that internal database, looping through each tables &lt;code&gt;.foreign_keys&lt;/code&gt; and confirming that the &lt;code&gt;.other_table&lt;/code&gt; and &lt;code&gt;.other_column&lt;/code&gt; values reference a table and column that genuinely exist.&lt;/p&gt;
&lt;p&gt;I ran this test, watched it fail, then applied the fix and it passed.&lt;/p&gt;
&lt;h4 id="url-addressable-steps-to-reproduce"&gt;URL-addressable Steps To Reproduce&lt;/h4&gt;
&lt;p&gt;The idea I most wanted to highlight here is the enormous value provided by &lt;strong&gt;URL-addressable Steps To Reproduce&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Having good Steps To Reproduce is crucial for productively fixing bugs. Something you can &lt;em&gt;click on&lt;/em&gt; to see the bug is the most effective form of STR there is.&lt;/p&gt;
&lt;p&gt;Ideally, these URLs will continue to work long into the future.&lt;/p&gt;
&lt;p&gt;The great thing about a system like Datasette Lite is that everything is statically hosted files. The application itself is hosted on GitHub Pages, and it works by loading additional files from various different CDNs. The only dynamic aspect is cached lookups against the PyPI API, which I expect to stay stable for a long time to come.&lt;/p&gt;
&lt;p&gt;As a stable component of the Web platform &lt;a href="https://caniuse.com/wasm"&gt;for almost 8 years&lt;/a&gt; WebAssembly is clearly here to stay. I expect we'll be able to execute today's WASM code in browsers 20+ years from now.&lt;/p&gt;
&lt;p&gt;I'm confident that the patterns I've been exploring in Datasette Lite over the past few years could be just as valuable for other projects. Imagine demonstrating bugs in a Django application using a static WebAssembly build, archived forever as part of an issue tracking system.&lt;/p&gt;
&lt;p&gt;I think WebAssembly and Pyodide still have a great deal of untapped potential for the wider Python world.&lt;/p&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/python"&gt;python&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/urls"&gt;urls&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette"&gt;datasette&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/webassembly"&gt;webassembly&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/pyodide"&gt;pyodide&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/datasette-lite"&gt;datasette-lite&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="python"/><category term="urls"/><category term="datasette"/><category term="webassembly"/><category term="pyodide"/><category term="datasette-lite"/></entry></feed>